Skip to content

feat(plugin-auth): break-glass 守卫 —— ban 不得停用最后一个管理员(ADR-0024 D5.2) - #5939

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-5892-last-admin-breakglass
Aug 6, 2026
Merged

feat(plugin-auth): break-glass 守卫 —— ban 不得停用最后一个管理员(ADR-0024 D5.2)#5939
baozhoutao merged 2 commits into
mainfrom
claude/issue-5892-last-admin-breakglass

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes #5892

前提逐条复核(动手前做的,结论改变了范围)

派发令要求先核对正文三项在 origin/main 上的现状。核对结果:

要求 现状 本 PR 做了什么
sys_user.beforeUpdate:更新会导致「剩余 0 个未被 ban 的管理员」时拒绝 确实缺失。全仓 git grep 对「最后一个管理员 / 计数后拒绝」这一形状零命中(last admin / lastAdmin / data.banned 判定均无命中) 新实现
enforced SSO 不得停用最后一个本地管理员的密码 已实现:auth-manager.tsemailAndPassword 段(resolveSsoOnly() 只强制 disableSignUp,从不动 enabled)、getPublicConfig()features.ssoEnforced、以及 /admin/ban-user 等三条路径上的 LAST_LOCAL_CREDENTIAL 守卫(auth-manager.ts:1239-1300)。但没有任何测试 只补钉,不重写
③ 测试 两半都没有 两个新测试文件

所以本 PR 的范围比正文小:第 ② 项一行实现代码都没改。

① 新守卫:last-admin-ban-guard.ts

sys_user.banned = true所有停用路径的落点 —— better-auth admin 插件的 ban 端点写它,@better-auth/scim 把 SCIM active: false 映射成同一次 admin ban(这正是 SCIM 会强制打开 admin 插件的原因,ADR-0071)。此前没有任何一层检查这次写入之后还剩下谁:把最后一个管理员 ban 掉是允许的,而且返回成功,组织就此被永久锁在自己的环境外面。SCIM 让它从假设变成现实风险 —— 写入由外部系统驱动,提交前没有人看一眼 payload,一个圈错的 IdP 组就够了。

守卫因此长在写入的那一个咽喉上,而不是某个端点上:sys_userbeforeUpdate。admin ban 端点、SCIM 的 adapter 写入、导入、脚本、以及以后新增的任何路径都被同一条不变量覆盖;by-id 与谓词/multi 两种写法都守(谓词那一支是「只读 input.id」的守卫必然漏掉的形状)。

谁算管理员 —— 用平台自己已有的答案,不另发明:

  • 平台管理员:未限定组织(organization_id = null)且在有效期内(ADR-0091)的 admin_full_access 授权 —— 与 resolveAuthzContext 推导 platform_admin 所用的是同一份证据(ADR-0068 D2 / ADR-0095 D3),绝不读 sys_user.role 字符串;
  • 组织 owner / admin:sys_member.role 的等级 —— 判定走 invitation-role-cap.ts唯一那把等级尺(本 PR 把它导出为 isOrgAdminGrade),而不是在守卫里重新拼一个 role === 'owner':手抄版会漏掉 'owner,member' 这种逗号拼写,把环境里唯一的 owner 误判成普通成员。

delegated_admin 不算(ADR-0105 D8:它只让你够得着端点,本身不带权限)、过期授权不算、不可登录的 usr_system 不算 —— 后两者若被算成「还剩一个管理员」,守卫就会替一次真实的锁死放行。

fail-closed:管理员群体读不出来、或大到无法枚举时,拒绝这次 ban 而不是猜。这与邻近的 LAST_LOCAL_CREDENTIAL 守卫(fail-open)方向相反,是刻意的:那边失败的代价是挡住一次合法操作,这边失败的代价是环境被永久锁死。两个方向都写进了各自文件的头注释,并各有一条测试钉住,免得日后有人「统一一下」。

拒绝信息即操作指引:错误里点名是哪个用户、这条不变量是什么(ADR-0024 D5.2)、怎样让这次 ban 变合法(先给别人 admin_full_access 或 owner/admin 成员身份),以及 —— 如果这次 ban 来自 IdP —— 要改的是 IdP 组而不是这个守卫。

与 ADR-0092 身份写守卫的关系:那个守卫回答「这个调用方能否经通用数据面写身份表」,并且按设计放行 system 上下文(better-auth 自己的 adapter 正是它必须放行的);本守卫回答的是另一个问题「这个能不能被写」,因此对所有上下文生效,isSystem 也不例外 —— 真正会造成锁死的恰恰是 system 这条路。两者在 auth-plugin.ts 的同一个 kernel:ready 里一起注册,优先级 10 → 20:用户上下文的调用方仍然先拿到 ADR-0092 的答复(「banned 不是数据面可编辑列」),只有合法携带 banned 的写入才会走到本守卫。这条顺序有测试钉住。

顺带修的一处(同一条路径上的必要半边):守卫抛的是引擎形状的错误(PERMISSION_DENIED + 403),REST 数据面的 mapDataError 本来就认;但 auth 管线不认,会变成一个空白 500。withValidationErrorMapping 因此增加了一条 403 分支,把它映射成 better-auth 的 APIError('FORBIDDEN')。一个「产出就是解释」的守卫,不能以「无解释的 500」抵达 IdP。

② 密码逃生口:只补钉

break-glass-local-credential.test.ts 覆盖既有实现,不改一行:

  • enforced SSO(ssoOnlyModeOS_AUTH_SSO_ONLY 两个开关)下 emailAndPassword.enabled 保持 truedisableSignUp 被强制 true,getPublicConfig() 同时报 features.ssoEnforced: trueemailPassword.enabled: true —— 这个「叫 UI 藏起来、能力本身仍开着」的落差就是逃生口本身;
  • 另有一条反向对照:显式 emailAndPassword.enabled: false 仍然生效 —— 否则上面那条断言对着一个「根本不读这个选项」的实现也会绿;
  • /admin/ban-user/admin/remove-user/delete-user 三条路径上,目标是唯一 credential 账户持有人时抛 LAST_LOCAL_CREDENTIAL;有第二个密码持有人时放行;目标是 IdP 托管(无本地凭据)时连全表扫描都不做;查询报错时 fail-open。

测试

last-admin-ban-guard.test.ts 不用假引擎:守卫的全部工作就是读身份表再判断,假引擎意味着由 fixture 里手写的 where 匹配器来决定守卫看见哪些行 —— 也就是让 fixture 而不是产品来回答被测的问题(#5785 点名的那种危险)。所以每条用例都跑在真的 ObjectQL 引擎 + 真的 better-sqlite3 :memory: 上:引擎派发钩子,SQL builder 编译 $in / $ne,布尔按 0/1 落库 —— 顺带把 better-auth supportsBooleans: false 交给引擎的数字 1 这条真实形状也覆盖了。

反向验证(方向先定后跑):文件里有一条 unguarded: true 的用例 —— 不注册守卫时,ban 最后一个管理员成功且行确实变成 banned = 1。这就是 #5892 之前的行为,也是其余每条 rejects.toThrow 的对照系。

20 条守卫用例 + 7 条密码逃生口用例,覆盖:两个管理员先后 ban(第一个放行、最后一个拒绝)、非管理员不受影响、平台管理员 vs 组织内限定授权、过期授权、delegated_admin、逗号拼写、usr_system、重复 ban 已封禁者、unban 与无关写入不触发、谓词与 $in 批量、fail-closed 的两种(读失败 / 超过枚举上限)、无管理员环境不被卡死、ADR-0092 顺序,以及经 better-auth adapter 的 SCIM/admin-ban 路径(403 APIError 而非 500)。

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

pnpm --filter @objectstack/plugin-auth typecheck   # tsc --noEmit, 无输出
pnpm --filter @objectstack/plugin-auth build       # DTS/ESM/CJS 均成功

闸门:check:nul-bytescheck:engine-double-contract(64 pinned,无新条目)、check:query-options-erasure(non-test 84 未新增,test 面 267 未移动)、check:error-code-casingcheck:role-wordcheck:adr-anchorscheck:authz-resolver 全绿;改动文件 eslint --no-inline-config 无告警。

边界

  • 未触碰 @better-auth/scim 的模型面 —— 本守卫是本仓自己的 sys_user 钩子,与 SCIM: 停在 @better-auth/scim rc.1,等正式版再整体迁移 —— rc.2 换掉了整套模型 #3653 的版本挂起无关;
  • 未触碰 packages/spec / packages/objectql:拒绝时用的是标准目录里已有的 PERMISSION_DENIED,而不是新造一个服务码(ADR-0112「通用条件走标准目录」,rest-server.tsRECORD_NOT_ACCESSIBLE 的处理就是这个先例),因此不需要往 error-code ledger 里加条目;
  • 不变量的作用域是环境(「本环境至少留一个能登录的管理员」),不是每个组织。「每个 org 必须保留一个 owner」是另一条更严的策略,牵涉自己的产品决定(唯一 owner 离职时怎么办),没有在这里顺手发明 —— 文件头注释写明了这一点。

Generated by Claude Code

…inistrators (#5892)

`sys_user.banned = true` is where every deprovision lands (better-auth's admin
ban; `@better-auth/scim` maps `active: false` onto it), and nothing checked what
the write left behind: banning the last administrator succeeded and locked the
organization out of its own environment for good. SCIM makes that an accident
waiting to happen — the write is driven by an external system, so nobody reads
the payload before it commits.

`last-admin-ban-guard.ts` registers a `beforeUpdate` hook on `sys_user` that
refuses any write turning `banned` on when it would leave the environment with
no unbanned administrator. It guards the WRITE, not an endpoint, so the admin
ban route, the SCIM adapter write, an import and a script are all covered, by-id
and predicate/multi alike. Administrator = the platform's own answer: an
unscoped in-window `admin_full_access` grant, or an `owner`/`admin` membership
graded by the single ladder in `invitation-role-cap.ts` (now exported as
`isOrgAdminGrade`). `delegated_admin`, expired grants and `usr_system` do not
count. Fail-closed: an unverifiable population refuses the ban.

The refusal carries `PERMISSION_DENIED` + 403; `withValidationErrorMapping`
gains a 403 arm so the auth pipeline reports it as an `APIError` instead of an
opaque 500.

The password half of the same invariant (enforced SSO never disables the last
local admin's password) was already implemented and is pinned, not rewritten.

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 12:42pm

Request Review

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

Copy link
Copy Markdown
Contributor Author

范围外发现,已另行开单(Prime Directive #10),均未在本 PR 修:

两条都与本 PR 的落点相邻但不同:一条换事件(delete vs update),一条换调用方(/sso/register 门禁)。


Generated by Claude Code

…. they are」

守卫的产出就是那段解释,所以它得读得通。单数/复数分别成句,`'that account'` 替掉指代不明的 `'it'`。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JwwiU9bjhwy2SWj13ho8uv
@baozhoutao
baozhoutao marked this pull request as ready for review August 6, 2026 13:03
@baozhoutao
baozhoutao added this pull request to the merge queue Aug 6, 2026
Merged via the queue into main with commit 61dc08e Aug 6, 2026
24 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-5892-last-admin-breakglass branch August 6, 2026 13:10
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

Development

Successfully merging this pull request may close these issues.

plugin-auth: break-glass 守卫 —— SCIM/ban 不得停用最后一个管理员(ADR-0024 D5.2,cloud#621 转入)

2 participants