Repository navigation
finding: the break-glass last-local-credential guard runs BEFORE authentication, so an anonymous caller can learn which user is the environment's break-glass account #10776
Description
Activity
Triage: routed to the decision inbox (needs-user-decision, domain:services, type Bug); security/auth-flow ordering fixes are on the manual-adjudication floor, and the two repair directions genuinely fork. Rationale: the guard's answer leaks per-record state to anonymous callers; both fixes change observable behavior on auth-adjacent paths. Chinese four-facet analysis below per decision-inbox convention.
os-decision-facets(四棱卡面块;标记按字面文本写入 —— sanitizer 会剥 HTML 注释形态)
一句话问题:未登录的陌生人向平台问「删掉这个用户」,平台不该理他,却回答了「不行,这个账号是本环境最后一个能本地登录的保命账号」—— 等于把全公司最值得攻击的那个账号指给了陌生人。
选项与真实代价:
- A. 先验明身份,再回答(把保命账号守卫移到认证之后):业务上 = 陌生人只会听到「请先登录」,和其它管理接口一致。真实代价:自助注销路径(用户删自己账号)也在同一守卫下,行为时序随之变化,需要同 PR 实测覆盖;守卫晚一步执行,不改变最终拒绝结果。
- B. 守卫留在原地,但对未认证调用者装作普通的「未登录」拒绝:业务上 = 陌生人同样只听到「请先登录」,守卫的不变量继续最早执行。真实代价:守卫内部要区分「谁在问」,代码里多一条分支,后续维护者要理解「同一个守卫两种答案」。
- C. 不动(接受泄露):业务上 = 任何能猜到用户 id 的人都能免登录探出保命账号是谁;对强制 SSO 姿态的客户,这是把「IdP 被攻破也锁不死组织」的后门指给攻击者。
四棱:
- ① 项目长远合理性:A 收敛特例 —— 「认证先于一切业务判断」是可以长期讲清的分层原则;B 在守卫里养一个身份分支,是新的特例。
- ② 实际业务拉动:今天就有真实拉动 —— 实测里匿名请求已拿到 409 与账号语义;企业客户做安全评估时这是一条可直接写进渗透报告的发现。
- ③ 防 AI 犯错:A 优 —— 「hooks.before 里不得读未认证请求体做业务查询」是 AI 下次写守卫时可机械遵守的规则;B 要求 AI 记住「这个守卫特殊」,恰是会被忘掉的那种。
- ④ 创业阶段不扩散:两个修法都不加新能力面;A 删掉一个例外,B 增加一个分支,A 更瘦。
推荐:A(回退:B —— 若守卫必须最早执行的论证成立)。回一个字母即可;「A,但先补测另一臂」= 自动落到下面的执行段。
置信缺口(本分析看不见什么):卡内自述第二臂未实测 —— 匿名指名一个「不是最后本地凭证持有者」的 userId 是否真落到 401,只是读码结论;若那一臂也不是 401,泄露口径比本卡写的更宽,选项不变但严重度上调。裁后执行:两臂各一条 hermetic 用例先落,再按裁定方向修;派发走条款②检查(改变对匿名调用者的接受/拒绝行为)。
Generated by Claude Code
huangyiirene commented
on Aug 22, 2026 CollaboratorMore actionsMaintainer ruling recorded (2026-08-22, decision-inbox digest with the skills seat,
session_01ApyDuQY2fkunMCqXiqvBhR)The full decision inbox (46 cards) was presented with per-card four-axis recommendations; the maintainer accepted the batch, verbatim: 「接受所有」.
Ruled: Option A — authentication before the guard. Move the break-glass last-local-credential guard to run after identity is established, so an anonymous caller hears only the ordinary "please log in" refusal (401), consistent with every other
/admin/route, and the 409LAST_LOCAL_CREDENTIALoracle identifying the break-glass account is closed. Option B (keep the guard early but disguise the pre-auth answer) is the fallback only if the guard-must-run-first argument is established; C (accept the leak) is not taken. Per triage's post-ruling execution note: first land hermetic test cases for both arms (including the unmeasured second arm — anonymous removal naming a non-last-holder userId), then fix in the ruled direction; the self-service/delete-userpath's timing change needs test coverage in the same PR, and dispatch goes through the clause-② check since anonymous-caller accept/reject behavior changes.State:
needs-user-decisionremoved →pm:queue(ready for dispatch).
Generated by Claude Code
Claim: dispatched.
- session: user-29 [974948]
- branch:
claude/issue-10776-break-glass-guard-after-auth - worktree:
../objectstack-10776 - Clause-②: YES — anonymous-caller accept/reject behaviour changes. Triage's post-ruling note requires the clause-② check by name. Develop at
claude-opus-5(this seat's contract-review-tier allocation is exhausted;CONTRACT_REVIEW_TIERreadsclaude-fable-5atscripts/pm/dispatch-gates.mjs:1932). PR stays draft;needs:contract-reviewhung as the compensating control. ⛔ This seat does not clear it. - serial constraint:
plugin-authadmin/user-removal surface.⚠️ PRs fix(plugin-auth): 2FA re-enrollment must not inheritverifiedfrom the previous enrollment #10994 (#10700, two-factor) and fix(plugin-auth): verify-domain answers DOMAIN_VERIFICATION_DISABLED for the disabled condition #11004 (#10859,register-sso-provider.ts) are open in the same package on different files, both armed for merge. Do not touch either.
The ruling is settled — implement it, do not re-open it
Maintainer, 2026-08-22, decision-inbox digest (46 cards, accepted verbatim 「接受所有」): Option A — authentication before the guard.
An anonymous caller hears only the ordinary "please log in" refusal (401), consistent with every other
/admin/route. The409 LAST_LOCAL_CREDENTIALoracle that identifies the break-glass account to a stranger is closed.⛔ Option B (keep the guard early, disguise the pre-auth answer) is the fallback only if the guard-must-run-first argument is established by measurement. If you find such an argument, that is a
needs_decisionreturn with the measurement — not a licence to switch. Option C (accept the leak) is not taken.Triage's execution order is part of the ruling
Land the hermetic test cases for BOTH arms first, then fix in the ruled direction. Not after, not "covered by the fix's own tests". The reason is in the card's own confidence gap:
卡内自述第二臂未实测 —— 匿名指名一个「不是最后本地凭证持有者」的 userId 是否真落到 401,只是读码结论
That second arm is a reading, not a measurement. Measure it before you change anything. If an anonymous request naming a non-last-holder userId also fails to reach 401, the leak is wider than this card states — the ruling does not change, but say so plainly, because the severity does.
What must be pinned
- arm 1: anonymous, naming the last-local-credential holder → 401, not 409. ADR-0112: assert
codeANDstatus. The pre-fix 409 +LAST_LOCAL_CREDENTIALmust be shown to be gone. - arm 2: anonymous, naming an ordinary userId → 401, measured on the pre-fix tree too so the before/after is real.
- still-refused: an authenticated caller who tries to remove the genuine last local credential still gets the 409
LAST_LOCAL_CREDENTIAL.⚠️ This is the load-bearing leg — the guard's invariant must survive the move. An implementation that simply deleted the guard would pass a leak-only suite and destroy the protection the guard exists for. - the self-service path:
/delete-user(a user deleting their own account) sits under the same guard and its timing changes. Triage requires coverage in the same PR. Measure what it does before and after; if the ordering change alters its outcome rather than just its timing, stop and report.
⛔ Disclosure discipline — binding
Auth/authz surface, and this card describes an information leak. No reproduction recipe in the PR body, the card, any commit message or comment — not the request shape, not the seeding, not the values. Report mechanism and verdict only. The card body already states the mechanism; do not extend it.
Standing proof standard
Ablation with the signature predicted first; restore proved byte-identical by
git hash-object. Provesrc/vsdist/resolution argued from the files —plugin-authwas measured (twice today, #10954 and #11004) as having nodist/at all and no vitest alias; re-verify rather than inheriting. Zero-hit counter-check with a positive control on the same corpus. Gate union derived on the final commit, clean tree,dispatch-gates.mjswith no path arguments, exit codes captured before any pipe. Class #10309 is live — the derivation named none ofcheck:route-envelope/check:dispatcher-error-vocabulary/check:error-code-casingon the last three plugin-auth PRs; run them explicitly and say whether it named them this time. Changeset required.⚠️ A gate's refusal (PREREQUISITE NOT MET) is NOT MEASURED, never a pass — build the closure and re-run, as #11004 and #11008 both did. Nevergit stash(shared stack). Never report a grep hit count as a fact count.⛔ Do not touch
content/docs/releases/**orpackages/spec(zero ownership in this lane — if the fix needs a spec edit, stop and report; it carves out as its own card, precedent #10999).Refs
Triage four-facet analysis and the maintainer ruling are both on this card · ADR-0112
Generated by Claude Code
os-dev-report
{ "issue": 10776, "status": "done", "branch": "claude/issue-10776-break-glass-guard-after-auth", "pr": "https://github.com/objectstack-ai/objectstack/pull/11038", "premise_still_valid": true, "summary": "Implemented the ruled option A: the break-glass last-local-credential guard in auth-manager.ts now resolves the acting user before it runs, so it decides only for a caller whose identity is established and an anonymous caller falls through to the ordinary authentication refusal. Per triage's execution order the hermetic cases for both arms landed FIRST in their own commit (08ac2e7) and were measured on the pre-fix tree; the fix is 07f1a57. The guard's other production call site (the raw /admin/ban-user mount) already ran gateAdmin before the guard and is unchanged; a zero-hit counter-check with a positive control confirms there is no third call site. PR stays draft, needs:contract-review untouched by this seat.", "tests": "All verdicts through AuthManager.handleRequest on the installed better-auth 1.7.1; gate union and final suite both on final commit f3a8f0022 (clean tree). PER-LEG code+status, before -> after: arm1 anonymous naming the break-glass holder on /admin/remove-user = 409 LAST_LOCAL_CREDENTIAL -> 401 UNAUTHENTICATED. arm2 anonymous naming an ordinary user = 401 UNAUTHENTICATED -> 401 UNAUTHENTICATED (the card's unmeasured arm: the reading HOLDS, the leak is not wider, severity unchanged). arm2b anonymous naming an id nobody holds = 401 UNAUTHENTICATED -> unchanged. STILL-REFUSED (load-bearing) authenticated admin removing the genuine last local credential = 409 LAST_LOCAL_CREDENTIAL -> 409 LAST_LOCAL_CREDENTIAL, invariant survives. ADMISSION authenticated admin removing an ordinary user = 200 -> 200, so the still-refused leg cannot pass by refusing everyone. SELF-SERVICE /delete-user: anonymous naming the holder 409 LAST_LOCAL_CREDENTIAL -> 401 UNAUTHORIZED (vendor's flat envelope, correct since /delete-user is not an /admin/ path); anonymous naming an ordinary user 401 -> 401; authenticated holder 409 LAST_LOCAL_CREDENTIAL -> 409 LAST_LOCAL_CREDENTIAL, so the ordering change moved its TIMING only, not its outcome. SUITE: pre-fix run 'Test Files 1 failed | 68 passed (69)', 'Tests 4 failed | 1435 passed (1439)'; final run on f3a8f0022 'Test Files 69 passed (69)', 'Tests 1440 passed (1440)'. TYPECHECK exit 0 for both tsc invocations after building plugin-auth (first attempt hit the known unbuilt-own-closure trap: examples/basic-usage.ts TS2307 on the package's own types; built rather than explained away). ABLATION, signature predicted in writing BEFORE mutating: predicted exactly 5 failures across exactly 2 files (arm1, the indistinguishability pin, and the two anonymous /delete-user pins each 'expected 409 to be 401', plus the unit-level anonymous-fall-through case rejecting instead of resolving), with the pre-fix-green legs staying green; observed exactly that, 'Tests 5 failed | 22 passed (27)'. Direction was RED as predicted, not fewer-diagnostics and not a reversal. RESTORE proved byte-identical: git hash-object before 2e63bcada18a628de4e120572a65364122df7b61, after 2e63bcada18a628de4e120572a65364122df7b61; the RESTORE LEG was re-run to a real verdict, 'Tests 27 passed (27)'. SRC-vs-DIST, re-measured on this tree rather than inherited and argued from the files: plugin-auth had NO dist/ at all at ablation time even after a full dependency-closure build, vitest.config.ts declares no resolve/alias key, there is no root vitest workspace file, and both test files sit in src/ importing the subject by RELATIVE specifier which bypasses package.json#exports -- so the subject is src/auth-manager.ts and the mutation took effect with no rebuild; the KNOWN_UNALIASED_TEST_IMPORTS entry for this package concerns its DEPENDENCIES, which is why the closure build was needed and does not apply to the subject. ZERO-HIT COUNTER-CHECK with the positive control run FIRST: the control (isLastLocalCredentialHolder( and LAST_LOCAL_CREDENTIAL over packages apps examples) named the expected files, proving the instrument speaks on this corpus; the silence (same instrument, excluding the defining module, the two known call sites and test files) returned nothing. GATES -- union derived by 'node scripts/pm/dispatch-gates.mjs' with NO path arguments on f3a8f0022, clean tree, every exit code captured before any pipe, every verdict quoted from the gate's own printed line: derived (11) check:changeset-gate-self-tests, check:objectui-changeset ('objectui-changeset-digest --self-test: all checks passed'), check:slot-lookup ('slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new'), check:test-source-alias ('check-test-source-alias OK -- 72 packages with tests scanned'), check:type-source-resolution ('check-type-source-resolution OK -- 77 packages with a tsconfig.json scanned'), check-adr-0087-registration ('this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)'), check-changeset-no-major ('This diff introduces no major bump'), check-ci-filter-parity ('OK: all 83 declared cross-package glob(s) ... are covered'), check-empty-changeset ('No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added)'), check-plugin-teardown-shape ('61 Plugin implementation(s) across 4444 source(s) ... baseline fully burned down'), check-affected-docs ('affected-docs self-test: 339 cases pass') -- all green. Convention-triggered by the new test file (5): check:query-options-erasure ('query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new'), check:type-check-coverage ('OK -- 65/78 workspace packages type-checked'), check:type-check-debt run on a BUILT workspace closure and reaching a real verdict rather than a refusal ('check-type-check-coverage --re-measure: OK -- 33 ledger entr(ies) re-measured in 252.4s, 1908 raw tsc error(s) total, none above its recorded number'), check:engine-double-contract ('OK self-test: ...'), check:where-matcher ('OK self-test: ...') -- all green. CLASS #10309: the derivation named NONE of check:route-envelope, check:dispatcher-error-vocabulary, check:error-code-casing this time either -- the same silence as the last three plugin-auth PRs; run explicitly by hand, all three green ('check-route-envelope self-test passed'; 'check-dispatcher-error-vocabulary: OK -- 21 unregistered code-stamping site(s), all classified'; 'no unlisted lowercase error codes in 4429 scanned file(s) (ADR-0112)'). Also check:nul-bytes ('OK (scanned 6391 text file(s) ... no raw ASCII control bytes)') plus a direct control-byte scan over the four changed files: none. No gate output matched PREREQUISITE NOT MET / Nothing was checked / cannot run / command not found. Changeset included. No reproduction recipe appears in the PR body, commits or this comment.", "open_questions": [], "out_of_scope_findings": [] }Notes not carried by the JSON, for PM triage:
- The unmeasured second arm is now measured and the card's reading holds. An anonymous request naming a userId that is not the last local-credential holder already landed on 401 before the fix. The disclosure was exactly as wide as the card described, so the severity does not move in either direction.
/delete-userchanged timing only, not outcome. The authenticated holder still receives the same conflict. Its anonymous answer is now the vendor's own flat 401 rather than the ADR-0112 envelope, which is correct rather than a drift:/delete-useris not an/admin/path, so the [finding] Anonymous callers to every better-auth-native /admin/ route get a bodyless 401 — no envelope, no machine-readable code #10349 normalizer is scoped away from it deliberately.- Nothing established that the guard must run first, so option B was not reached and no
needs_decisionreturn was warranted. - Posture: PR fix(plugin-auth): run the break-glass last-local-credential guard after authentication #11038 stays draft,
needs:contract-reviewremains on this card, and this seat did not clear it, mark ready, arm auto-merge or merge. CI convergence is the PM's to read. - One unrelated working-tree entry appeared during setup — a file-mode bit (
100644to100755) onpackages/create-objectstack/bin/create-objectstack.js, produced bypnpm install, not by any edit here. It was restored so the gate union was derived on a genuinely clean tree, and it is not part of this branch.
Generated by Claude Code
Generated by Claude Code
PM review of PR #11038. Not flipping ready — Clause-② means a non-dispatching seat PASSes first, and this seat does not clear
needs:contract-review.The card's own confidence gap is closed, and the answer is the boring one
Triage flagged that the second arm was a reading, not a measurement, and said the severity would rise if it turned out otherwise. It did not:
arm before after anonymous, naming the break-glass holder 409 LAST_LOCAL_CREDENTIAL401 UNAUTHENTICATEDanonymous, naming an ordinary user 401 UNAUTHENTICATEDunchanged anonymous, naming an id nobody holds 401 UNAUTHENTICATEDunchanged The reading holds. The leak is not wider than the card states, and the severity does not move. That is worth saying plainly, because the instruction was to report it either way and a "nothing new" result is the easiest one to quietly skip.
The execution order triage required was actually followed
Both arms landed first, in their own commit (
08ac2e7), measured on the pre-fix tree —Test Files 1 failed | 68 passed (69),Tests 4 failed | 1435 passed (1439). The fix is a separate commit (07f1a57). So the before/after is a measurement, not a narrative reconstructed after the fix was already in.The two legs that stop this passing vacuously
- still-refused: an authenticated admin removing the genuine last local credential still gets
409 LAST_LOCAL_CREDENTIAL. The guard's invariant survives the move — deleting the guard would have closed the leak too, and this is what makes that indistinguishable from the real fix impossible. - admission: an authenticated admin removing an ordinary user still gets
200. Without this, "still-refused" could be satisfied by an implementation that refuses everyone.
Both directions, on the same surface, in the same run. That is the shape this lane asks for and it is not always delivered.
/delete-user: timing moved, outcome did notThe self-service path sits under the same guard and triage required coverage in the same PR:
- anonymous naming the holder:
409 LAST_LOCAL_CREDENTIAL→401 UNAUTHORIZED - anonymous naming an ordinary user:
401→401 - authenticated holder:
409 LAST_LOCAL_CREDENTIAL→409 LAST_LOCAL_CREDENTIAL
⚠️ Note the envelope differs from the/admin/arms —UNAUTHORIZED, the vendor's flat shape, because/delete-useris not an/admin/path. The seat named that rather than smoothing it into a single code, which is correct: reporting one code for two different envelopes would have been a false uniformity.The ordering change moved this path's timing only, not its outcome — which is what the dispatch asked to be established rather than assumed.
Verified by content, not accepted from the report
The claim that the guard has no third call site, checked with the positive control run first as instructed:
- control — the instrument names
last-local-credential.ts(the defining module),auth-manager.ts,admin-ban-endpoints.ts, anderror-code-ledger.zod.ts(where the code is registered). It speaks on this corpus. - so the two real call sites are
auth-manager.ts(fixed here) andadmin-ban-endpoints.ts. No third.
And the claim that the other site was already safe: confirmed at
auth-plugin.ts:2005—const actor = await gateAdmin(c);runs beforerunAdminBanUseris entered at all.:1978records thatrunAdminBanUserthen re-runs the guard, so that path is defence-in-depth rather than a single check. Correctly left unchanged.One small imprecision in the report, noted because it is the kind that misleads a later reader: it describes the other call site as "the raw
/admin/ban-usermount inauth-plugin.ts". The mount is there; the guard call is one level down inadmin-ban-endpoints.ts. The substance —gateAdminfirst — is right.Instrument notes
Class #10309 confirmed a fourth consecutive time on this package. The derivation named none of
check:route-envelope,check:dispatcher-error-vocabulary,check:error-code-casing. All three run by hand, all three green. Four plugin-auth PRs in a row, same silence — that is no longer an anecdote.The unbuilt-own-closure trap hit again and was resolved by building rather than explaining away — and it is worth recording where:
examples/basic-usage.tsraisingTS2307on the package's own types. That is the file PR #11008 just brought into a tsc program hours ago. The new program is doing its job; it also means this package's typecheck now needs its own build first, which the next seat here should expect rather than rediscover.Ablation predicted in writing before mutating — 5 failures across exactly 2 files, direction RED — and observed exactly (
Tests 5 failed | 22 passed (27)). Restore byte-identical (2e63bcada…both sides) and the restore leg re-run to a real verdict (27 passed), rather than assumed from the hash.src-vs-distre-measured on this tree rather than inherited: nodist/even after a full closure build, noresolve.alias, no root vitest workspace file, relative import bypassingpackage.json#exports. The seat also separated theKNOWN_UNALIASED_TEST_IMPORTSentry (about this package's dependencies) from the subject — which is why the closure build was needed and why it does not weaken the src-resolution claim.Final:
Test Files 69 passed (69),Tests 1440 passed (1440). No gate printedPREREQUISITE NOT MET/Nothing was checked/cannot run/command not found. No declared narrowings. No reproduction recipe anywhere in the PR, commits or report.Ready-flipping waits on the contract-review PASS.
Generated by Claude Code
- still-refused: an authenticated admin removing the genuine last local credential still gets
Contract review — PASS (clause-② compensation review of PR #11038's actual diff, not its report).
Reviewer: skills seat, session
session_01MsbKEG4LtERSLaDrbehM3e. Tier fuse machine reading, not self-report:get_session→external_metadata.last_served_model = claude-fable-5=CONTRACT_REVIEW_TIER.- The accept/reject delta is exactly the 2026-08-22 Option-A ruling: an unauthenticated caller on the guarded paths now receives the lane's ordinary refusal (401), never the guard's per-record 409 — pinned end-to-end for both arms plus an indistinguishability assertion, so the oracle is closed as measured, not inferred.
- The authenticated contract is unchanged and load-bearing-pinned: still-refused (409
LAST_LOCAL_CREDENTIALfor the genuine last local credential) AND admission (200 for an ordinary removal), so the disclosure fix cannot be satisfied vacuously by deleting the guard. Ablation prediction/observation match and byte-identical restore are on the PR. - Nothing widens the public surface; no
packages/spectouch; changeset is patch-level and accurate.
Residual noted, not a blocker (pre-existing behaviour, merely no longer anonymous): the guard still prefers
body.userIdon/delete-user, so any authenticated caller can probe a named user's break-glass status there. Filed as #11074 for triage.needs:contract-reviewcleared from this card in the same stroke. PR #11038 proceeds by its lane's normal landing rules.
Generated by Claude Code
- added a commit that references this issue
on Aug 23, 2026
Found while implementing #10349 (the ADR-0112 envelope for the better-auth-native
/admin/refusals). Filed unassigned; not fixed in that PR — it is a different defect class (ordering / disclosure, not envelope shape) and #10349's change deliberately leaves this response untouched.Measured
Hermetic fixture:
AuthManager(plugins: { admin: true }) over the in-memory engine, better-auth 1.7.1, requests throughAuthManager.handleRequest. One signed-up user, no session, noAuthorizationheader.Every other vendor-lane
/admin/route in the same run answered the same anonymous caller401. So a 409LAST_LOCAL_CREDENTIALrather than a 401 is itself the answer to a question the caller was never authenticated to ask.Why it happens
packages/plugins/plugin-auth/src/auth-manager.tsregisters the break-glass guard as a better-authhooks.beforekeyed onctx.path(/delete-user,/admin/remove-user,/admin/ban-user). Abeforehook runs ahead of the endpoint's ownuse: [adminMiddleware], andadminMiddlewareis the only thing that establishes identity on that lane (getAuthoritativeSessionFromCtx(ctx)thenAPIError.fromStatus("UNAUTHORIZED")). The hook readsctx.body.userIdstraight from the unauthenticated request and callsisLastLocalCredentialHolder(ctx.context.adapter, targetId)(last-local-credential.ts), so the lookup — and theCONFLICTthrow that follows it — both happen before anyone has asked who the caller is.Why this is not #9654
#9654 (closed
not_planned) recorded the same ordering shape on ObjectStack's raw/admin/mounts and judged it low severity because what leaks is parameter shape — field names and which are required, close to free information for a documented API.What leaks here is not schema shape. It is a per-record state answer about a named user, and specifically the one record an attacker most wants: the account that must keep a local password so an IdP outage cannot lock the org out. An anonymous caller who can enumerate or guess user ids gets a yes/no oracle identifying the break-glass holder — the highest-value target in the deployment, and the one whose compromise defeats an enforced-SSO posture. That is a different severity argument from #9654's, which is why this is filed rather than folded into it.
Not yet measured
The second arm — anonymous
POST /admin/remove-usernaming a userId that is not the last local-credential holder, which the source says falls through to the401— was read offauth-manager.tsrather than fired, because it was outside the card being worked. Anyone picking this up should fire both arms in one run before acting on the oracle framing; if the 401 arm does not hold, the finding is weaker than written here.Shape of a fix, not a prescription
The obvious ordering repair (authenticate, then run the guard) is a behaviour change on the paths that hook covers and touches the
/delete-userself-service path too, so it wants its own decision rather than a rider. There may also be an argument that the guard must stay early precisely because it is an invariant — in which case the honest fix is to make the pre-auth answer indistinguishable from the ordinary refusal rather than to move the guard.