Skip to content

Commit 6947bec

Browse files
committed
fix(plugin-auth): break-glass 补上第三条路径 —— 撤销管理员「身份」的写同样被拒 (#5978)
ADR-0024 D5.2 的不变量此前由两个钩子守着,都装在 sys_user 上(ban / delete)。 但「谁是管理员」不存在 sys_user 上 —— 它由 sys_member 与 sys_user_permission_set 推导。第三条写法因此完全绕开两者:用户行原封不动,把管理员身份拿掉。 同形状扩到这两张表的 beforeUpdate/beforeDelete(共六个钩子,同 packageId、同 priority 20)。判据 = 枚举、模拟、再枚举:同一个 resolveAdminUserIds 再跑一遍 写后的行,第二次为空而第一次不为空则拒写。全覆盖(不只自降级)、谓词写做整集 模拟、fail-closed。模拟单向(只拿走身份不授予),取整一律倒向拒写。 等级判定只问 isOrgAdminGrade 这把唯一的尺;有效期按 isGrantActive 原样消费。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JwwiU9bjhwy2SWj13ho8uv
1 parent 9e3709a commit 6947bec

4 files changed

Lines changed: 1192 additions & 68 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(plugin-auth): break-glass 不变量补上第三条路径 —— 撤销「管理员身份」的写(`sys_member` 降级/删行、`admin_full_access` 授权删/改)同样被拒 (#5978)
6+
7+
cloud ADR-0024 D5.2 的不变量是「环境永远至少留一个能登录的管理员」。此前它由两个引擎钩子守着,
8+
**都装在 `sys_user`**:`banned = true`(#5892 / PR #5939)与删 `sys_user` 行(#5941 / PR #5993)。
9+
10+
但「谁是管理员」这件事根本不存在 `sys_user` 上 —— 它由另外两张表推导(`resolveAdminUserIds`
11+
正是从这两张表反向枚举的)。于是第三条写法完全绕开两个守卫:**用户行原封不动,把他的管理员身份拿掉**
12+
13+
- 把最后一个管理员的 `sys_member.role` 降到 admin 等级之下(better-auth 的 `updateMemberRole`
14+
一次 SCIM 组映射变更、导入、脚本),或直接删掉那条 `sys_member` 行;
15+
- 删掉那条 `admin_full_access``sys_user_permission_set` 授权,或把它改到不再生效
16+
—— 改指向别的权限集、加上 `organization_id` 组织作用域、把 ADR-0091 有效期窗口改过去。
17+
18+
三者事后状态与「删掉最后一个管理员」完全等价:所有人都还在,没有任何人能管理任何东西,
19+
产品内部无恢复路径。
20+
21+
**新增的拒写语义。** 守卫现在按同一形状扩到 `sys_member``sys_user_permission_set`
22+
`beforeUpdate` / `beforeDelete`(共六个钩子,同 `packageId`、同 priority 20)。判据就是 issue 的原话
23+
——**枚举、模拟、再枚举**:先枚举当前管理员,再把这次写落地后的行拿同一个枚举函数跑一遍,
24+
若第二次为空而第一次不为空则拒写。两次枚举是同一份实现,「谁是管理员」不可能对写前问题和写后问题
25+
给出两个答案。
26+
27+
- **全覆盖,不是只拦自降级**:真正会发生的是 IdP 组映射改别人的角色,不是管理员给自己降级。
28+
- **谓词/批量写照判**:一次 `where` 命中多行的 update/delete 会先解析出整个匹配行集再做写后模拟,
29+
而不是一律拒绝;只有匹配集本身解析不出来(读失败,或超过 `maxScan`)才响亮拒写。
30+
- **fail-closed**:枚举失败或形状不确定一律拒写并点名 ADR-0024 D5.2,与既有两半同向。
31+
- 模拟是**单向**的 —— 只会拿走身份,不会授予身份(把 role 从 `member` 升到 `admin`、把授权改指向
32+
`admin_full_access` 这类写,模拟看不见新增的管理员),因此每一处取整都倒向「拒写」而非「放行」。
33+
34+
**不拦的**:降级到**另一个** admin 等级(`owner``admin`,或逗号拼写 `member,admin`)—— 等级未失;
35+
已被 ban 的管理员的身份被撤(本来就不能登录,没有东西被拿走);非管理员的 membership/授权;
36+
以及不触及 `role` / `user_id`(membership)或权限集/作用域/有效期(授权)的 payload —— 这类写
37+
静态可证不改变枚举结果,一次读都不做。
38+
39+
有效期语义按 `resolveAdminUserIds` 现有的 `isGrantActive`(ADR-0091 D2)**原样消费**,本次不新造
40+
(#5893 才是那个问题的归属单)。等级判定全程只问 `isOrgAdminGrade` 这把唯一的尺(#5939 / #5942),
41+
守卫内没有任何手抄的 role 解析。

packages/plugins/plugin-auth/src/auth-plugin.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -987,17 +987,19 @@ export class AuthPlugin implements Plugin {
987987
getSecondaryStorage: () =>
988988
this.effectiveSecondaryStorage as SecondaryStorageLike | undefined,
989989
});
990-
// [cloud ADR-0024 D5.2] Break-glass — the SAME `sys_user` write
990+
// [cloud ADR-0024 D5.2] Break-glass — the same identity write
991991
// chokepoints, guarding a different question: not "may this caller
992992
// write identity tables" (above, and system writes bypass it by
993993
// design) but "may this WRITE happen at all". A `banned = true` (#5892)
994-
// or a row DELETE (#5941) that would leave the environment with no
995-
// administrator able to sign in is refused for EVERY context,
996-
// `isSystem` included — because the paths that actually lock an org out
997-
// are the system ones (better-auth's admin ban and remove-user, driven
998-
// by a SCIM `active: false` / `DELETE /Users/{id}`). Registered at
999-
// priority 20 so the ADR-0092 checks above (10) still answer first for
1000-
// user-context callers. See last-admin-guard.ts.
994+
// or a row DELETE (#5941) on `sys_user`, and — since #5978 — any write
995+
// to `sys_member` / `sys_user_permission_set` that revokes the last
996+
// administrator's STANDING while leaving their user row untouched, is
997+
// refused for EVERY context, `isSystem` included: the paths that
998+
// actually lock an org out are the system ones (better-auth's admin
999+
// ban, remove-user and updateMemberRole, driven by a SCIM
1000+
// `active: false` / `DELETE /Users/{id}` / group remap). All six hooks
1001+
// register at priority 20 so the ADR-0092 checks above (10) still
1002+
// answer first for user-context callers. See last-admin-guard.ts.
10011003
registerLastAdminGuard(engine, {
10021004
packageId: 'com.objectstack.plugin-auth.last-admin-guard',
10031005
logger: ctx.logger,

0 commit comments

Comments
 (0)