发现于 #5941 / PR(delete 半边)的实现过程,范围外,未在该 PR 修。
现状
cloud ADR-0024 D5.2 的不变量是「环境永远至少留一个能登录的管理员」。今天它由两个引擎钩子守着,都装在 sys_user 上:
| 写法 |
钩子 |
出处 |
sys_user.banned = true |
beforeUpdate |
#5892 / PR #5939 |
删 sys_user 行 |
beforeDelete |
#5941 |
两者都只看「这一行用户还在不在、被没被 ban」。但「谁是管理员」这件事不存在 sys_user 上 —— 它由另外两张表决定(last-admin-guard.ts 的 resolveAdminUserIds 正是从这两张表反向枚举出来的):
sys_member.role 带 owner / admin 等级;
- 无组织范围、在有效期内的
sys_user_permission_set → admin_full_access 授权。
所以第三条写法完全绕开两个守卫:用户行原封不动,把他的管理员身份拿掉。
- 把最后一个管理员的
sys_member.role 改成 member(better-auth organization 插件的 updateMemberRole,或 SCIM 组映射的一次变更);
- 删掉他的
sys_member 行(移出组织);
- 删掉 / 改期那条
admin_full_access 的 sys_user_permission_set 授权。
三者事后状态与「删掉最后一个管理员」等价:环境里没有任何人能管理任何东西,产品内部无恢复路径。
为什么现在报的是这一条
#5892 与 #5941 的守卫都把「剩余管理员数」算得很准,却只在被删/被 ban 的那一行上判定。撤销身份的写落在 sys_member / sys_user_permission_set 上,两个钩子的 object 过滤(SystemObjectName.USER)直接跳过。last-admin-guard.ts 的头注释已把这条明确标为「不在本次范围内」,这张单是那句注释指向的登记。
可能的方向(未决,需要拍板)
- 同形状扩到两张表:在
sys_member 的 beforeUpdate/beforeDelete 与 sys_user_permission_set 的 beforeUpdate/beforeDelete 上复用同一份 resolveAdminUserIds —— 判据变成「这次写之后重新枚举,还剩不剩管理员」。代价:枚举要做两次(写前/写后模拟),或者按写入内容推算差集。
- 收窄到「自降级」:只拦「最后一个管理员把自己降级」,把组织侧的角色治理留给更大的策略。覆盖面小,但与 ADR-0024 D5.2 的字面要求仍有差距。
两条路的判据都要 fail-closed,并且要和 #5942(「谁是管理员」有两种拼写)一起看:守卫扩到 sys_member 上之后,等级尺的口径会同时被两条安全路径消费。
参考
Blocked-by: #5941
Generated by Claude Code
发现于 #5941 / PR(delete 半边)的实现过程,范围外,未在该 PR 修。
现状
cloud ADR-0024 D5.2 的不变量是「环境永远至少留一个能登录的管理员」。今天它由两个引擎钩子守着,都装在
sys_user上:sys_user.banned = truebeforeUpdatesys_user行beforeDelete两者都只看「这一行用户还在不在、被没被 ban」。但「谁是管理员」这件事不存在
sys_user上 —— 它由另外两张表决定(last-admin-guard.ts的resolveAdminUserIds正是从这两张表反向枚举出来的):sys_member.role带owner/admin等级;sys_user_permission_set→admin_full_access授权。所以第三条写法完全绕开两个守卫:用户行原封不动,把他的管理员身份拿掉。
sys_member.role改成member(better-auth organization 插件的updateMemberRole,或 SCIM 组映射的一次变更);sys_member行(移出组织);admin_full_access的sys_user_permission_set授权。三者事后状态与「删掉最后一个管理员」等价:环境里没有任何人能管理任何东西,产品内部无恢复路径。
为什么现在报的是这一条
#5892 与 #5941 的守卫都把「剩余管理员数」算得很准,却只在被删/被 ban 的那一行上判定。撤销身份的写落在
sys_member/sys_user_permission_set上,两个钩子的object过滤(SystemObjectName.USER)直接跳过。last-admin-guard.ts的头注释已把这条明确标为「不在本次范围内」,这张单是那句注释指向的登记。可能的方向(未决,需要拍板)
sys_member的beforeUpdate/beforeDelete与sys_user_permission_set的beforeUpdate/beforeDelete上复用同一份resolveAdminUserIds—— 判据变成「这次写之后重新枚举,还剩不剩管理员」。代价:枚举要做两次(写前/写后模拟),或者按写入内容推算差集。两条路的判据都要 fail-closed,并且要和 #5942(「谁是管理员」有两种拼写)一起看:守卫扩到
sys_member上之后,等级尺的口径会同时被两条安全路径消费。参考
packages/plugins/plugin-auth/src/last-admin-guard.ts)isOrgOrPlatformAdmin对role='Owner'答否,等级尺答是 #5942(等级尺两种拼写)Blocked-by: #5941
Generated by Claude Code