fix(iam): expose scoped account metadata and avoid duplicate notices

This commit is contained in:
2026-10-30 11:50:55 +08:00
parent 27cab30c48
commit a32e167f8b
4 changed files with 52 additions and 7 deletions
@@ -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
@@ -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
+1
View File
@@ -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";
+21 -1
View File
@@ -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";