fix(plugin-auth): break-glass 补上第三条路径 —— 撤销管理员「身份」的写(sys_member 降级/删行、admin_full_access 授权删/改)同样被拒 (#5978) - #6086
Conversation
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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 10 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
|
队列管家:本 PR 是链上连坐**,自身无问题 ⇒ ⛔ 未重投、无需改动** 本 PR 所在的队列世代 该文件不在本 PR 的改动面内。 本轮以 base sha 重建队列链序为 #5999 → #6067 → #6086 → #6061 → #6068; 反证:链上位于 #6067 之前的 #5999 没有这条红(它的红是另一条已知 flaky,在 CI job 而非 Lint job)⇒ 责任边界与链序一致。 处置:⛔ 不重投(重投无效——只要 #6067 仍在本 PR 之前,该红就会重现)、⛔ 不改代码。#6067 转绿或出队后本 PR 自动恢复。完整签名与修法建议已留在 #6067 的拦截评论。 已核让行:本 PR 最近 30 分钟无车道 PM 动作;本 PR 当前仍在队列内(无 Generated by Claude Code |
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_member与sys_user_permission_set的beforeUpdate/beforeDelete,合计六个钩子,同packageId、同 priority 20、同engine.registerHook调用形状,只有object过滤不同 —— 整个不变量作为一个 package 一起绑定/解绑。判据是 issue 的原话:
resolveAdminUserIds再跑一遍(applyPending:被寻址的行在 delete 时消失,在 update 时变成{ ...row, ...payload });两次枚举是同一份实现,所以「谁是管理员」不可能对写前问题和写后问题给出两个答案 —— 这正是把它写成一个带
pending参数的函数、而不是写一个「差集推算器」的理由。模拟是单向的:只会拿走身份,不会授予身份。把 role 从
member升到admin、把授权改指向admin_full_access这类写,落在枚举那两个收窄where之外,模拟看不见新增的管理员 ⇒ 幸存者被少算 ⇒ 每一处取整都倒向「拒写」而非「放行」。覆盖的三条撤销形状:
sys_member.role降到 admin 等级之下beforeUpdateisOrgAdminGrade为假sys_member行beforeDeleteadmin_full_access授权beforeDeleteorganization_id组织作用域 / 改 ADR-0091 有效期窗口beforeUpdateadminSetIds、有 org 作用域、isGrantActive为假三条硬约束的落法
where命中多行的 update/delete 会先把匹配行集解析成 id 集合(resolveTargetIds,与 feat(plugin-auth): break-glass 守卫的 delete 半边 —— 最后一个管理员也删不掉 (#5941) #5993 同一个函数,加了object参数),再对整集做写后模拟 —— 而不是一律拒绝。只有匹配集本身解析不出来(读抛错,或超过maxScan)才响亮拒写。failClosed包装:任何查找抛错都变成一次点名原因的拒写。原先内联在enforce里的 catch 被提出来共用,措辞逐字不变(既有/Refusing this delete/等断言原样通过)。不拦的(各钉一条测试)
owner→admin,或逗号拼写member,admin—— 等级未失。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,测试文件保持新版):25 条红,逐条列名:
[#5978] path 1 — downgrading the last administrator's sys_member roledelegated_adminIS refused (ADR-0105 D8: reach, not authority)[#5978] path 2 — deleting the last administrator's sys_member row[#5978] path 3 — revoking the last administrator's admin_full_access grantvalid_frompast now is refused too (the other half of the window)usr_systemgrant is never counted as the survivor[#5978] the standing halves hold on predicate (multi) writes, not only by-idmultimembership delete — the one that empties the table — is refused$inpredicate naming both administrative memberships is refused[#5978] the standing halves fail CLOSED[#5978] the updateMemberRole path: refused as a 403, not an opaque 500改动后:
「放行」侧的钉子(降到另一个 admin 等级、非最后管理员、已 ban 的管理员、payload 不触及相关列……)与三条
reverse verification用例在两轮都是绿的 —— 它们断言的是写成功,改动前后都成立。这如实记在测试文件的注释里,没有把它们算进 25 条红。fixture 处置:一条钉子被整条替换
[#5941]那组里有一条:它钉的正是本 PR 关掉的那个洞,注释里也写明了「哪天真守上了,就是这条要被有意改掉」。今天就是那天:
mem_usr_member(一条非管理的 membership)—— 语义仍是「判过了,允许」,而不是「没在判」。原来那条mem_usr_owner的 delete 以相反判决搬到[#5978] path 2的THE INVERTED PIN,同一份 seed、同一个调用、相反结论,并在原位留下注释说明搬去了哪里。「这确实是第三条路径」的本体钉
三条路径各有一条
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.ts的kernel:ready),但它现在会拦到任何引擎级的sys_member/sys_user_permission_set写。所以按调用方而不是按被改包清扫:packages/qa/dogfood(唯一会真正启动整栈 + 写sys_member的测试面,例如membership-actor-attribution.dogfood.test.ts对sys_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 前,完整数字)
与在飞工作的关系
last-admin-guard.ts/last-admin-guard.test.ts/auth-plugin.ts/ changeset;fix(plugin-auth): /sso/register 门禁改用唯一那把管理员等级尺 (#5942) #6010 改auth-manager.ts/auth-manager.test.ts/ changeset。⛔ 全程未触碰auth-manager.ts与其测试。invitation-role-cap.ts导出的isOrgAdminGrade(feat(plugin-auth): break-glass 守卫 —— ban 不得停用最后一个管理员(ADR-0024 D5.2) #5939 已合入;「这个 membership 是不是管理员」有两种拼写,大小写敏感性不同:isOrgOrPlatformAdmin对role='Owner'答否,等级尺答是 #5942 / PR fix(plugin-auth): /sso/register 门禁改用唯一那把管理员等级尺 (#5942) #6010 把/sso/register门禁也换到同一把尺)。invitation-role-cap.ts全程只读。有一条测试专门钉住这点:降级到member,admin被放行 —— 手抄一份role === 'owner' || role === 'admin'到模拟里会误拒这条合法写。resolveAdminUserIds现有的isGrantActive(ADR-0091 D2)原样消费。本 PR 对「授权过期语义」不做任何判断,那是auth-contains-filter.test.ts的见证后端迁 sqlite 排在 #5702 之后 —— #5830 裁决 C 的到期单 #5893 的问题(blocked on drivers:$regex响亮拒收 +$icontains各后端实现(#4706 裁决 B 案 · 驱动半边) #5702)。范围外发现
实现过程中发现第四条路径:删掉 / 改名
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