Skip to content

break-glass 不变量的第三条路径无守卫:撤掉最后一个管理员的「身份」(sys_member 降级 / 删 admin_full_access 授权)同样锁死环境 #5978

Description

@baozhoutao

发现于 #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.tsresolveAdminUserIds 正是从这两张表反向枚举出来的):

  1. sys_member.roleowner / admin 等级;
  2. 无组织范围、在有效期内的 sys_user_permission_setadmin_full_access 授权。

所以第三条写法完全绕开两个守卫:用户行原封不动,把他的管理员身份拿掉

  • 把最后一个管理员的 sys_member.role 改成 member(better-auth organization 插件的 updateMemberRole,或 SCIM 组映射的一次变更);
  • 删掉他的 sys_member 行(移出组织);
  • 删掉 / 改期那条 admin_full_accesssys_user_permission_set 授权。

三者事后状态与「删掉最后一个管理员」等价:环境里没有任何人能管理任何东西,产品内部无恢复路径。

为什么现在报的是这一条

#5892#5941 的守卫都把「剩余管理员数」算得很准,却只在被删/被 ban 的那一行上判定。撤销身份的写落在 sys_member / sys_user_permission_set 上,两个钩子的 object 过滤(SystemObjectName.USER)直接跳过。last-admin-guard.ts 的头注释已把这条明确标为「不在本次范围内」,这张单是那句注释指向的登记。

可能的方向(未决,需要拍板)

  • 同形状扩到两张表:在 sys_memberbeforeUpdate/beforeDeletesys_user_permission_setbeforeUpdate/beforeDelete 上复用同一份 resolveAdminUserIds —— 判据变成「这次写之后重新枚举,还剩不剩管理员」。代价:枚举要做两次(写前/写后模拟),或者按写入内容推算差集。
  • 收窄到「自降级」:只拦「最后一个管理员把自己降级」,把组织侧的角色治理留给更大的策略。覆盖面小,但与 ADR-0024 D5.2 的字面要求仍有差距。

两条路的判据都要 fail-closed,并且要和 #5942(「谁是管理员」有两种拼写)一起看:守卫扩到 sys_member 上之后,等级尺的口径会同时被两条安全路径消费。

参考

Blocked-by: #5941


Generated by Claude Code

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions