Skip to content

fix(plugin-auth): break-glass 补上第三条路径 —— 撤销管理员「身份」的写(sys_member 降级/删行、admin_full_access 授权删/改)同样被拒 (#5978) - #6086

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-5978-break-glass-third-path
Aug 7, 2026
Merged

fix(plugin-auth): break-glass 补上第三条路径 —— 撤销管理员「身份」的写(sys_member 降级/删行、admin_full_access 授权删/改)同样被拒 (#5978)#6086
baozhoutao merged 2 commits into
mainfrom
claude/issue-5978-break-glass-third-path

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes #5978

问题

cloud ADR-0024 D5.2 的不变量是「环境永远至少留一个能登录的管理员」。此前它由两个引擎钩子守着,都装在 sys_user:banned = true(#5892 / PR #5939)与删 sys_user 行(#5941 / PR #5993)。

但「谁是管理员」这件事根本不存在 sys_user 上 —— 它由另外两张表推导(resolveAdminUserIds 正是从这两张表反向枚举的)。于是第三条写法完全绕开两个守卫:用户行原封不动,把他的管理员身份拿掉。两个钩子的 object 过滤(SystemObjectName.USER)直接跳过整条路径。

last-admin-guard.ts 头注释里那句「Same end state, third write shape; filed as #5978 rather than half-guarded from this file」指向的就是本单。

做法:同形状扩到两张表,判据 = 枚举、模拟、再枚举

守卫按 #5939/#5993 已有的接线形状扩到 sys_membersys_user_permission_setbeforeUpdate / beforeDelete,合计六个钩子,同 packageId、同 priority 20、同 engine.registerHook 调用形状,只有 object 过滤不同 —— 整个不变量作为一个 package 一起绑定/解绑。

判据是 issue 的原话:

  1. 按当前行枚举管理员;
  2. 把这次写落地后的行拿同一个 resolveAdminUserIds 再跑一遍(applyPending:被寻址的行在 delete 时消失,在 update 时变成 { ...row, ...payload });
  3. 第二次为空而第一次不为空 ⇒ 拒写,响亮报错并点名 ADR-0024 D5.2。

两次枚举是同一份实现,所以「谁是管理员」不可能对写前问题和写后问题给出两个答案 —— 这正是把它写成一个带 pending 参数的函数、而不是写一个「差集推算器」的理由。

模拟是单向的:只会拿走身份,不会授予身份。把 role 从 member 升到 admin、把授权改指向 admin_full_access 这类写,落在枚举那两个收窄 where 之外,模拟看不见新增的管理员 ⇒ 幸存者被少算 ⇒ 每一处取整都倒向「拒写」而非「放行」。

覆盖的三条撤销形状:

钩子 判定
sys_member.role 降到 admin 等级之下 beforeUpdate 模拟后 isOrgAdminGrade 为假
sys_member beforeDelete 模拟后该行消失
admin_full_access 授权 beforeDelete 同上
授权改指向别的权限集 / 加 organization_id 组织作用域 / 改 ADR-0091 有效期窗口 beforeUpdate 模拟后分别不在 adminSetIds、有 org 作用域、isGrantActive 为假

三条硬约束的落法

  • 全覆盖,不是只拦自降级(按 PM 预裁)。真正会发生的是 IdP 组映射改别人的角色,不是管理员给自己降级。识别期没有发现任何迫使收窄的代价 —— 全覆盖与自降级在实现上是同一份代码,前者反而更简单(不需要拿 caller 身份)。
  • 谓词写做整集模拟。一次 where 命中多行的 update/delete 会先把匹配行集解析成 id 集合(resolveTargetIds,与 feat(plugin-auth): break-glass 守卫的 delete 半边 —— 最后一个管理员也删不掉 (#5941) #5993 同一个函数,加了 object 参数),再对整集做写后模拟 —— 而不是一律拒绝。只有匹配集本身解析不出来(读抛错,或超过 maxScan)才响亮拒写。
  • fail-closed。两半共用一个 failClosed 包装:任何查找抛错都变成一次点名原因的拒写。原先内联在 enforce 里的 catch 被提出来共用,措辞逐字不变(既有 /Refusing this delete/ 等断言原样通过)。

不拦的(各钉一条测试)

  • 降级到另一个 admin 等级:owneradmin,或逗号拼写 member,admin —— 等级未失。
  • 已被 ban 的管理员的身份被撤 —— 本来就不能登录,没有东西被拿走。
  • 非管理员(member / delegated_admin)的 membership、已 org 作用域化的授权、已过期的授权。
  • 不触及 role / user_id(membership)或权限集/作用域/有效期(授权)的 payload —— 这类写静态可证不改变枚举结果(MEMBER_STANDING_KEYS / GRANT_STANDING_KEYS),一次读都不做。sys_member.organization_id 不在其列:不变量是环境作用域的,membership 挪到哪个 org 都不改变谁管理这个部署。

反向验证

方向在跑之前就定了,而且是最常见的那种:。新增的钉子断言的是「拒写」,在扩守卫之前这些写全部成功。

改动前(把 last-admin-guard.ts 还原成 origin/main,测试文件保持新版):

 Test Files  1 failed | 37 passed (38)
      Tests  25 failed | 882 passed (907)

25 条红,逐条列名:

  • [#5978] path 1 — downgrading the last administrator's sys_member role
  • [#5978] path 2 — deleting the last administrator's sys_member row
  • [#5978] path 3 — revoking the last administrator's admin_full_access grant
    • two platform admins: revoking the first is allowed, revoking the last is refused
    • THE PATH ITSELF: the sys_user row is never touched
    • ORG-SCOPING the last grant is refused — a tenant admin is not a break-glass admin
    • EXPIRING the last grant is refused (ADR-0091 window, consumed as-is)
    • back-DATING valid_from past now is refused too (the other half of the window)
    • RE-POINTING the last grant at another permission set is refused
    • the non-loginable usr_system grant is never counted as the survivor
  • [#5978] the standing halves hold on predicate (multi) writes, not only by-id
    • a predicate downgrade that would sweep every administrative membership is refused
    • an unpredicated multi membership delete — the one that empties the table — is refused
    • an $in predicate naming both administrative memberships is refused
    • a predicate revoke that would sweep every admin_full_access grant is refused
    • a predicate grant EDIT that would expire every admin grant at once is refused
  • [#5978] the standing halves fail CLOSED
    • a failing identity read refuses the membership removal and names the reason
    • a failing identity read refuses the role downgrade too
    • a failing identity read refuses the grant revoke too
    • a population larger than the guard can enumerate refuses, in the op's own words
  • [#5978] the updateMemberRole path: refused as a 403, not an opaque 500
    • downgrading the LAST administrator is refused with a 403 APIError

改动后:

 Test Files  38 passed (38)
      Tests  907 passed (907)

「放行」侧的钉子(降到另一个 admin 等级、非最后管理员、已 ban 的管理员、payload 不触及相关列……)与三条 reverse verification 用例在两轮都是绿的 —— 它们断言的是写成功,改动前后都成立。这如实记在测试文件的注释里,没有把它们算进 25 条红。

fixture 处置:一条钉子被整条替换

[#5941] 那组里有一条:

it("deleting a row on another object is not this guard's business", async () => {
  await seedUser(engine, 'usr_owner', { role: 'owner' });
  // ... filed as #5978, and deliberately not half-guarded from here. Pinned so
  // the day it IS guarded, this expectation is the one that has to be changed
  // on purpose.
  await expect(
    engine.delete('sys_member', { where: { id: 'mem_usr_owner' }, ...SYSTEM }),
  ).resolves.toBeDefined();
});

它钉的正是本 PR 关掉的那个洞,注释里也写明了「哪天真守上了,就是这条要被有意改掉」。今天就是那天:

  • 替换:原地留下的同名用例改成删 mem_usr_member(一条非管理的 membership)—— 语义仍是「判过了,允许」,而不是「没在判」。原来那条 mem_usr_owner 的 delete 以相反判决搬到 [#5978] path 2THE INVERTED PIN,同一份 seed、同一个调用、相反结论,并在原位留下注释说明搬去了哪里。
  • 没有重新拼写类的 fixture(本次没有删别名分支),也没有需要补声明的 fixture。

「这确实是第三条路径」的本体钉

三条路径各有一条 THE PATH ITSELF / THE INVERTED PIN 用例:写被拒之后断言 sys_user仍在、且未被 ban(expectUserRowUntouched),被写的那张标准表也原样未动。这就是为什么 #5892 / #5941 两个钩子看不见这条路 —— 它们的 object 过滤在 sys_user 上,而这次写从头到尾没碰过 sys_user

另有一条专门的用例证明模拟不能退化成「被寻址行的集合运算」:把 membership 的 user_id 改到一个已被 ban 的用户身上 —— owner 等级仍在、行仍在,集合运算会判为无害,只有真正的写后重枚举 + resolveUnbannedAdmins 能看出幸存集为空。

消费半径清扫

守卫只有一个注册点(auth-plugin.tskernel:ready),但它现在会拦到任何引擎级的 sys_member / sys_user_permission_set 写。所以按调用方而不是按被改包清扫:

  • packages/qa/dogfood(唯一会真正启动整栈 + 写 sys_member 的测试面,例如 membership-actor-attribution.dogfood.test.tssys_member 做 update):Test Files 85 passed | 1 skipped (86) / Tests 520 passed | 3 skipped (523)
  • packages/runtime(三个 integration 测试 new AuthPlugin):Test Files 102 passed (102) / Tests 1476 passed (1476)

实测(push 前,完整数字)

pnpm --workspace-concurrency=2 --filter '@objectstack/plugin-auth' typecheck
> tsc --noEmit                                        (exit 0,无输出)

pnpm --workspace-concurrency=2 --filter '@objectstack/plugin-auth' test -- --maxWorkers=2
 Test Files  38 passed (38)
      Tests  907 passed (907)
   Duration  19.74s

pnpm --workspace-concurrency=2 --filter '@objectstack/dogfood' test -- --maxWorkers=2
 Test Files  85 passed | 1 skipped (86)
      Tests  520 passed | 3 skipped (523)

pnpm --workspace-concurrency=2 --filter '@objectstack/runtime' test -- --maxWorkers=2
 Test Files  102 passed (102)
      Tests  1476 passed (1476)

node scripts/check-nul-bytes.mjs
check-nul-bytes: OK (scanned 5780 tracked text file(s); skipped 5 binary, 1 non-regular;
                     no raw ASCII control bytes).

与在飞工作的关系

范围外发现

实现过程中发现第四条路径:删掉 / 改名 sys_permission_set 里那条 name = 'admin_full_access' 的行,会让 adminSetIds 为空 ⇒ 所有 platform admin 一次废掉;更糟的是守卫的引导期豁免(admins.size === 0 ⇒ 放行)此后会把「刚被清空」读成「引导期」,连带解除 #5892 / #5941 / 本 PR 三条路径的守卫。已立 #6084,未在本 PR 修;last-admin-guard.ts 头注释的 scope 段已改为指向它。


Generated by Claude Code

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
@vercel

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 7, 2026 12:32am

Request Review

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth.

10 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via @objectstack/plugin-auth)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/plugin-auth)
  • content/docs/kernel/contracts/cache-service.mdx (via @objectstack/plugin-auth)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/authentication.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/sso.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-auth)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-auth)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-auth)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

队列管家:本 PR 是链上连坐**,自身无问题 ⇒ ⛔ 未重投、无需改动**

本 PR 所在的队列世代 Lint \& Type Check 判红,致命 step 9 Slot-lookup ratchet:

• packages/plugins/plugin-sharing/src/sharing-plugin.ts: erasure count grew 10 → 11.

该文件不在本 PR 的改动面内。 本轮以 base sha 重建队列链序为 #5999#6067#6086#6061#6068;grouping_strategy: ALLGREEN 下,链上条目会继承其前序条目的改动。改动 sharing-plugin.ts 的是链上第 2 位的 #6067(新增 ctx.getService<any>('tenancy'),即 ratchet 所数的类型擦除站点),本 PR 位于其之后,故继承该红。

反证:链上位于 #6067 之前#5999 没有这条红(它的红是另一条已知 flaky,在 CI job 而非 Lint job)⇒ 责任边界与链序一致。

处置:⛔ 不重投(重投无效——只要 #6067 仍在本 PR 之前,该红就会重现)、⛔ 不改代码。#6067 转绿或出队后本 PR 自动恢复。完整签名与修法建议已留在 #6067 的拦截评论

已核让行:本 PR 最近 30 分钟无车道 PM 动作;本 PR 当前仍在队列内(无 removed 事件),本座位未撤队、未动认领。


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants