From a32e167f8b211c87263e3ea47acba72e2d483cb0 Mon Sep 17 00:00:00 2001 From: long <452591453@qq.com> Date: Fri, 30 Oct 2026 11:50:55 +0800 Subject: [PATCH] fix(iam): expose scoped account metadata and avoid duplicate notices --- .../adminapi/controller/IamV2Controller.php | 2 +- .../adminapi/service/iam/v2/IamV2Manager.php | 34 ++++++++++++++++--- server/tests/IamV2RouteContractTest.php | 1 + server/tests/IamV2SourceTest.php | 22 +++++++++++- 4 files changed, 52 insertions(+), 7 deletions(-) diff --git a/server/app/adminapi/controller/IamV2Controller.php b/server/app/adminapi/controller/IamV2Controller.php index 1feddd653..8f16e5ed8 100644 --- a/server/app/adminapi/controller/IamV2Controller.php +++ b/server/app/adminapi/controller/IamV2Controller.php @@ -26,7 +26,7 @@ final class IamV2Controller extends BaseAdminController return $this->data($fn())->header(['Cache-Control' => 'no-store', 'Referrer-Policy' => 'no-referrer']); } catch (\Throwable $e) { $code = preg_match('/^[A-Z_]{3,64}$/D', $e->getMessage()) ? $e->getMessage() : 'SERVICE_UNAVAILABLE'; - return $this->fail($code, ['error' => ['code' => $code]])->header(['Cache-Control' => 'no-store']); + return $this->fail($code, ['error' => ['code' => $code]], 0, 0)->header(['Cache-Control' => 'no-store']); } } private function session(bool $recent = false): array diff --git a/server/app/adminapi/service/iam/v2/IamV2Manager.php b/server/app/adminapi/service/iam/v2/IamV2Manager.php index a009ef43c..f65aa14b4 100644 --- a/server/app/adminapi/service/iam/v2/IamV2Manager.php +++ b/server/app/adminapi/service/iam/v2/IamV2Manager.php @@ -14,7 +14,11 @@ final class IamV2Manager 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) { return json_decode($row['payload'], true, 32, JSON_THROW_ON_ERROR); } + 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) { @@ -38,7 +42,8 @@ final class IamV2Manager } elseif ($changed) { IamV2Source::revokeGeneration($id, (int) $row['version'], (string) $row['employee_id']); } - return $result + ['checked_at' => time()]; + $result['checked_at'] = $values['checked_at']; + return $result; } public static function revoke(int $id): void { @@ -66,10 +71,29 @@ final class IamV2Manager } $total = (clone $db)->count(); $items = $db->field('id,account,name,disable,root')->page($page, $size)->select()->toArray(); - foreach ($items as &$r) { - try { $r['iam'] = $this->binding((int) $r['id']); $r['iam']['state'] = $r['iam']['binding']['state'] ?? 'unlinked'; } - catch (\Throwable $e) { $r['iam'] = ['state' => 'unknown', 'error' => 'SERVICE_UNAVAILABLE']; } + // 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 diff --git a/server/tests/IamV2RouteContractTest.php b/server/tests/IamV2RouteContractTest.php index 8ef8aaa18..f57815c3c 100644 --- a/server/tests/IamV2RouteContractTest.php +++ b/server/tests/IamV2RouteContractTest.php @@ -28,4 +28,5 @@ $login = file_get_contents($root . '/app/adminapi/service/iam/IamLoginService.ph requireContract(strpos($login, '->assertLinked(') < strpos($login, '$result = (array) (new LoginLogic())->login'), 'binding sync precedes new token issuance'); $provisioner = file_get_contents($root . '/app/adminapi/service/iam/IamAccountProvisioner.php'); requireContract(strpos($provisioner, 'return IamLoginService::boundAdminId') < strpos($provisioner, 'IAM_BINDING_REVOKED'), 'explicit central binding precedes local auto-create tombstone'); +requireContract(str_contains($controller, "\$this->fail(\$code, ['error' => ['code' => \$code]], 0, 0)"), 'IAM structured errors suppress shared toast without changing other interfaces'); echo "PASS IamV2RouteContractTest: fixed POST callbacks, auto-route method gate, origin/service authentication, live proof authorization, target scope, dedicated TLS client, default-off, personal OIDC proof\n"; diff --git a/server/tests/IamV2SourceTest.php b/server/tests/IamV2SourceTest.php index d751767d6..74a4b1df0 100644 --- a/server/tests/IamV2SourceTest.php +++ b/server/tests/IamV2SourceTest.php @@ -20,6 +20,8 @@ try { foreach ([ 'CREATE TABLE admin (id INTEGER PRIMARY KEY,account TEXT,name TEXT,password TEXT,disable INTEGER,root INTEGER,multipoint_login INTEGER DEFAULT 1,delete_time INTEGER)', 'CREATE TABLE admin_session (id INTEGER PRIMARY KEY,admin_id INTEGER,terminal INTEGER,token TEXT,expire_time INTEGER,update_time INTEGER)', + 'CREATE TABLE dept (id INTEGER PRIMARY KEY,name TEXT,delete_time INTEGER)', + 'CREATE TABLE admin_dept (admin_id INTEGER,dept_id INTEGER)', 'CREATE TABLE admin_role (admin_id INTEGER,role_id INTEGER)', 'CREATE TABLE system_role (id INTEGER PRIMARY KEY,name TEXT,delete_time INTEGER)', 'CREATE TABLE system_role_menu (role_id INTEGER,menu_id INTEGER)', @@ -63,11 +65,29 @@ try { $client=new IamV2Client($cfg,function($method,$url,$headers,$body)use(&$calls){$calls[]=compact('method','url','headers','body');return ['status'=>200,'body'=>json_encode(['binding'=>null,'employee'=>null,'access'=>['allowed'=>false]])];}); $service=new IamV2Manager($client);$service->binding(2);$service->binding(2);ok(count($calls)===1,'30 second read cache'); Db::name('iam_v2_binding')->where('admin_id',2)->update(['checked_at'=>time()-31]);$service->binding(2);ok(count($calls)===2,'missed revocation compensation after 30 seconds'); + Db::name('dept')->insertAll([['id'=>10,'name'=>'Synthetic A','delete_time'=>null],['id'=>20,'name'=>'Synthetic B','delete_time'=>null],['id'=>30,'name'=>'Deleted Department','delete_time'=>time()]]); + Db::name('admin_dept')->insertAll([['admin_id'=>1,'dept_id'=>10],['admin_id'=>1,'dept_id'=>20],['admin_id'=>1,'dept_id'=>30],['admin_id'=>2,'dept_id'=>20]]); + $checkedAt=time()-5;Db::name('iam_v2_binding')->where('admin_id',2)->update(['checked_at'=>$checkedAt]); + $listing=$service->accounts(['q'=>'employee'],1);ok(count($listing['items'])===1 && $listing['items'][0]['dept_names']===['Synthetic B'],'department labels for scoped account'); + ok($listing['items'][0]['iam']['checked_at']===$checkedAt,'cached successful check time is returned unchanged'); + $all=$service->accounts([],1);$byId=[];foreach($all['items']as$item)$byId[$item['id']]=$item; + ok($byId[1]['dept_names']===['Synthetic A','Synthetic B'],'soft-deleted department is not displayed'); + $project=$app->config->get('project');$scoped=$project;$scoped['data_scope']['enabled']=true;$app->config->set($scoped,'project'); + $self=$service->accounts([],2);ok(count($self['items'])===1 && (int)$self['items'][0]['id']===2,'metadata does not widen existing data scope');$app->config->set($project,'project'); + $lastSuccess=time()-100;Db::name('iam_v2_binding')->where('admin_id',2)->update(['checked_at'=>$lastSuccess]); + $offline=new IamV2Manager(new IamV2Client($cfg,fn()=>['status'=>503,'body'=>json_encode(['error'=>['code'=>'SERVICE_UNAVAILABLE']])])); + $unknown=$offline->accounts(['q'=>'employee'],1)['items'][0]['iam']; + ok($unknown['state']==='unknown' && $unknown['access']['allowed']===null && $unknown['checked_at']===$lastSuccess,'failed refresh never fabricates allowed access or successful timestamp'); + ok((int)Db::name('iam_v2_binding')->where('admin_id',2)->value('checked_at')===$lastSuccess,'failure preserves actual persisted check time'); $invokeCallback=function($action,$body,$bearer=null,$method='POST')use($app){ $request=(new think\Request())->withHeader(['authorization'=>$bearer??('Bearer '.str_repeat('c',32))])->withPost($body); $request->setMethod($method);$request->setAction($action);$request->setController('IamV2');$app->instance('request',$request); return (new app\adminapi\controller\IamV2Controller($app))->{$action}(); }; + $request=(new think\Request())->withPost([]);$request->setMethod('POST');$request->setAction('launchStart');$app->instance('request',$request); + $controller=new app\adminapi\controller\IamV2Controller($app);$method=new ReflectionMethod($controller,'invokeV2');$method->setAccessible(true); + $response=$method->invoke($controller,static function(){throw new RuntimeException('NOT_LINKED');});$envelope=$response->getData(); + ok($envelope['code']===0 && $envelope['show']===0 && $envelope['data']['error']['code']==='NOT_LINKED','IAM structured failure silences only the global toast'); $live=$invokeCallback('checkSession',['sessionId'=>$s2['session_id'],'sessionVersion'=>1]);$liveBody=$live->getData(); ok($live->getCode()===200 && $liveBody['active'] && $liveBody['sessionId']===$s2['session_id'] && $liveBody['sessionVersion']===1,'actual source callback raw HTTP shape/SID/version'); ok(!isset($liveBody['code']) && !isset($liveBody['data']),'callback is not business response envelope'); @@ -180,4 +200,4 @@ try { $legacy=$cfg; unset($legacy['v2_token']); $legacy['application_token']=str_repeat('l',32); denies(fn()=>new IamV2Client($legacy),'SERVICE_UNAVAILABLE'); denies(fn()=>IamV2Client::https('https://iam.example.test/path?override=1'),'INVALID_FIXED_URL'); } finally { @unlink($file); if (is_dir($cachePath)) { foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($cachePath, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $path) { $path->isDir() ? rmdir($path->getPathname()) : unlink($path->getPathname()); } rmdir($cachePath); } } -echo "PASS IamV2SourceTest: independent browser SID, legacy reauth, live DB, scope, protected accounts, single-use proof/intent, expiry, immutable authTime, disabled/rotated/revoked sessions, 30s compensation, signed event replay/digest/future/version, revoked tombstone, batch protected filtering/index mapping, selected-only execution, no local account mutation, credential-change revocation, central empty-tx/reconcile contract, first-link/session preservation, access restoration, protected single-review/ordinary denial/live target protection, separate credentials, durable events, historical mapping, generation-specific business expiry, maintenance gates retain revocation core and live self-link protection\n"; +echo "PASS IamV2SourceTest: independent browser SID, legacy reauth, live DB, scope, protected accounts, single-use proof/intent, expiry, immutable authTime, disabled/rotated/revoked sessions, 30s compensation, signed event replay/digest/future/version, revoked tombstone, batch protected filtering/index mapping, selected-only execution, no local account mutation, credential-change revocation, central empty-tx/reconcile contract, first-link/session preservation, access restoration, protected single-review/ordinary denial/live target protection, separate credentials, durable events, historical mapping, generation-specific business expiry, maintenance gates retain revocation core and live self-link protection, scoped departments/real cached check time/unknown access, single-toast envelope\n";