Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-auth): run the break-glass last-local-credential guard after authentication - #11038
Conversation
…t the real seam Lands the hermetic cases before the fix, per the triage execution order on #10776: both anonymous arms, the still-refused leg for an authenticated admin, the admission direction that keeps it non-vacuous, and the self-service path whose timing moves with the guard. Measured on this tree (pre-fix), through AuthManager.handleRequest on the installed better-auth 1.7.1: - the anonymous arm naming the break-glass holder answers 409 LAST_LOCAL_CREDENTIAL, and the same request naming an ordinary user answers 401 UNAUTHENTICATED -- the card's second arm was a reading, and the reading holds - an authenticated admin still gets 409 for the genuine last credential and 200 for an ordinary user - the self-service path shows the same split Part of #10776 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
… before it The last-local-credential guard is a better-auth `hooks.before`, which runs ahead of the endpoint's own `use: [adminMiddleware]` -- the only layer that establishes identity on that lane. It therefore read the request body, asked the database a question about the named user, and answered it, for a caller nobody had authenticated. Its refusal is distinctive, and every sibling route on the same lane answers 401, so the refusal itself was a per-record answer. Resolve the acting user first and run the guard only for a caller who has an identity; an unauthenticated caller falls through to the vendor's own session middleware and gets the ordinary refusal, the same shape as every neighbour. Same pattern the /oauth2/authorize gate above already uses. This changes WHEN the guard decides, not WHAT it decides: an authenticated caller reaches the same lookup and the same CONFLICT. The still-refused leg and the admission direction are both pinned so that closing the disclosure by deleting the guard cannot pass. Maintainer ruling 2026-08-22, decision-inbox digest, accepted verbatim 「接受所有」: option A, authentication before the guard. Part of #10776 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
Part of #10776 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅ What this run could not see
Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0f6b3e26ed1066df23c12e42d1ca1b6f1a6ed49a && git checkout 0f6b3e26ed1066df23c12e42d1ca1b6f1a6ed49a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2866d5f97e9877a438c77ab9b2435a5043ebc0d2 f3a8f002290d0f00d10b80bfdbc4af671c59cb0a && git checkout -B drift-repro 2866d5f97e9877a438c77ab9b2435a5043ebc0d2 && git merge --no-ff f3a8f002290d0f00d10b80bfdbc4af671c59cb0a
node scripts/docs-audit/affected-docs.mjs --json 2866d5f97e9877a438c77ab9b2435a5043ebc0d2 |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32581943370 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10776
What was wrong
The break-glass last-local-credential guard was registered as a better-auth
hooks.before. Abeforehook runs ahead of the endpoint's ownuse: [adminMiddleware], and that middleware is the only layer establishingidentity on the vendor
/admin/lane. The guard therefore evaluated — andanswered — a per-record question for a caller whose identity had never been
established, while every neighbouring route on the same lane answers with the
ordinary authentication refusal. The guard's refusal is distinctive, so the
refusal itself was the answer.
Mechanism only, per the card. No request shapes, seeding steps or values appear
in this PR, in its commits, or in the card.
What changed
packages/plugins/plugin-auth/src/auth-manager.tsresolves the acting userbefore the guard and runs the guard only for a caller who has an identity. An
unauthenticated caller falls through to the vendor's own session middleware and
receives the ordinary refusal — the same shape as every neighbour. This is the
pattern the
/oauth2/authorizegate a few lines above already uses.This moves when the guard decides, never what it decides. An
authenticated caller reaches the same lookup and the same
CONFLICT.Maintainer ruling, 2026-08-22 decision-inbox digest, accepted verbatim
「接受所有」: option A — authentication before the guard. Option B (keep the
guard early and disguise the pre-auth answer) was the fallback and is not taken;
nothing in the measurement establishes that the guard must run first. Option C
is not taken.
The guard's other production call site is the raw
/admin/ban-usermount inauth-plugin.ts, which already runsgateAdmin(c)before invokingrunAdminBanUser. It was already authentication-first and is unchanged.Execution order
Triage made the order part of the ruling, so the hermetic cases landed first,
in their own commit (
08ac2e7), and were measured on the pre-fix tree beforeanything was changed. The fix is the second commit (
07f1a57).Measured verdicts —
codeANDstatus(ADR-0112)All through
AuthManager.handleRequeston the installed better-auth 1.7.1./admin/remove-userLAST_LOCAL_CREDENTIALUNAUTHENTICATED/admin/remove-userUNAUTHENTICATEDUNAUTHENTICATEDUNAUTHENTICATEDUNAUTHENTICATEDLAST_LOCAL_CREDENTIALLAST_LOCAL_CREDENTIAL/delete-user, break-glass holderLAST_LOCAL_CREDENTIALUNAUTHORIZED/delete-user, ordinary userUNAUTHORIZEDUNAUTHORIZED/delete-user(self-service)LAST_LOCAL_CREDENTIALLAST_LOCAL_CREDENTIALThe card's second arm was an unmeasured reading and is now measured: it
already landed on 401. The disclosure is therefore exactly as wide as the card
states, not wider — the severity does not move.
The self-service path's outcome is unchanged; only its timing moved. For an
authenticated holder it is still the same conflict. For an anonymous caller it
is now the vendor's own flat 401, which is correct:
/delete-useris not an/admin/path, so #10349's envelope normalizer deliberately leaves it alone.Two legs are load-bearing and exist so the disclosure fix cannot be satisfied
vacuously: an implementation that simply deleted the guard would score green on
every disclosure assertion, and fails the still-refused leg; one that refused
every caller would pass the still-refused leg, and fails the admission leg.
Proof
Ablation, signature predicted in writing before mutating. Mutation: restore
the pre-fix ordering in
auth-manager.ts, both test files untouched.indistinguishability pin, and the two anonymous
/delete-userpins, eachexpected 409 to be 401; plus the unit-level "does not run at all for anunauthenticated caller" case rejecting instead of resolving. The legs that
were green pre-fix predicted to stay green.
Tests 5 failed | 22 passed (27), inbreak-glass-guard-authentication-order.test.ts(4) andbreak-glass-local-credential.test.ts(1), with the predicted messages.git hash-objectbefore2e63bcada18a628de4e120572a65364122df7b61,after
2e63bcada18a628de4e120572a65364122df7b61— byte-identical. The restoreleg was re-run to a real verdict:
Tests 27 passed (27).srcvsdistresolution, argued from the files (re-measured on this treerather than inherited):
packages/plugins/plugin-authhad nodist/at allat ablation time, even after a full dependency-closure build;
vitest.config.tsdeclares no
resolve/aliaskey and there is no root vitest workspace file;and both test files sit inside
src/and import the subject by relativespecifier, which bypasses
package.json#exportsentirely. So the subjectunder test is
src/auth-manager.tsand the mutation took effect without arebuild. The
KNOWN_UNALIASED_TEST_IMPORTSentry for this package concerns itsdependencies, which do resolve through their own
dist/— that is why theclosure build was required, and it does not apply to the subject.
Zero-hit counter-check, positive control run first. The control: searching
isLastLocalCredentialHolder(andLAST_LOCAL_CREDENTIALoverpackages apps examplesnames the expected files, so the instrument speaks onthis corpus. The silence: the same instrument, excluding the defining module,
the two known call sites and test files, returns nothing — the guard has exactly
the two production call sites already known, and the other one is
authentication-first already.
Gates
Union derived on the final commit
f3a8f0022with a clean tree, vianode scripts/pm/dispatch-gates.mjswith no path arguments (it took thechange set from the merge base itself). Every exit code captured before any
pipe; every verdict below is the gate's own printed line, not a bare
$?.Derived (11):
check:changeset-gate-self-tests,check:objectui-changeset,check:slot-lookup,check:test-source-alias,check:type-source-resolution,check-adr-0087-registration,check-changeset-no-major,check-ci-filter-parity,check-empty-changeset,check-plugin-teardown-shape,check-affected-docs— all green.Convention-triggered by the new test file (5):
check:query-options-erasure,check:type-check-coverage,check:type-check-debt,check:engine-double-contract,check:where-matcher— all green. The ratchetran on a built workspace closure and reached a real verdict, not 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.Class #10309, run explicitly: the derivation named none of
check:route-envelope,check:dispatcher-error-vocabularyorcheck:error-code-casingthis time either — the same silence reported on thelast three plugin-auth PRs. Run 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.Package suite and typecheck on
f3a8f0022:Test Files 69 passed (69),Tests 1440 passed (1440);pnpm typecheckexit 0for both of its
tscinvocations.Posture
Clause-② is yes — anonymous-caller accept/reject behaviour changes. This PR
stays draft,
needs:contract-reviewstays on the card, and this seat doesnot clear it, mark it ready, arm auto-merge or merge.
Scope
No
packages/specedit was needed; nocontent/docs/releases/**touched.Changeset included. No files belonging to the two open sibling PRs in this
package were touched.
Generated by Claude Code