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_memberbeforeDelete模拟后该行消失
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()=>{awaitseedUser(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.awaitexpect(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

vercelBot commented Aug 6, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 7, 2026 12:32am

Request Review

@github-actions

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

claudeBot 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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

2 participants

@baozhoutao@claude