发现于 #5978(第三条路径)的实现过程,范围外,未在该 PR 修。
现状
packages/plugins/plugin-auth/src/last-admin-guard.ts 的 resolveAdminUserIds 分两半枚举管理员,platform admin 那一半的第一步是:
const sets = await scan(op, SystemObjectName.PERMISSION_SET, {
where: { name: ADMIN_FULL_ACCESS },
fields: ['id', 'name'],
});
const adminSetIds = sets.map((r) => toId(r.id)).filter(Boolean);
if (adminSetIds.length > 0) { /* 只有这里才去读 sys_user_permission_set */ }
即「谁是 platform admin」不只依赖 sys_user_permission_set 授权行,还依赖 sys_permission_set 里那条 name = 'admin_full_access' 的行本身存在且仍叫这个名字。
#5978 落地后守卫覆盖三张表(sys_user / sys_member / sys_user_permission_set),sys_permission_set 不在内。所以第四条写法仍然绕开全部守卫:
- 删掉那条
sys_permission_set 行;
- 把它的
name 改成别的值。
两者事后 adminSetIds 为空 ⇒ 所有 platform admin 的授权行还在、sys_user 行原封不动、sys_member 行原封不动,但没有任何人被枚举为 platform admin。
为什么比看上去严重
守卫有一条引导期豁免(合理且必要):
const admins = await resolveAdminUserIds(op);
if (admins.size === 0) return; // 没有管理员可保护
所以在一个「platform admin 是唯一管理员形态」(没有 org owner/admin)的环境里,删掉那条 sys_permission_set 行之后:
- 环境立刻进入零管理员状态;
- 并且守卫从此对所有写放行 —— 因为
admins.size === 0 被读成「引导期,无可保护」,而不是「刚刚被清空」。
也就是说这一步不仅锁死环境,还顺带解除了 #5892 / #5941 / #5978 三条路径的守卫。
复现(engine 级)
last-admin-guard.test.ts 的既有 fixture 即可:seed 一个 platformAdmin: true 的用户 + seedAdminPermissionSet,然后
await engine.delete('sys_permission_set', { where: { id: PS_ADMIN }, ...SYSTEM });
当前 resolves(无守卫);此后 ban(engine, 'usr_platform') 也 resolves。
可能的方向(未决)
判据同样要 fail-closed。是否值得单独守一张只在部署期写一次的表,由 PM/维护者裁。
参考
Blocked-by: #5978
Generated by Claude Code
发现于 #5978(第三条路径)的实现过程,范围外,未在该 PR 修。
现状
packages/plugins/plugin-auth/src/last-admin-guard.ts的resolveAdminUserIds分两半枚举管理员,platform admin 那一半的第一步是:即「谁是 platform admin」不只依赖
sys_user_permission_set授权行,还依赖sys_permission_set里那条name = 'admin_full_access'的行本身存在且仍叫这个名字。#5978 落地后守卫覆盖三张表(
sys_user/sys_member/sys_user_permission_set),sys_permission_set不在内。所以第四条写法仍然绕开全部守卫:sys_permission_set行;name改成别的值。两者事后
adminSetIds为空 ⇒ 所有 platform admin 的授权行还在、sys_user行原封不动、sys_member行原封不动,但没有任何人被枚举为 platform admin。为什么比看上去严重
守卫有一条引导期豁免(合理且必要):
所以在一个「platform admin 是唯一管理员形态」(没有 org owner/admin)的环境里,删掉那条
sys_permission_set行之后:admins.size === 0被读成「引导期,无可保护」,而不是「刚刚被清空」。也就是说这一步不仅锁死环境,还顺带解除了 #5892 / #5941 / #5978 三条路径的守卫。
复现(engine 级)
last-admin-guard.test.ts的既有 fixture 即可:seed 一个platformAdmin: true的用户 +seedAdminPermissionSet,然后当前 resolves(无守卫);此后
ban(engine, 'usr_platform')也 resolves。可能的方向(未决)
sys_permission_set的beforeUpdate(payload 触及name)/beforeDelete,复用 break-glass 不变量的第三条路径无守卫:撤掉最后一个管理员的「身份」(sys_member 降级 / 删 admin_full_access 授权)同样锁死环境 #5978 的enforceStanding与applyPending—— 机制已经在位,PendingStandingWrite只需多认一张表。admins.size === 0⇒ 放行」这条引导期豁免收紧成「确实处于引导期」的可判定条件(例如环境里根本没有admin_full_access授权行 vs 有授权行但权限集不见了),这样第四条路径至少不会连带解除其余守卫。两者不互斥。判据同样要 fail-closed。是否值得单独守一张只在部署期写一次的表,由 PM/维护者裁。
参考
packages/plugins/plugin-auth/src/last-admin-guard.ts头注释「Scope in the other direction」段已把这一条明确标为不在 break-glass 不变量的第三条路径无守卫:撤掉最后一个管理员的「身份」(sys_member 降级 / 删 admin_full_access 授权)同样锁死环境 #5978 范围内,本单是那句话指向的登记Blocked-by: #5978
Generated by Claude Code