Files
2026-11-06 13:37:12 +08:00

256 lines
17 KiB
PHP
Executable File

<?php
declare(strict_types=1);
namespace app\adminapi\service\iam\v2;
use RuntimeException;
use think\facade\Db;
use think\facade\Config;
use app\adminapi\logic\auth\AdminLogic;
final class IamV2Manager
{
public function __construct(private ?IamV2Client $client = null) { $this->client ??= new IamV2Client(); }
public function binding(int $id, bool $fresh = false): array
{
$row = Db::name('iam_v2_binding')->where('admin_id', $id)->find();
if (!$fresh && $row && $row['checked_at'] > time() - 30) {
$cached = json_decode($row['payload'], true, 32, JSON_THROW_ON_ERROR);
$cached['checked_at'] = (int) $row['checked_at'];
return $cached;
}
$result = $this->client->call('GET', '/bindings/by-external/' . $id);
if (($result['binding']['externalAccountId'] ?? (string) $id) !== (string) $id) { throw new RuntimeException('ACCOUNT_MISMATCH'); }
if ($result['binding'] ?? null) {
$b = $result['binding']; $employee = $result['employee'] ?? [];
if (($b['applicationId'] ?? '') !== (string) Config::get('iam.application_id', 'zyt')
|| !is_int($b['version'] ?? null) || $b['version'] < 1
|| !in_array($b['state'] ?? '', ['verified','revoked','pending','conflict'], true)
|| empty($employee['id']) || ($b['employeeId'] ?? '') !== $employee['id'] || empty($employee['oidcSubject'])) { throw new RuntimeException('INVALID_RESPONSE'); }
}
$state = $result['binding']['state'] ?? 'unlinked';
$version = (int) ($result['binding']['version'] ?? 0);
if ($row && $row['state'] === 'revoked' && $state === 'verified' && $version <= (int) $row['version']) { throw new RuntimeException('BINDING_CONFLICT'); }
if ($row && (int) $row['version'] > $version) { throw new RuntimeException('BINDING_CONFLICT'); }
$values = ['admin_id' => $id, 'employee_id' => $result['employee']['id'] ?? '', 'subject' => $result['employee']['oidcSubject'] ?? '',
'state' => $state, 'version' => $version, 'payload' => json_encode($result, JSON_THROW_ON_ERROR), 'checked_at' => time()];
if ($row) { Db::name('iam_v2_binding')->where('admin_id', $id)->update($values); }
else { Db::name('iam_v2_binding')->insert($values); }
$changed = $row && $row['state'] === 'verified' && (($row['employee_id'] ?? '') !== ($values['employee_id'] ?? '') || (int) $row['version'] !== $version);
if ($state === 'revoked' || (($result['employee'] ?? null) && (($result['employee']['status'] ?? '') !== 'active' || !($result['access']['allowed'] ?? false)))) {
self::revoke($id);
} elseif ($changed) {
IamV2Source::revokeGeneration($id, (int) $row['version'], (string) $row['employee_id']);
}
$result['checked_at'] = $values['checked_at'];
return $result;
}
public static function revoke(int $id): void
{
IamV2Source::revokeAdmin($id);
foreach (Db::name('admin_session')->where('admin_id', $id)->column('token') as $token) { AdminLogic::expireToken($token); }
}
public function assertLinked(int $id): array
{
$b = $this->binding($id, true);
if (($b['binding']['state'] ?? '') !== 'verified') { throw new RuntimeException('NOT_LINKED'); }
if (($b['employee']['status'] ?? '') !== 'active' || !($b['access']['allowed'] ?? false)) { throw new RuntimeException('NOT_AUTHORIZED'); }
return $b;
}
public function accounts(array $query, int $actor): array
{
$page = max(1, (int) ($query['page'] ?? 1)); $size = min(20, max(1, (int) ($query['pageSize'] ?? 20)));
$db = Db::name('admin')->whereNull('delete_time');
$visible = IamV2Source::visibleIds($actor);
if ($visible !== null) { $db->whereIn('id', $visible ?: [0]); }
if (($query['q'] ?? '') !== '') { $db->whereLike('account|name', '%' . mb_substr((string) $query['q'], 0, 64) . '%'); }
if (($query['state'] ?? '') !== '') {
$ids = Db::name('iam_v2_binding')->where('state', (string) $query['state'])->column('admin_id');
if ($query['state'] === 'unknown') { $db->whereNotIn('id', Db::name('iam_v2_binding')->column('admin_id')); }
else { $db->whereIn('id', $ids ?: [0]); }
}
$total = (clone $db)->count();
$items = $db->field('id,account,name,disable,root')->page($page, $size)->select()->toArray();
// Resolve department labels only for already-scoped rows, never broaden the account query.
$departmentNames = [];
if ($items !== []) {
$links = Db::name('admin_dept')->whereIn('admin_id', array_column($items, 'id'))
->field('admin_id,dept_id')->order('dept_id', 'asc')->select()->toArray();
$ids = array_values(array_unique(array_column($links, 'dept_id')));
$labels = $ids ? Db::name('dept')->whereIn('id', $ids)->whereNull('delete_time')->column('name', 'id') : [];
foreach ($links as $link) {
if (isset($labels[$link['dept_id']])) { $departmentNames[$link['admin_id']][$link['dept_id']] = (string) $labels[$link['dept_id']]; }
}
}
foreach ($items as &$r) {
$r['dept_names'] = array_values($departmentNames[$r['id']] ?? []);
try { $r['iam'] = $this->binding((int) $r['id']); $r['iam']['state'] = $r['iam']['binding']['state'] ?? 'unlinked'; }
catch (\Throwable $e) {
$last = Db::name('iam_v2_binding')->where('admin_id', $r['id'])->value('checked_at');
$reason = in_array($e->getMessage(), ['BINDING_CONFLICT','ACCOUNT_MISMATCH','ACCOUNT_DISABLED','NOT_AUTHORIZED'], true)
? $e->getMessage() : 'SERVICE_UNAVAILABLE';
$r['iam'] = ['state' => 'unknown', 'error' => $reason, 'access' => ['allowed' => null, 'reason' => $reason],
'checked_at' => (int) $last > 0 ? (int) $last : null];
}
}
unset($r);
return ['items' => $items, 'total' => $total];
}
public static function input(array $input): array
{
$kind = (string) ($input['kind'] ?? '');
if (!in_array($kind, ['bind', 'create_and_bind', 'unbind', 'rebind', 'reconcile'], true)) { throw new RuntimeException('INVALID_OPERATION'); }
$id = (int) ($input['user_id'] ?? 0);
if ($id < 1 || strlen(trim((string) ($input['reason'] ?? ''))) < 3) { throw new RuntimeException('INVALID_INPUT'); }
$out = ['kind' => $kind, 'externalAccountId' => (string) $id, 'reason' => mb_strcut(trim($input['reason']), 0, 500, 'UTF-8')];
if (isset($input['employee_id'])) { $out['employeeId'] = (string) $input['employee_id']; }
if (isset($input['expected_version'])) { $out['expectedVersion'] = (int) $input['expected_version']; }
if (in_array($kind, ['unbind','rebind'], true) && !isset($out['expectedVersion'])) { throw new RuntimeException('BINDING_CONFLICT'); }
if ($kind === 'create_and_bind') {
$n = $input['new_employee'] ?? [];
$out['newEmployee'] = ['username' => (string) ($n['username'] ?? ''), 'displayName' => (string) ($n['display_name'] ?? '')];
if (!preg_match('/^[A-Za-z0-9_.@-]{3,64}$/D', $out['newEmployee']['username']) || trim($out['newEmployee']['displayName']) === '') { throw new RuntimeException('INVALID_INPUT'); }
if (!empty($n['email'])) {
if (!filter_var($n['email'], FILTER_VALIDATE_EMAIL)) { throw new RuntimeException('INVALID_INPUT'); }
$out['newEmployee']['email'] = $n['email'];
}
}
return $out;
}
public static function cap(string $kind): string { return ['create_and_bind' => 'create', 'reconcile' => 'bind'][$kind] ?? $kind; }
public function operation(array $session, array $input, string $requestId): array
{
$body = self::input($input); $target = (int) $body['externalAccountId']; $cap = self::cap($body['kind']);
IamV2Source::authorize($session, $cap, [$target], true, true);
$current = $this->binding($target, true); $caps = ['iam:' . $cap];
if ($body['kind'] === 'create_and_bind' && ($current['binding']['state'] ?? '') === 'revoked') {
if (!isset($body['expectedVersion'])) { throw new RuntimeException('BINDING_CONFLICT'); }
IamV2Source::authorize($session, 'rebind', [$target], true, true); $caps[] = 'iam:rebind';
}
$body['proof'] = IamV2Source::proof($session, 'management', [$target], $caps, null, ['protectedReviewEligible' => IamV2Source::reviewOperator((int) $session['admin_id'])]);
$op = $this->client->call('POST', '/operations', $body, $requestId);
$op = $this->reviewLink($op);
$this->remember('operation', (string) $op['id'], $session, [$target], $caps);
if (($op['status'] ?? '') === 'succeeded') { $this->binding($target, true); }
return $op;
}
private function reviewLink(array $operation): array
{
if (!isset($operation['reviewUrl'])) { return $operation; }
$origin = IamV2Client::origin((string) Config::get('iam.portal_url'));
$parts = parse_url((string) $operation['reviewUrl']);
$fixed = parse_url($origin);
if (!$parts || ($parts['scheme'] ?? '') !== 'https' || ($parts['host'] ?? '') !== $fixed['host']
|| ($parts['port'] ?? 443) !== ($fixed['port'] ?? 443) || ($parts['path'] ?? '') !== '/admin/iam/review'
|| isset($parts['user']) || isset($parts['pass']) || isset($parts['fragment'])) { throw new RuntimeException('INVALID_FIXED_URL'); }
parse_str($parts['query'] ?? '', $query);
if (count($query) !== 1 || ($query['request'] ?? null) !== ($operation['id'] ?? null)) { throw new RuntimeException('INVALID_FIXED_URL'); }
return $operation;
}
public function remember(string $kind, string $id, array $session, array $targets, array $caps = []): void
{
$key = hash('sha256', $kind . ':' . $id);
$existing = Db::name('iam_v2_resource')->where('resource_key', $key)->find();
if ($existing) { if ((int) $existing['actor_id'] !== (int) $session['admin_id']) { throw new RuntimeException('FORBIDDEN'); } return; }
Db::name('iam_v2_resource')->insert(['resource_key' => $key, 'kind' => $kind, 'external_id' => $id, 'actor_id' => $session['admin_id'],
'payload' => json_encode(['targets' => $targets, 'capabilities' => $caps]), 'created_at' => time()]);
}
public function owned(string $kind, string $id, array $session): array
{
$r = Db::name('iam_v2_resource')->where('resource_key', hash('sha256', $kind . ':' . $id))->where('actor_id', $session['admin_id'])->find();
if (!$r) { throw new RuntimeException('FORBIDDEN'); }
$payload = json_decode($r['payload'], true, 32, JSON_THROW_ON_ERROR);
IamV2Source::scope((int) $session['admin_id'], $payload['targets']);
return $payload;
}
public function preflight(array $session, array $items): array
{
if (!$items || count($items) > 100) { throw new RuntimeException('INVALID_INPUT'); }
IamV2Source::authorize($session, 'batch'); $mapped = []; $targets = []; $caps = ['iam:batch']; $indexMap = []; $review = []; $itemCaps = [];
foreach ($items as $index => $input) {
try {
$item = self::input($input); $id = (int) $item['externalAccountId']; $cap = self::cap($item['kind']);
IamV2Source::authorize($session, $cap, [$id]);
$held = ['iam:' . $cap];
if ($item['kind'] === 'create_and_bind' && (($this->binding($id, true)['binding']['state'] ?? '') === 'revoked')) {
if (!isset($item['expectedVersion'])) { throw new RuntimeException('BINDING_CONFLICT'); }
IamV2Source::authorize($session, 'rebind', [$id]); $held[] = 'iam:rebind';
}
$indexMap[] = (int) $index; $mapped[] = $item; $targets[] = $id; $itemCaps[] = $held; $caps = array_merge($caps, $held);
} catch (RuntimeException $e) {
$review[] = ['index' => (int) $index, 'externalAccountId' => (string) ($input['user_id'] ?? ''),
'status' => $e->getMessage() === 'PROTECTED_ACCOUNT_REVIEW_REQUIRED' ? 'review' : 'invalid', 'message' => $e->getMessage()];
}
}
if (!$mapped) { return ['previewId' => null, 'items' => $review, 'expiresAt' => time()]; }
$r = $this->client->call('POST', '/batch/preflight', ['items' => $mapped,
'proof' => IamV2Source::proof($session, 'management', $targets, array_values(array_unique($caps)))]);
$this->remember('preview', (string) $r['previewId'], $session, $targets, $caps);
$resource = hash('sha256', 'preview:' . $r['previewId']);
$ready = [];
foreach ($r['items'] as &$item) {
$centralIndex = (int) $item['index'];
if (!array_key_exists($centralIndex, $indexMap)) { throw new RuntimeException('INVALID_RESPONSE'); }
$item['index'] = $indexMap[$centralIndex];
if ($item['status'] === 'ready') { $ready[] = $item['index']; }
}
unset($item);
Db::name('iam_v2_resource')->where('resource_key', $resource)->update(['payload' => json_encode([
'targets' => $targets, 'capabilities' => $caps, 'indexMap' => $indexMap, 'ready' => $ready, 'itemCaps' => $itemCaps])]);
$r['items'] = array_merge($r['items'], $review);
usort($r['items'], fn($a, $b) => $a['index'] <=> $b['index']); return $r;
}
public function batch(array $session, string $preview, array $indexes, string $key): array
{
$scope = $this->owned('preview', $preview, $session);
$indexes = array_values(array_unique(array_map('intval', $indexes)));
if (!$indexes || array_diff($indexes, $scope['ready'] ?? [])) { throw new RuntimeException('INVALID_INPUT'); }
$indexes = array_map(fn($i) => array_search($i, $scope['indexMap'], true), $indexes);
$targets = array_map(fn($i) => $scope['targets'][$i], $indexes); $caps = ['iam:batch']; $itemCaps = [];
foreach ($indexes as $i) { $itemCaps[$i] = $scope['itemCaps'][$i] ?? $scope['capabilities']; $caps = array_merge($caps, $itemCaps[$i]); }
$caps = array_values(array_unique($caps));
foreach ($caps as $cap) { IamV2Source::authorize($session, substr($cap, 4), $targets); }
$r = $this->client->call('POST', '/batch', ['previewId' => $preview, 'indexes' => $indexes,
'proof' => IamV2Source::proof($session, 'management', $targets, $caps)], $key);
$this->remember('job', (string) $r['id'], $session, $targets, $caps);
Db::name('iam_v2_resource')->where('resource_key', hash('sha256', 'job:' . $r['id']))->update(['payload' => json_encode([
'targets' => $targets, 'capabilities' => $caps, 'itemCaps' => $itemCaps, 'centralIndexes' => $indexes])]);
return $r;
}
/** Public retry indexes are job.items positions, not original central preview indexes. */
public static function retrySelection(array $job, array $positions = []): array
{
$items = $job['items'] ?? []; $central = $job['indexes'] ?? [];
if ($positions === []) { $positions = array_keys($items); }
$indexes = []; $targets = []; $caps = ['iam:batch'];
foreach (array_unique($positions) as $position) {
if (!is_int($position) || !isset($items[$position], $central[$position])) { throw new RuntimeException('INVALID_INPUT'); }
$item = $items[$position];
if (!in_array($item['status'] ?? '', ['failed', 'pending'], true)) { continue; }
$indexes[] = $central[$position]; $targets[] = (int) $item['externalAccountId']; $caps[] = 'iam:' . self::cap($item['kind']);
}
return ['indexes' => $indexes, 'targets' => $targets, 'capabilities' => array_values(array_unique($caps))];
}
public function result(array $session, string $kind, string $id, bool $retry = false, array $indexes = []): array
{
$scope = $this->owned($kind, $id, $session); IamV2Source::authorize($session, 'read', [], false);
$path = ($kind === 'job' ? '/jobs/' : '/operations/') . rawurlencode($id);
if ($retry) {
$job = $this->client->call('GET', $path);
// The saved mapping prevents the central endpoint changing this operation's target set.
if (!isset($job['indexes'])) { $job['indexes'] = $scope['centralIndexes'] ?? []; }
$selected = self::retrySelection($job, $indexes);
if (!$selected['indexes']) { return $job; }
if (array_diff($selected['targets'], array_map('intval', $scope['targets']))) { throw new RuntimeException('FORBIDDEN'); }
foreach ($selected['indexes'] as $i) { $selected['capabilities'] = array_merge($selected['capabilities'], $scope['itemCaps'][$i] ?? []); }
$selected['capabilities'] = array_values(array_unique($selected['capabilities']));
foreach ($selected['capabilities'] as $cap) { IamV2Source::authorize($session, substr($cap, 4), $selected['targets']); }
$r = $this->client->call('POST', $path . '/retry', ['indexes' => $selected['indexes'],
'proof' => IamV2Source::proof($session, 'management', $selected['targets'], $selected['capabilities'])]);
$scope['targets'] = $selected['targets'];
} else { $r = $this->client->call('GET', $path); }
foreach ($scope['targets'] as $target) { $this->binding((int) $target, true); }
return $kind === 'operation' ? $this->reviewLink($r) : $r;
}
}