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; } }