feat(plugin-auth): break-glass 守卫的 delete 半边 —— 最后一个管理员也删不掉 (#5941) - #5993
Merged
Conversation
#5892 / PR #5939 只守住了 ban(`sys_user.banned = true`,`beforeUpdate`)。 删除这条路径今天没有任何一段代码判断「管理员还剩几个」:`auth-manager.ts` 那段 HTTP 守卫判的是「最后一个本地 credential 持有人」,目标不持本地密码 (enforced SSO 下 IdP 托管 / SCIM JIT provision 出来的管理员)时整段跳过, 于是 SCIM `DELETE /Users/{id}` / `/admin/remove-user` 可以把环境最后一个 管理员的行删掉,环境从此无人可管。 本次把同一条不变量装到 `sys_user` 的 `beforeDelete` 上,与 ban 半边同形状、 同一份管理员枚举:装在写上而不是端点上(SCIM adapter、admin remove-user、 导入、脚本都覆盖),by-id 与谓词/multi(含无谓词的清表 multi)都守,对 `isSystem` 同样生效(真正会锁死的正是 system 那条),并且 fail-closed —— 枚举读不出来或超出 maxScan 一律拒绝。 实测(写进了模块头注释):`beforeDelete` 的 by-id 派发给 `input.id`, 谓词/multi 派发把行域谓词放在 `input.options.where`;`ctx.previous` (引擎 #5272 预取 + objectql 的 `sys_fetch_previous_delete` 内建)只在 by-id 形状上绑定,批量派发上是 undefined ——守卫因此完全不消费它,只解析目标 id 集。 拒绝走 `PERMISSION_DENIED` + 403,信息按调用方实际执行的动作措辞 (「Refusing to delete '…'」),并给出 ADR-0024 D5.2 依据与修复办法; `withValidationErrorMapping` 逐方法包装,#5939 加的 403 arm 原样覆盖 `delete`。 模块随之更名为 `last-admin-guard.ts` / `registerLastAdminGuard`(原 `last-admin-ban-guard.ts`,同一个未发布周期内加入):它现在注册两个钩子, 旧名字会低估它装了什么。 ⛔ 未动 `auth-manager.ts:1239-1300` 的 fail-open HTTP 守卫段(分诊明令, 它继续守自己的 credential 版不变量,两层并存)。撤销管理员「身份」的第三条 写法(改/删 `sys_member` 行、撤 `admin_full_access` 授权)另开 #5978。 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
|
Contributor
📓 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:
|
…t-admin-delete-guard
#5964 刚把 `HookEvent` 的枚举注释对齐到契约表的「谓词不在 `input` 上」。 两句都对,但要分清:钩子拿不到的是 composed `ast`(生效谓词);`before*` 期间 `input.options` 仍是调用方那只 engine options 包(engine.ts 5516→5517 / 6137→6152 之后才重建成 DriverOptions),`where` 与 `multi` 都在 —— 守卫读的 正是它。中间件只收窄不放宽,所以把调用方谓词当目标集是上界近似,对 fail-closed 守卫恰是安全方向。契约表那两处 `before` 行的措辞另记为 #5997。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JwwiU9bjhwy2SWj13ho8uv
baozhoutao
marked this pull request as ready for review
August 6, 2026 14:22
This was referenced Aug 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5941
前提复核(先于实现)
对
origin/main(#5939 已合入,61dc08ead)逐条核对,issue 前提成立:last-admin-ban-guard.ts只注册beforeUpdate,delete 路径上没有任何「还剩几个管理员」的判定;auth-manager.ts:1239-1300的守卫先查目标是否持有本地credential账户(targetCred),不持有则整段跳过 —— enforced SSO 下 IdP 托管 / SCIM JIT provision 出来的管理员正好是这一类;identity-write-guard.ts的rejectWrite('delete')按 ADR-0092 D2 设计放行isSystem,而 better-auth adapter 的删除正是 system。⇒ 一条完整可达链:IdP 把最后一个管理员移出组 → SCIM
DELETE /Users/{id}→ 行被删 → 环境无人可管。硬前置的实测(
beforeDelete能不能拿到判定所需的行集)分诊要求「拿不到就 STOP」。拿得到,实测结论(同时写进了模块头注释):
input.idinput.options.wherectx.previousdelete(obj, { where: { id } }),better-auth adapter 与级联递归都是这个){ id }multi{ id: { $in: […] } })关于 #5929 的引擎内建预取:
sys_fetch_previous_delete(object: '*',beforeDelete,priority 5)与引擎自己的 #5272 预取都只在input.id存在时取行,批量派发上一律不绑。所以守卫完全不消费ctx.previous:它要的是目标 id 集而不是行快照,而基于previous的实现会在 by-id 上正确、在恰恰能一次扫掉全部管理员的批量路径上失明。与 HookContext 契约表的关系(#5964 今天刚落地,值得说清):契约表说「行级谓词 NOT reachable from
inputat all」—— 那说的是 composedast(生效谓词,filters 中间件会往上叠 RLS / sharing 收窄),钉子测试断言的也正是'ast' in input === false。而before*期间input.options仍是调用方那只 engine options 包:engine.ts在triggerHooks('beforeUpdate')(5516)与triggerHooks('beforeDelete')(6137)返回之后(5517 / 6152)才把它重建成DriverOptions。守卫读的就是这只包里的where,与 ban 半边同一份代码。中间件只收窄不放宽 ⇒ 把调用方谓词当目标集是上界近似:可能拒掉一个实际会删更少行的写,永远不会漏掉删更多行的写 —— fail-closed 方向。契约表那两处before行把options记成DriverOptions的措辞另记为 #5997(观察类,未在本 PR 修)。改了什么
一条不变量,两个写入口,共用同一份管理员枚举(
resolveAdminUserIds,ADR-0024 D5.2):sys_user.banned = truebeforeUpdate(#5892,已有)sys_user行beforeDelete(本次)/admin/remove-user、导入、脚本一并覆盖;multi(会清表的那种);isSystem生效 —— 真正会锁死环境的正是 system 那条;maxScan,拒绝而不是猜;Refusing to delete 'usr_…',而不是「ban」),带 ADR-0024 D5.2 依据、补救办法、以及「若来自 IdP,是 SCIM deprovision 范围过宽」的指向。PERMISSION_DENIED→FORBIDDEN的映射无需补:withValidationErrorMapping是逐方法包装适配器的,#5939 加的 403 arm 原样覆盖delete,测试从适配器面驱动证实(403 +PERMISSION_DENIED,非 500)。⛔ 未动
auth-manager.ts:1239-1300的 fail-open HTTP 守卫段(分诊明令):它继续守它的 credential 版不变量,两层并存 —— 头注释把「为什么两者方向相反且都对」写清楚了。更名:
last-admin-ban-guard.ts/registerLastAdminBanGuard→last-admin-guard.ts/registerLastAdminGuard。它现在注册两个钩子,旧名会低估装了什么;该导出与 #5939 同属未发布周期(CHANGELOG 里还没有它),仓内外均无第三方消费者,changeset 里写了 FROM → TO。测试(先红后绿,真实输出)
新增 18 个用例,全部落在同一个真
ObjectQL+ better-sqlite3:memory:引擎上(沿用 #5939 的 harness:没有假引擎,where由真 SQL builder 编译,布尔以 0/1 落盘)。下面是合并origin/main(含07f1822a9的 NULL-safe$ne,守卫的sys_member扫描正用$ne)之后重跑的结果:反向验证(方向事先预测:红) —— 把
beforeDelete的注册摘掉重跑同一批 fixture:留绿的 28 条正是「本就该放行」的那些(非管理员、还剩别的管理员、已被 ban 的管理员、ban 半边全部用例),即方向正确而非用例失效。文件底部另有一条常驻的
unguarded: true反向用例:pre-#5941 引擎删掉最后一个管理员并报成功,留下一个能登录、什么都管不了的密码持有者 —— issue 描述的终局状态。覆盖面:① 两管理员删一个允许 / 删最后一个拒绝;② IdP 托管(无本地 credential)最后管理员经 system 上下文删除 → 拒绝(issue 可达链本体,fixture 里连
sys_account都按 issue 的样子种上,以此钉住守卫不看凭据);③ 谓词 /$in/ 无谓词 multi;④ 非管理员、已 ban 管理员、scoped 授权持有人、delegated_admin、usr_system不受影响或不计数;⑤ 枚举失败与 maxScan 溢出 fail-closed;⑥ 用户上下文的 delete 仍先拿到 ADR-0092 的答案(优先级 10 → 20);⑦ 适配器面(SCIMDELETE /Users/{id})403APIError。另跑:
check:nul-bytes、check:engine-double-contract、check:adr-anchors、check:durability-log-level、check:startup-registry-verdict、check:error-code-casing、check:role-word、eslint(改动文件)—— 全绿。本 PR 未引入新的假引擎(fail-closed 用例的替身只实现registerHook+find),不涉及assertEngineDeleteDispatch。与 #5942 的关系(必答项)
变简单一点点,不变难。 本 PR 没有新增第三种「谁算管理员」的拼写:delete 半边复用的正是 ban 半边那份
resolveAdminUserIds,它只调用唯一那把等级尺isOrgAdminGrade(invitation-role-cap.ts,parseOrgRoles会.toLowerCase())。#5942 的收口动作(把auth-manager.ts:3625-3634那 6 行手抄版换成isOrgAdminGrade)因此仍是同一处、同样大小的改动。唯一的变化是赌注变大:等级尺现在有三个消费者(invitation cap、ban 守卫、delete 守卫),其中两个是安全判定路径。若
sys_member.role真的存进Owner/ADMIN,两个 break-glass 守卫都会把该行算作管理员(于是可能放行删掉另一个真管理员),而/sso/register门禁仍算他不是 —— 分歧的方向和 #5942 记的一致,只是覆盖面从一条路径变成两条。定价可据此排序,但改动量不变。顺带发现(均未在本 PR 修)
sys_member行、撤admin_full_access授权,能在不动用户行的前提下拿掉最后一个管理员的身份,两个钩子(都挂在sys_user上)都看不见。模块头注释里点名,测试里也钉了一条「删sys_member不受本守卫管辖」的现状用例,好让它被守住的那天是被有意改掉的。before*的input.options记成 DriverOptions —— 实测那里仍是调用方的 engine options(含 where),两个 break-glass 守卫正读它 #5997(finding)—— 上面那段契约表措辞:before*的input.options被记成DriverOptions,实测那里仍是调用方的 engine options(含where),而两个 break-glass 守卫正读它;钉子测试只钉了ast那一半。