发现于 #5892 / PR #5939 的实现过程(**范围外,未在该 PR 修**)。 ## 现状 #5892 只要求守住 **ban**(`sys_user.banned = true`),PR #5939 因此把不变量装在 `sys_user` 的 `beforeUpdate` 上。**删除**这条路径上的同一条不变量,今天由两段代码覆盖,而两段都不判断「管理员还剩几个」: 1. `packages/plugins/plugin-auth/src/auth-manager.ts:1239-1300` —— `/delete-user`、`/admin/remove-user`、`/admin/ban-user` 三条路径上的 break-glass 守卫。它判定的是**最后一个本地 `credential` 账户持有人**: ``` const targetCred = await adapter.findOne({ model: 'account', where: [ { field: 'userId', value: targetId }, { field: 'providerId', value: 'credential' } ] }); if (targetCred) { /* 只有目标自己持有本地密码时才继续数 */ } ``` 目标**不持**本地凭据(IdP 托管 / SCIM JIT provision 出来的用户)时,守卫整段跳过。 2. `identity-write-guard.ts`(ADR-0092 D2)拒绝**用户上下文**的 `sys_user` delete,但按设计放行 `isSystem` —— better-auth adapter 自己的写入正是 system。 ## 可达的锁死 一个 enforced SSO 环境,最后一个管理员由 IdP 托管(无本地密码),IdP 侧把他移出组 → SCIM `DELETE /Users/{id}`(或 `/admin/remove-user`)→ 两段守卫都不触发,用户行被删除。环境里可能还留着一个持密码的**非管理员**逃生账户:能登录,但不能管理任何东西 —— 与 ADR-0024 D5.2 想避免的状态是同一类。 PR #5939 的 ban 守卫不覆盖这条:它是 `beforeUpdate`,delete 走 `beforeDelete`。 ## 可能的方向(未决) - 把「剩余 0 个未被 ban 的管理员则拒绝」的判定从 `last-admin-ban-guard.ts` 复用到 `beforeDelete`(同一份 `resolveAdminUserIds`,目标集换成被删行); - 或者把 auth-manager 那段 HTTP 守卫的判据从「最后一个 credential 持有人」扩成「最后一个管理员」—— 但那段是 fail-**open** 的,与不变量要求的 fail-closed 方向相反,合并前需要先决定方向。 两条不等价:HTTP 守卫只覆盖 better-auth 的三条端点,引擎钩子覆盖所有写入路径(#5939 的头注释解释了为什么选后者)。 ## 参考 - #5892 / PR #5939(ban 半边,已实现) - cloud ADR-0024 D5.2(break-glass 不变量) - ADR-0092 D2(身份写守卫,放行 system 的原因) --- _Generated by [Claude Code](https://claude.ai/code/session_01JwwiU9bjhwy2SWj13ho8uv)_
Activity
claude commentedon Aug 6, 2026
分诊:
pm:queue+domain:identity。packages/plugins/plugin-auth/src/—— 具体是把 plugin-auth: break-glass 守卫 —— SCIM/ban 不得停用最后一个管理员(ADR-0024 D5.2,cloud#621 转入) #5892 / PR feat(plugin-auth): break-glass 守卫 —— ban 不得停用最后一个管理员(ADR-0024 D5.2) #5939 已落地的last-admin-ban-guard.ts(resolveAdminUserIds)复用到beforeDelete,目标集换成被删行;auth-manager.ts:1239-1300那段 HTTP 守卫是第二现场。两处都在 plugin-auth ⇒domain:identity,与 plugin-auth: break-glass 守卫 —— SCIM/ban 不得停用最后一个管理员(ADR-0024 D5.2,cloud#621 转入) #5892 同座位、同文件族。beforeDelete上拿不到足以判定「剩余管理员数」的行集(如级联删/批量删路径),那就变成契约问题 —— STOP 上报,⛔ 不要退回去改 fail-open 的那段。DELETE /Users/{id}删除 ⇒ 环境失去全部管理能力,与 cloud ADR-0024 D5.2 要防的锁死同类;feat(plugin-auth): break-glass 守卫 —— ban 不得停用最后一个管理员(ADR-0024 D5.2) #5939 的 ban 守卫不覆盖这条(beforeUpdatevsbeforeDelete)。last-admin/break-glass/resolveAdminUserIds命中 plugin-auth: break-glass 守卫 —— SCIM/ban 不得停用最后一个管理员(ADR-0024 D5.2,cloud#621 转入) #5892(ban 半边,已派发)与本单;两者是互补半边,不是重复 —— 正文与 plugin-auth: break-glass 守卫 —— SCIM/ban 不得停用最后一个管理员(ADR-0024 D5.2,cloud#621 转入) #5892 已互相点名,派发入口保持一个。git log --oneline origin/main -- packages/plugins/plugin-auth/src/auth-manager.ts无 feat(plugin-auth): break-glass 守卫 —— ban 不得停用最后一个管理员(ADR-0024 D5.2) #5939 之后的改动;PR feat(plugin-auth): break-glass 守卫 —— ban 不得停用最后一个管理员(ADR-0024 D5.2) #5939 装在sys_user的beforeUpdate上,delete 路径仍空。前提成立。本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
baozhoutao commentedon Aug 6, 2026
认领:PM 循环第 6 轮(
domain:identity车道)会话:
session_01JwwiU9bjhwy2SWj13ho8uv分支:
claude/issue-5941-last-admin-delete-guardWorktree:
objectstack-issue-5941域:
domain:identity文件面:
packages/plugins/plugin-auth/**(last-admin-ban-guard.ts扩展/复用 + 注册 + 测试)+.changeset/*;auth-manager.ts原则上只读(fail-open 段不动,分诊明令);⛔ 触@better-auth/scim模型面或packages/objectql即停PR #5939 已 MERGED(
61dc08ead),本单从合并后 main 切分支复用其resolveAdminUserIds。方向按分诊:引擎钩子 fail-closed(方向一);beforeDelete拿不到判定行集(级联/批量)即 STOP 上报。#5942(等级尺两种拼写)同包待下轮串行,本单不顺手修它。Generated by Claude Code
baozhoutao commentedon Aug 6, 2026
复核 ACCEPT(PM,
session_01JwwiU9bjhwy2SWj13ho8uv,第 6 轮):交付 PR #5993。ctx.previous(delete() 的按对象前置行门在任何 kernel 托管引擎上恒真 —— ObjectQLPlugin 自带的 sys_fetch_previous_delete 以 object: '*' 注册在 beforeDelete #5929 的预取只绑 by-id),by-id 走input.id、谓词走input.options.where,中间件只收窄不放宽 ⇒ 上界近似恰是 fail-closed 方向 —— 这段测量正是分诊要的「拿不到就 STOP」的正面回答。last-admin-ban-guard→last-admin-guard在同一未发布周期、零外部消费者,changeset 写明 FROM→TO —— 名字跟上不变量的实际覆盖面。isOrgOrPlatformAdmin对role='Owner'答否,等级尺答是 #5942 不受影响 —— 两把等级尺的文件(invitation-role-cap.ts/auth-manager.ts)本 PR 均未触碰,「这个 membership 是不是管理员」有两种拼写,大小写敏感性不同:isOrgOrPlatformAdmin对role='Owner'答否,等级尺答是 #5942 按原价进入下轮。before*的input.options记成 DriverOptions —— 实测那里仍是调用方的 engine options(含 where),两个 break-glass 守卫正读它 #5997(HookContext 契约表把input.options记错,两个守卫正读它 —— finding)。守卫模块头已点名 break-glass 不变量的第三条路径无守卫:撤掉最后一个管理员的「身份」(sys_member 降级 / 删 admin_full_access 授权)同样锁死环境 #5978 并有现状用例钉住,防下一个读者误以为已覆盖。CI 24 检查全绿,转 ready 入合并队列;落地后开第 7 轮(#5942,同包串行解除)。
Generated by Claude Code
ci(dx): 按裁决(option A)在合并树上全量重校准,三个热包记档 +10 余量
test(plugin-auth): the #5941 guard case deletes a REAL row, not a gho…
fix(objectql): gate by-id update/delete on record existence — 404 REC…