Skip to content

feat(plugin-auth): break-glass 守卫的 delete 半边 —— 最后一个管理员也删不掉 (#5941) - #5993

Merged
baozhoutao merged 3 commits into
mainfrom
claude/issue-5941-last-admin-delete-guard
Aug 6, 2026
Merged

feat(plugin-auth): break-glass 守卫的 delete 半边 —— 最后一个管理员也删不掉 (#5941)#5993
baozhoutao merged 3 commits into
mainfrom
claude/issue-5941-last-admin-delete-guard

Conversation

@baozhoutao

@baozhoutao baozhoutao commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

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.tsrejectWrite('delete') 按 ADR-0092 D2 设计放行 isSystem,而 better-auth adapter 的删除正是 system。

⇒ 一条完整可达链:IdP 把最后一个管理员移出组 → SCIM DELETE /Users/{id} → 行被删 → 环境无人可管。

硬前置的实测(beforeDelete 能不能拿到判定所需的行集)

分诊要求「拿不到就 STOP」。拿得到,实测结论(同时写进了模块头注释):

派发形状 input.id input.options.where ctx.previous
by-id(delete(obj, { where: { id } }),better-auth adapter 与级联递归都是这个) 标量 id { id } 已绑定(整行)
谓词 / multi present-but-undefined 行域谓词(如 { id: { $in: […] } }) undefined

关于 #5929 的引擎内建预取:sys_fetch_previous_delete(object: '*',beforeDelete,priority 5)与引擎自己的 #5272 预取都只在 input.id 存在时取行,批量派发上一律不绑。所以守卫完全不消费 ctx.previous:它要的是目标 id 集而不是行快照,而基于 previous 的实现会在 by-id 上正确、在恰恰能一次扫掉全部管理员的批量路径上失明。

与 HookContext 契约表的关系(#5964 今天刚落地,值得说清):契约表说「行级谓词 NOT reachable from input at all」—— 那说的是 composed ast(生效谓词,filters 中间件会往上叠 RLS / sharing 收窄),钉子测试断言的也正是 'ast' in input === false。而 before* 期间 input.options 仍是调用方那只 engine options 包:engine.tstriggerHooks('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 = true beforeUpdate(#5892,已有)
sys_user beforeDelete(本次)
  • 装在写上,不是端点上:SCIM adapter delete、/admin/remove-user、导入、脚本一并覆盖;
  • by-id 与谓词/multi 都守,包括无谓词的 multi(会清表的那种);
  • isSystem 生效 —— 真正会锁死环境的正是 system 那条;
  • fail-closed:枚举读失败或超出 maxScan,拒绝而不是猜;
  • 拒绝按调用方实际动作措辞(Refusing to delete 'usr_…',而不是「ban」),带 ADR-0024 D5.2 依据、补救办法、以及「若来自 IdP,是 SCIM deprovision 范围过宽」的指向。

PERMISSION_DENIEDFORBIDDEN 的映射无需补:withValidationErrorMapping 是逐方法包装适配器的,#5939 加的 403 arm 原样覆盖 delete,测试从适配器面驱动证实(403 + PERMISSION_DENIED,非 500)。

未动 auth-manager.ts:1239-1300 的 fail-open HTTP 守卫段(分诊明令):它继续守它的 credential 版不变量,两层并存 —— 头注释把「为什么两者方向相反且都对」写清楚了。

更名:last-admin-ban-guard.ts / registerLastAdminBanGuardlast-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)之后重跑的结果:

pnpm --filter @objectstack/plugin-auth test
 Test Files  38 passed (38)
      Tests  866 passed (866)

pnpm --filter @objectstack/plugin-auth typecheck   # tsc --noEmit,无输出即通过

反向验证(方向事先预测:红) —— 把 beforeDelete 的注册摘掉重跑同一批 fixture:

 Tests  11 failed | 28 passed (39)
 × two org admins: deleting the first is allowed, deleting the last is refused
 × the reachable chain: an IdP-managed last admin (no local credential) removed by a SYSTEM caller is refused
 × a predicate that would sweep every administrator is refused
 × an unpredicated `multi` delete — the one that empties the table — is refused
 × a failing identity read refuses the delete and names the reason
 × removing the LAST administrator is refused with a 403 APIError
 …(共 11 条)

留绿的 28 条正是「本就该放行」的那些(非管理员、还剩别的管理员、已被 ban 的管理员、ban 半边全部用例),即方向正确而非用例失效。文件底部另有一条常驻的 unguarded: true 反向用例:pre-#5941 引擎删掉最后一个管理员并报成功,留下一个能登录、什么都管不了的密码持有者 —— issue 描述的终局状态。

覆盖面:① 两管理员删一个允许 / 删最后一个拒绝;② IdP 托管(无本地 credential)最后管理员经 system 上下文删除 → 拒绝(issue 可达链本体,fixture 里连 sys_account 都按 issue 的样子种上,以此钉住守卫不看凭据);③ 谓词 / $in / 无谓词 multi;④ 非管理员、已 ban 管理员、scoped 授权持有人、delegated_adminusr_system 不受影响或不计数;⑤ 枚举失败与 maxScan 溢出 fail-closed;⑥ 用户上下文的 delete 仍先拿到 ADR-0092 的答案(优先级 10 → 20);⑦ 适配器面(SCIM DELETE /Users/{id})403 APIError

另跑:check:nul-bytescheck:engine-double-contractcheck:adr-anchorscheck:durability-log-levelcheck:startup-registry-verdictcheck:error-code-casingcheck: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 修)

#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
@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 6, 2026 2:08pm

Request Review

@github-actions github-actions Bot added the size/l label Aug 6, 2026
@github-actions

github-actions Bot commented Aug 6, 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.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 6, 2026
claude added 2 commits August 6, 2026 14:02
#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
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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

最后一个管理员的「删除」路径无守卫:目标不持本地密码时,SCIM/admin remove 可删掉环境最后一个管理员

2 participants