fix(iam): keep revocation core active when new flows are disabled

This commit is contained in:
2026-10-29 20:38:08 +08:00
parent 3f3b4b400a
commit 27cab30c48
13 changed files with 59 additions and 20 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ requireContract(str_contains($source,"self::check(\$p['sessionId']"), 'proof che
requireContract(str_contains($source,"self::authorize(\$session, substr(\$cap, 4)"), 'proof rechecks live permission');
requireContract(str_contains($source,"self::scope((int) \$session['admin_id'], \$targets)"), 'target data scope');
requireContract(str_contains($client,'CURLOPT_FOLLOWLOCATION => false') && str_contains($client,'CURLOPT_SSL_VERIFYPEER => true'), 'TLS fixed host no redirect');
requireContract(str_contains($config,"'v2_enabled' => env('iam.v2_enabled', false)"), 'default OFF');
requireContract(str_contains($config,"'v2_enabled' => env('iam.v2_enabled', false)") && str_contains($config,"'v2_management_enabled' => env('iam.v2_management_enabled', false)") && str_contains($config,"'v2_launch_enabled' => env('iam.v2_launch_enabled', false)"), 'global and new-flow gates default OFF');
requireContract(!str_contains($controller,"'token' =>"), 'no business token callback');
requireContract(str_contains($controller,"verifiedIdToken()") && str_contains($controller,"'oidcNonce' =>"), 'self-link independently verifiable OIDC token and nonce');
requireContract(!str_contains($controller,"request->post('employeeId'"), 'no browser chosen personal employee');
+13 -3
View File
@@ -15,7 +15,7 @@ $app->config->set(['default'=>'file','stores'=>['file'=>['type'=>'File','path'=>
$app->config->set(['default'=>'fixture','connections'=>['fixture'=>['type'=>'sqlite','database'=>$file,'prefix'=>'','fields_strict'=>true]]], 'database');
$manager = new think\DbManager(); $manager->setConfig($app->config->get('database')); $app->instance('think\Db', $manager);
$app->config->set(['data_scope'=>['enabled'=>false], 'unique_identification'=>'fixture'], 'project');
$app->config->set(['application_id'=>'zyt','portal_url'=>'https://iam.example.test','v2_enabled'=>true,'v2_introspection_token'=>str_repeat('c',32)], 'iam');
$app->config->set(['application_id'=>'zyt','portal_url'=>'https://iam.example.test','v2_enabled'=>true,'v2_management_enabled'=>true,'v2_launch_enabled'=>true,'v2_introspection_token'=>str_repeat('c',32)], 'iam');
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)',
@@ -59,7 +59,7 @@ try {
Db::name('admin')->where('id',1)->update(['disable'=>1]); denies(fn()=>IamV2Source::check($s1['session_id']),'ACCOUNT_DISABLED'); Db::name('admin')->where('id',1)->update(['disable'=>0]);
Db::name('admin_session')->where('id',1)->update(['token'=>'rotated']); denies(fn()=>IamV2Source::check($s1['session_id']),'REAUTH_REQUIRED'); Db::name('admin_session')->where('id',1)->update(['token'=>'fixture-token-1']);
IamV2Source::revokeSession($s1['session_id']); denies(fn()=>IamV2Source::check($s1['session_id']),'REAUTH_REQUIRED'); ok(IamV2Source::check($s2['session_id'])['revoked']===0,'revoking one browser preserves other independent session');
$calls=[];$cfg=['v2_enabled'=>true,'api_url'=>'https://iam.example.test/api','public_url'=>'https://zyt.example.test','erp_url'=>'https://erp.example.test','v2_token'=>str_repeat('v',32),'v2_management_token'=>str_repeat('m',32),'application_id'=>'zyt'];
$calls=[];$cfg=['v2_enabled'=>true,'v2_management_enabled'=>true,'v2_launch_enabled'=>true,'api_url'=>'https://iam.example.test/api','public_url'=>'https://zyt.example.test','erp_url'=>'https://erp.example.test','v2_token'=>str_repeat('v',32),'v2_management_token'=>str_repeat('m',32),'application_id'=>'zyt'];
$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');
@@ -145,6 +145,7 @@ try {
Db::name('system_role_menu')->insert(['role_id'=>7,'menu_id'=>7]);Db::name('admin_role')->insert(['admin_id'=>2,'role_id'=>7]);
ok(IamV2Source::introspect($raceProof,'management','')['protectedAccount']===true,'protection recomputed at callback after target privilege change');
denies(fn()=>IamV2Source::authorize($normalSession,'bind',[1],true,true),'PROTECTED_ACCOUNT_REVIEW_REQUIRED');
ok(IamV2Source::liveStatus($normalSession['session_id'],1,'self_link')['protectedAccount']===true,'self-link callback reports live protection without requiring management capabilities');
denies(fn()=>IamV2Source::liveStatus($normalSession['session_id'],1,'management',['1']),'PROTECTED_ACCOUNT_REVIEW_REQUIRED');
Db::name('system_role_menu')->where('role_id',7)->delete();
denies(fn()=>IamV2Source::liveStatus($normalSession['session_id'],1,'management',['2']),'FORBIDDEN');
@@ -167,7 +168,16 @@ try {
$oldGeneration=IamV2Source::enroll('old-generation-token','password',false);
IamV2Source::revokeGeneration(4,1,'old-employee');
ok((int) Db::name('admin_session')->where('id',4)->value('expire_time')<=time(),'old generation without newer holder expires actual business session row');
$gateSession=IamV2Source::enroll('fixture-token-1','password',false);
$pendingProof=IamV2Source::proof($gateSession,'management',[2],['iam:bind']);
$gateConfig=$app->config->get('iam');$gateConfig['v2_management_enabled']=false;$gateConfig['v2_launch_enabled']=false;$app->config->set($gateConfig,'iam');
ok(IamV2Source::liveStatus($gateSession['session_id'],1)['active'],'closing new-flow gates preserves live-session validation');
denies(fn()=>IamV2Source::proof($gateSession,'launch',[1]),'FEATURE_DISABLED');
denies(fn()=>IamV2Source::introspect($pendingProof,'management',''),'FEATURE_DISABLED');
denies(fn()=>IamV2Source::liveStatus($gateSession['session_id'],1,'management',['2']),'FEATURE_DISABLED');
Db::name('admin')->where('id',1)->update(['disable'=>1]);denies(fn()=>IamV2Source::liveStatus($gateSession['session_id'],1),'ACCOUNT_DISABLED');
ok(!IamV2Client::feature('management',['v2_enabled'=>true]) && !IamV2Client::feature('launch',['v2_enabled'=>true]),'new-flow gates default closed independently of global core');
$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\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\n";
+1 -1
View File
@@ -9,7 +9,7 @@ function targetReject(callable $fn,string $code):void { try{$fn();}catch(Runtime
$app=new think\App(dirname(__DIR__).'/');think\Container::setInstance($app);$file=tempnam(sys_get_temp_dir(),'zyt-target-');
$app->config->set(['default'=>'fixture','connections'=>['fixture'=>['type'=>'sqlite','database'=>$file,'prefix'=>'','fields_strict'=>true]]],'database');
$db=new think\DbManager();$db->setConfig($app->config->get('database'));$app->instance('think\Db',$db);
$config=['v2_enabled'=>true,'api_url'=>'https://iam.example.test/api','public_url'=>'https://zyt.example.test','erp_url'=>'https://erp.example.test','application_id'=>'zyt','v2_token'=>str_repeat('v',32),
$config=['v2_enabled'=>true,'v2_management_enabled'=>true,'v2_launch_enabled'=>true,'api_url'=>'https://iam.example.test/api','public_url'=>'https://zyt.example.test','erp_url'=>'https://erp.example.test','application_id'=>'zyt','v2_token'=>str_repeat('v',32),
'v2_sources'=>['iam-admin'=>'https://iam.example.test/admin/iam/launch/continue','iam-portal'=>'https://iam.example.test/portal/launch/continue']];$app->config->set($config,'iam');
try {
$ini=parse_ini_string('[IAM]'.PHP_EOL.'V2_SOURCES_JSON = '.json_encode($config['v2_sources']),true,INI_SCANNER_RAW);
+3 -2
View File
@@ -15,13 +15,14 @@ for(const file of files){
}
const source=fs.readFileSync(new URL(files[0],root),'utf8'), script=source.match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
const js=ts.transpileModule(script,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText.replace(/^.*require\(.+\);?$/gm,'').replace(/^Object.defineProperty\(exports.*$/gm,'')
async function scenario({enabled=true,fail=false,blocked=false}={}){
async function scenario({enabled=true,launchEnabled=enabled,fail=false,blocked=false}={}){
const events=[],tab={opener:'origin',location:{href:'about:blank'},close(){events.push('close')}},ref=v=>({value:v});let mounted
const context={exports:{},vue_1:{ref,onMounted:f=>{mounted=f}},element_plus_1:{ElMessage:{warning:m=>events.push(m)}},iam_v2_1:{iamGet:async()=>({enabled}),iamPost:async(path)=>{events.push(path);assert.equal(tab.opener,null);if(fail)throw{error:{code:'REAUTH_REQUIRED'}};return{url:'https://erp.example.test/iam/launch?source=zyt&intent=opaque'}},identityError:()=> 'reauth'},window:{open:(url,target)=>{events.push(['open',url,target]);return blocked?null:tab}}}
const context={exports:{},vue_1:{ref,onMounted:f=>{mounted=f}},element_plus_1:{ElMessage:{warning:m=>events.push(m)}},iam_v2_1:{iamGet:async()=>({enabled,launch_enabled:launchEnabled,management_enabled:enabled}),iamPost:async(path)=>{events.push(path);assert.equal(tab.opener,null);if(fail)throw{error:{code:'REAUTH_REQUIRED'}};return{url:'https://erp.example.test/iam/launch?source=zyt&intent=opaque'}},identityError:()=> 'reauth'},window:{open:(url,target)=>{events.push(['open',url,target]);return blocked?null:tab}}}
context.iam_v2_1.identityError=()=> 'reauth'
vm.createContext(context);vm.runInContext(js,context);await mounted();return{context,events,tab}
}
let s=await scenario({enabled:false});assert.equal(vm.runInContext('enabled.value',s.context),false);assert.deepEqual(s.events,[])
s=await scenario({enabled:true,launchEnabled:false});assert.equal(vm.runInContext('enabled.value',s.context),false);assert.deepEqual(s.events,[])
s=await scenario();await vm.runInContext('launch()',s.context);assert.equal(s.events[0][0],'open');assert.equal(s.events[1],'/launch/start');assert.match(s.tab.location.href,/intent=opaque/);assert.equal(s.tab.opener,null)
s=await scenario({fail:true});await vm.runInContext('launch()',s.context);assert.ok(s.events.includes('close'));assert.equal(vm.runInContext('busy.value',s.context),false)
const management=fs.readFileSync(new URL(files[2],root),'utf8')