diff --git a/admin/src/components/iam-v2/launch.vue b/admin/src/components/iam-v2/launch.vue
index 4fc0b1262..fca9ffa33 100644
--- a/admin/src/components/iam-v2/launch.vue
+++ b/admin/src/components/iam-v2/launch.vue
@@ -9,7 +9,8 @@ const enabled = ref(false),
busy = ref(false)
onMounted(async () => {
try {
- enabled.value = !!(await iamGet('/status')).enabled
+ const status = await iamGet('/status')
+ enabled.value = !!status.enabled && status.launch_enabled === true
} catch {
enabled.value = false
}
diff --git a/admin/src/components/iam-v2/personal.vue b/admin/src/components/iam-v2/personal.vue
index 495fab025..1f4cbb0c9 100644
--- a/admin/src/components/iam-v2/personal.vue
+++ b/admin/src/components/iam-v2/personal.vue
@@ -27,20 +27,20 @@
验证当前身份
企业微信重新登录
关联已有 IAM 账号
设置 IAM 登录密码
- 进入甄美 ERP ↗
+ 进入甄美 ERP ↗
= {
}
const enabled = ref(false),
reviewAllowed = ref(false),
+ managementEnabled = ref(false),
caps = ref([]),
rows = ref([]),
total = ref(0),
@@ -446,7 +447,7 @@ const batchVisible = ref(false),
selectedIndexes = ref([]),
batchConfirmed = ref(false),
job = ref(null)
-const can = (c: string) => caps.value.includes(c)
+const can = (c: string) => caps.value.includes(c) && (c === 'read' || managementEnabled.value)
const validIdentity = computed(
() =>
operation.kind === 'unbind' ||
@@ -618,6 +619,7 @@ onMounted(async () => {
enabled.value = s.enabled
caps.value = s.management_capabilities || []
reviewAllowed.value = s.protected_review_operator === true
+ managementEnabled.value = s.management_enabled === true
if (enabled.value && can('read')) await load()
} catch (e) {
error.value = identityError(e)
diff --git a/docs/iam-v2.md b/docs/iam-v2.md
index 91ad30932..fd8c1999a 100644
--- a/docs/iam-v2.md
+++ b/docs/iam-v2.md
@@ -2,7 +2,7 @@
## 默认状态与部署次序
-所有新功能由 `iam.v2_enabled` 控制,默认 **false**。关闭时不写新表、不登记来源会话,原密码、企微、IAM 入站登录保持原路径。本文只描述源码;生产开关、迁移、真实浏览器及三端联调需另行验收。
+首次部署 `iam.v2_enabled`、`iam.v2_management_enabled`、`iam.v2_launch_enabled` 都默认 **false**。全局未启用时不登记新来源会话,原密码、企微、IAM 入站登录保持原路径。产生v2关联后,全局核心必须保持true以持续中央停用检查、签名事件和代际补偿;维护/回退优先关闭management与launch两个新流程开关,不能以关闭全局核心绕过这些检查。本文只描述源码;生产开关、迁移、真实浏览器及三端联调需另行验收。
1. 备份部署版本与配置,审查并执行 `server/database/migrations/20261029_iam_v2.sql`(按实际表前缀调整)。此迁移只加七张表及独立菜单/权限,不给现有角色自动授予权限,不改账号与角色。
2. 中央先部署 v2 绑定墓碑、幂等恢复、固定来源回查、版本检查、源/目标访问授权、密码设置协议与签名事件。旧 v1 token 不得获得新管理权限。
@@ -12,6 +12,8 @@
```ini
[IAM]
V2_ENABLED = false
+V2_MANAGEMENT_ENABLED = false
+V2_LAUNCH_ENABLED = false
V2_TOKEN =
V2_MANAGEMENT_TOKEN =
V2_INTROSPECTION_TOKEN =
@@ -77,7 +79,7 @@ SQLite 合成测试不加载生产 `.env`、不发网络、不修改真实用户
## 回退
-先将 `V2_ENABLED=false`,恢复上一已验证代码/静态构建。保留七张新表、旧开户账本、事件与审计记录、所有真实账号和业务数据。关闭 v2 停止委托登录/新证明,不撤回中央已执行的人事操作;错误关联通过有审计的显式解绑/换绑恢复,而非删表或恢复旧数据库。
+已有v2状态时先将 `V2_MANAGEMENT_ENABLED=false`、`V2_LAUNCH_ENABLED=false`,保持 `V2_ENABLED=true` 和当前安全核心;管理写/个人关联密码写/新的委托事务停止,普通授权核验、事件和代际补偿继续。仅可回到同样保持这些核心能力的兼容版本,不能盲退到无v2检查的旧代码。只有从未激活、七表无v2业务状态且全局仍false的首次部署,才可还原本次原文件与原index。保留七张新表、旧开户账本、事件与审计记录、所有真实账号和业务数据。关闭新流程开关停止新的委托登录/管理证明,不撤回中央已执行的人事操作;错误关联通过有审计的显式解绑/换绑恢复,而非删表或恢复旧数据库。
## 2026-10-29 只读生产入口核对与发布步骤
@@ -97,7 +99,7 @@ SQLite 合成测试不加载生产 `.env`、不发网络、不修改真实用户
4. 中央先完成v2迁移、凭据/固定URL/回查注册、原生IAM本人入口和中央复核部署;ZYT新代码先保持 `V2_ENABLED=false`。配置完整的API/portal/ERP公开callback URL及两条来源映射,尤其ERP公开 `/basic-api` 前缀。
5. 后端按审核清单更新;静态资源先增量加入旧资源目录,最后原子切换index.html,不删旧哈希文件。按实际OPcache配置处理源码重载,不把CLI `opcache_reset()`当成FPM已刷新,不重启无关服务。
6. 先验证开关关闭时原登录/业务行为,再用授权测试账号开启v2验收:密码/企微→ERP、IAM管理后台本地session无KC cookie→ZYT/ERP、员工工作台→两端、同人/异人多标签、已有账号/角色2新开户、密码owner双阶段、敏感单人中央审批及批量跳过、签名事件/30秒补偿。逐项核对真实页面与后端会话,不以HTTP200代替登录验收。
-7. 回退先关闭v2,恢复发布前的本次后端文件与静态index;保留七表、事件、绑定历史、中央操作与所有业务数据。不要用整库恢复或删除IAM员工来回退代码。普通source logout按约定不是全局登出。
+7. 初次未激活部署(全局false且七表空)可恢复发布前文件/index;一旦已有v2状态,回退优先只关闭管理/新登录两个开关、保持全局安全核心true,不盲退到旧代码;保留七表、事件、绑定历史、中央操作与所有业务数据。不要用整库恢复或删除IAM员工来回退代码。普通source logout按约定不是全局登出。
### 最终发布基线差异(追加)
diff --git a/server/app/adminapi/controller/IamV2Controller.php b/server/app/adminapi/controller/IamV2Controller.php
index 7b6c115c4..1feddd653 100644
--- a/server/app/adminapi/controller/IamV2Controller.php
+++ b/server/app/adminapi/controller/IamV2Controller.php
@@ -17,6 +17,9 @@ final class IamV2Controller extends BaseAdminController
{
try {
if (!IamV2Client::enabled()) { throw new RuntimeException('FEATURE_DISABLED'); }
+ $action = strtolower((string) $this->request->action());
+ if (in_array($action, ['operation','preflight','batch','retry','selfstart','passwordsetup'], true) && !IamV2Client::feature('management')) { throw new RuntimeException('FEATURE_DISABLED'); }
+ if (in_array($action, ['launchstart','targetprepare','targetexchange'], true) && !IamV2Client::feature('launch')) { throw new RuntimeException('FEATURE_DISABLED'); }
$reads = ['status','accounts','identities','operationresult','audit','job','targetconfig'];
$read = in_array(strtolower((string) $this->request->action()), $reads, true);
if (($read && !$this->request->isGet()) || (!$read && !$this->request->isPost())) { throw new RuntimeException('METHOD_NOT_ALLOWED'); }
@@ -48,7 +51,7 @@ final class IamV2Controller extends BaseAdminController
}
public function status()
{
- if (!IamV2Client::enabled()) { return $this->data(['enabled' => false]); }
+ if (!IamV2Client::enabled()) { return $this->data(['enabled' => false, 'management_enabled' => false, 'launch_enabled' => false]); }
return $this->invokeV2(function () {
$token = (string) $this->request->header('token', '');
$legacy = Db::name('admin_session')->where('token', $token)->where('expire_time', '>', time())->find();
@@ -56,7 +59,7 @@ final class IamV2Controller extends BaseAdminController
IamV2Source::admin((int) $legacy['admin_id']);
$fresh = false; try { $s = IamV2Source::current($token); $fresh = $s['auth_time'] >= time() - 300; } catch (\Throwable $e) { $s = null; }
$b = (new IamV2Manager())->binding((int) $legacy['admin_id']);
- return ['enabled' => true, 'session_ready' => $s !== null, 'reauth_required' => !$fresh,
+ return ['enabled' => true, 'management_enabled' => IamV2Client::feature('management'), 'launch_enabled' => IamV2Client::feature('launch'), 'session_ready' => $s !== null, 'reauth_required' => !$fresh,
'management_capabilities' => IamV2Source::capabilities((int) $legacy['admin_id']),
'protected_review_operator' => IamV2Source::reviewOperator((int) $legacy['admin_id']),
'protected_account' => IamV2Source::protectedAccount((int) $legacy['admin_id'])] + $b;
@@ -117,6 +120,7 @@ final class IamV2Controller extends BaseAdminController
public function launchContinue()
{
try {
+ IamV2Source::requirePurpose('launch');
$client = new IamV2Client(); $s = IamV2Source::check((string) ($_COOKIE[IamV2Source::COOKIE] ?? ''));
$intent = (string) $this->request->get('intent', ''); $cookie = (string) ($_COOKIE['ZYT_IAM_V2_INTENT'] ?? '');
if ($intent === '' || !hash_equals($cookie, $intent)) { throw new RuntimeException('ACCOUNT_MISMATCH'); }
@@ -147,6 +151,7 @@ final class IamV2Controller extends BaseAdminController
public function selfCallback()
{
try {
+ IamV2Source::requirePurpose('self_link');
$central = new IamV2Client(); $s = IamV2Source::check((string) ($_COOKIE[IamV2Source::COOKIE] ?? ''));
$flow = IamV2Flow::take('self_link', (string) $this->request->get('state', ''), $s['session_id']);
$oidc = new IamOidcClient($this->selfConfig()); $oidc->exchange((string) $this->request->get('code', ''), $flow['verifier'], $flow['nonce']);
@@ -172,6 +177,7 @@ final class IamV2Controller extends BaseAdminController
public function passwordContinue()
{
try {
+ IamV2Source::requirePurpose('password_setup');
$client = new IamV2Client(); $s = IamV2Source::check((string) ($_COOKIE[IamV2Source::COOKIE] ?? ''));
$f = IamV2Flow::take('password_setup', (string) ($_COOKIE['ZYT_IAM_PASSWORD_INTENT'] ?? ''), $s['session_id']);
$id = (string) $this->request->get('request_id', ''); $state = (string) $this->request->get('state', '');
@@ -187,10 +193,10 @@ final class IamV2Controller extends BaseAdminController
}
public function targetConfig()
{
- if (!IamV2Client::enabled()) { return $this->data(['enabled' => false]); }
+ if (!IamV2Client::enabled()) { return $this->data(['enabled' => false, 'management_enabled' => false, 'launch_enabled' => false]); }
return $this->invokeV2(function () {
$source = (string) $this->request->get('source', 'iam-portal');
- return ['enabled' => true, 'restart_url' => IamV2Client::origin((string) Config::get('iam.portal_url')) . ($source === 'iam-admin' ? '/admin' : '/portal')];
+ return ['enabled' => IamV2Client::feature('launch'), 'global_enabled' => true, 'management_enabled' => IamV2Client::feature('management'), 'launch_enabled' => IamV2Client::feature('launch'), 'restart_url' => IamV2Client::origin((string) Config::get('iam.portal_url')) . ($source === 'iam-admin' ? '/admin' : '/portal')];
});
}
public function targetPrepare()
diff --git a/server/app/adminapi/service/iam/v2/IamV2Client.php b/server/app/adminapi/service/iam/v2/IamV2Client.php
index 4c5660090..74335396b 100644
--- a/server/app/adminapi/service/iam/v2/IamV2Client.php
+++ b/server/app/adminapi/service/iam/v2/IamV2Client.php
@@ -22,6 +22,12 @@ final class IamV2Client
$settings ??= (array) Config::get('iam', []);
return filter_var($settings['v2_enabled'] ?? false, FILTER_VALIDATE_BOOL);
}
+ public static function feature(string $feature, ?array $settings = null): bool
+ {
+ $settings ??= (array) Config::get('iam', []);
+ if (!in_array($feature, ['management', 'launch'], true)) { return false; }
+ return self::enabled($settings) && filter_var($settings['v2_' . $feature . '_enabled'] ?? false, FILTER_VALIDATE_BOOL);
+ }
public static function https(string $url): string
{
$p = parse_url($url);
diff --git a/server/app/adminapi/service/iam/v2/IamV2Source.php b/server/app/adminapi/service/iam/v2/IamV2Source.php
index 354e81021..9261f4210 100644
--- a/server/app/adminapi/service/iam/v2/IamV2Source.php
+++ b/server/app/adminapi/service/iam/v2/IamV2Source.php
@@ -13,6 +13,11 @@ use app\common\cache\AdminAccountSafeCache;
final class IamV2Source
{
public const COOKIE = 'ZYT_IAM_V2_SESSION';
+ public static function requirePurpose(string $purpose): void
+ {
+ $feature = in_array($purpose, ['management', 'self_link', 'password_setup'], true) ? 'management' : ($purpose === 'launch' ? 'launch' : null);
+ if ($feature !== null && !IamV2Client::feature($feature)) { throw new RuntimeException('FEATURE_DISABLED'); }
+ }
public static function enroll(string $token, string $method, bool $cookie = true, ?int $authTime = null): array
{
if (!in_array($method, ['password', 'wecom', 'oidc', 'iam_launch'], true)) { throw new RuntimeException('REAUTH_REQUIRED'); }
@@ -70,10 +75,11 @@ final class IamV2Source
/** Service callback: local-only live authorization, safe while central holds its state lock. */
public static function liveStatus(string $id, int $version, string $purpose = '', array $targets = []): array
{
+ self::requirePurpose($purpose);
$s = self::check($id, $version);
$out = ['active' => true, 'actorLocalId' => (string) $s['admin_id'], 'sessionId' => $s['session_id'], 'sessionVersion' => (int) $s['version'],
'authMethod' => $s['auth_method'], 'authTime' => (int) $s['auth_time'], 'expiresAt' => (int) $s['expires_at']];
- if ($purpose !== 'management') { return $out; }
+ if ($purpose !== 'management') { return $out + ['protectedAccount' => self::protectedAccount((int) $s['admin_id'])]; }
if ($s['auth_time'] < time() - 300) { throw new RuntimeException('REAUTH_REQUIRED'); }
if (!$targets || count($targets) > 100) { throw new RuntimeException('INVALID_INPUT'); }
foreach ($targets as $target) {
@@ -147,6 +153,7 @@ final class IamV2Source
}
public static function proof(array $session, string $purpose, array $targets = [], array $capabilities = [], ?string $transaction = null, array $extra = []): string
{
+ self::requirePurpose($purpose);
self::check($session['session_id'], (int) $session['version']);
$secret = bin2hex(random_bytes(32));
$payload = ['applicationId' => (string) Config::get('iam.application_id', 'zyt'), 'actorLocalId' => (string) $session['admin_id'],
@@ -160,6 +167,7 @@ final class IamV2Source
}
public static function introspect(string $proof, string $purpose, ?string $transaction = null): array
{
+ self::requirePurpose($purpose);
// The central Go attestor serializes an absent transaction as an empty string.
$transaction = $transaction === '' ? null : $transaction;
return Db::transaction(function () use ($proof, $purpose, $transaction) {
diff --git a/server/app/adminapi/service/iam/v2/IamV2Target.php b/server/app/adminapi/service/iam/v2/IamV2Target.php
index c927a378c..c7c94dddb 100644
--- a/server/app/adminapi/service/iam/v2/IamV2Target.php
+++ b/server/app/adminapi/service/iam/v2/IamV2Target.php
@@ -19,6 +19,7 @@ final class IamV2Target
public function __construct(private ?IamV2Client $client = null, private $resolve = null, private $login = null)
{
$this->settings = (array) Config::get('iam');
+ if (!IamV2Client::feature('launch', $this->settings)) { throw new RuntimeException('FEATURE_DISABLED'); }
$this->client ??= new IamV2Client();
$this->resolve ??= fn(array $result) => $this->resolveAdmin($result);
$this->login ??= function (int $id, array $authentication): array {
diff --git a/server/config/iam.php b/server/config/iam.php
index edb386578..e45513d08 100644
--- a/server/config/iam.php
+++ b/server/config/iam.php
@@ -3,6 +3,8 @@
// Optional integration: disabled installations never touch IAM or its transaction store.
return [
'v2_sources' => json_decode((string) env('iam.v2_sources_json', '{}'), true) ?: [],
+ 'v2_management_enabled' => env('iam.v2_management_enabled', false),
+ 'v2_launch_enabled' => env('iam.v2_launch_enabled', false),
'v2_enabled' => env('iam.v2_enabled', false),
'v2_management_token' => env('iam.v2_management_token', ''),
'v2_token' => env('iam.v2_token', ''),
diff --git a/server/tests/IamV2RouteContractTest.php b/server/tests/IamV2RouteContractTest.php
index 09295c5ae..8ef8aaa18 100644
--- a/server/tests/IamV2RouteContractTest.php
+++ b/server/tests/IamV2RouteContractTest.php
@@ -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');
diff --git a/server/tests/IamV2SourceTest.php b/server/tests/IamV2SourceTest.php
index 66ad631e6..d751767d6 100644
--- a/server/tests/IamV2SourceTest.php
+++ b/server/tests/IamV2SourceTest.php
@@ -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";
diff --git a/server/tests/IamV2TargetTest.php b/server/tests/IamV2TargetTest.php
index 7072e3636..48ce4395a 100644
--- a/server/tests/IamV2TargetTest.php
+++ b/server/tests/IamV2TargetTest.php
@@ -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);
diff --git a/server/tests/IamV2UiTest.mjs b/server/tests/IamV2UiTest.mjs
index 1cc4f1f24..2972a8821 100644
--- a/server/tests/IamV2UiTest.mjs
+++ b/server/tests/IamV2UiTest.mjs
@@ -15,13 +15,14 @@ for(const file of files){
}
const source=fs.readFileSync(new URL(files[0],root),'utf8'), script=source.match(/