Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-auth): /delete-user resolves its target from the actor, never the body - #11475
Conversation
…eak-glass-probe-self-service-target
…ver the body The break-glass last-local-credential guard's target resolution on /delete-user still preferred a body-supplied `userId` whenever one was present. /delete-user is the vendor's self-service delete — its contract names no target, the subject IS the authenticated caller — so that preference let an authenticated caller steer the guard's own refusal at a user other than themselves. The guard's target on /delete-user is now the resolved actor unconditionally; body.userId is never consulted for that route. /admin/remove-user and /admin/ban-user are unaffected. Updates the #5892 fixture that pinned the old cross-target behavior on /delete-user to the new invariant: the refusal there is only reachable by authenticating AS the credential holder, never by naming one in the body. Part of #11074
📓 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 09e0990140b355a1be5106a314481bedc44152cc && git checkout 09e0990140b355a1be5106a314481bedc44152cc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 905019b1bf2f291e6a1a4fb87bf7f667f2af9088 33b16b5f611c7dfaa25255bccdc3694c6808312b && git checkout -B drift-repro 905019b1bf2f291e6a1a4fb87bf7f667f2af9088 && git merge --no-ff 33b16b5f611c7dfaa25255bccdc3694c6808312b
node scripts/docs-audit/affected-docs.mjs --json 905019b1bf2f291e6a1a4fb87bf7f667f2af9088 |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11074
What was wrong
The break-glass last-local-credential guard's target resolution on
/delete-userstill preferred a body-supplieduserIdwhenever one waspresent:
/delete-useris the vendor's self-service delete: its own contract names notarget, the subject IS the authenticated caller. Because a body value still
won whenever one was supplied, the guard's own refusal became a per-record
answer about a user OTHER than the caller, reachable by any authenticated
caller — no admin role required, since
/delete-usersits outside the/admin/lane. This is the authenticated residual of #10776's fix (#10776closed the anonymous half; the pre-auth code had the same body-first
preference, just with no authentication at all in front of it).
Mechanism and fix only, per the card's disclosure discipline. No request
shapes, seeding steps or worked oracle example appear in this PR, in its
commits, or in the card.
What changed
packages/plugins/plugin-auth/src/auth-manager.ts, the samehooks.beforeblock #10776 touched: on
/delete-userthe target is now the resolved actorunconditionally —
ctx?.body?.userId/ctx?.body?.user_idare neverconsulted for that path, not even as a fallback.
/admin/remove-userand/admin/ban-userare unaffected — target-naming istheir own contract (an admin acting on someone else), per the vendor and the
ADR-0068 admin gate — and neither branch nor test touches them.
For every caller acting on themselves, nothing changes in outcome: the same
lookup runs against the same id and the same
LAST_LOCAL_CREDENTIALconflict (or the same admission) is returned.
Tests
New file
break-glass-guard-self-service-target.test.ts, driven through thereal better-auth pipeline (
AuthManager.handleRequest), same precedent asbreak-glass-guard-authentication-order.test.ts:authenticated non-holder caller; two
/delete-usercalls that differ onlyin what
body.userIdclaims (the genuine holder vs. an id nobody holds, andseparately, the holder vs. an omitted
userId). Both legs of each pair areasserted to land on the same status, the same absence of the guard's code,
AND on each other —
expect(a.status).toBe(b.status)/expect(a.text).toBe(b.text). Pinning only "naming the holder no longerrefuses" would prove nothing about distinguishability by itself; the
equality assertion is what the finding is actually about.
themselves via
/delete-useris still refused with 409LAST_LOCAL_CREDENTIAL— the fix must not disarm the guard. And a holdercannot evade the guard by naming a decoy target in
body.userIdeither:the target is the actor unconditionally, in both directions.
break-glass-local-credential.test.ts(#5892) pinned the pre-#11074 shape of/delete-userdirectly against the synthetic before-hook: itsBAN_PATHSloop drove all three guarded paths with one caller identity and a
body-supplied target, which is exactly the shape this PR removes for
/delete-user. Updated so that leg authenticates AS the credential holderinstead of merely naming one in the body — the only way the guard's refusal
is reachable on that route now.
/admin/ban-userand/admin/remove-userkeep the original body-target shape, unchanged.
Ran together (
vitest runon all four files): 4 test files, 52 tests, allpassing, on commit
33b16b5f61./admin/*same-pass measurement (reported, not acted on)Traced, not fixed here, per the card: does the guard's refusal fire before
the admin-authorization refusal for an authenticated non-admin on an
/admin/route?/admin/remove-useris served directly by better-auth's own router. Theguard is a global
hooks.before, which better-auth runs ahead of everyendpoint's own
use: [...]middleware — including that route'sadminMiddleware(session-only; the real role check runs even later,inside the endpoint handler itself). So yes: for an authenticated
non-admin, the guard's own lookup and possible refusal happen before either
of those checks ever run. This is a second, narrower face of the same class
of issue as this card, out of scope here.
/admin/ban-useris different:#9652shadows it with a raw mount(
auth-plugin.ts) that authorizes the caller (gateAdmin(c)) BEFOREre-running the guard, so no such ordering issue exists on that path.
Not extended into this PR — reported to the maintainer for a separate card,
per the dispatch brief.
Gates
Union derived via
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackoncommit
33b16b5f61(clean tree). Every exit code captured before any pipe;every verdict below is the gate's own printed line.
Path-derived (12):
check:changeset-gate-self-tests,check:objectui-changeset,check:published-files,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/edited test files (6):
check:query-options-erasure,check:engine-double-contract,check:cross-package-test-inputs,check:where-matcher,check:type-check-coverage,check:type-check-debt— all green. The ratchetran on a built workspace closure and reached a real verdict:
check-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured in 355.2s, 1897 raw tsc error(s) total, none above its recorded number.(
@objectstack/plugin-auth's own TEST_DEBT entry moved from 109 to 97 rawerrors — an improvement, left as reported rather than lowered, per the
gate's own guidance that an improvement need not pay a bookkeeping toll to
land.)
Also
check:nul-bytes:OK (scanned 6432 text file(s) ... no raw ASCII control bytes).Package suite and typecheck: the four directly-affected test files above are
green (52/52). The package's full 71-file suite was run once, pre-fix, and
correctly caught the one fixture this PR's second commit updates
(
break-glass-local-credential.test.ts's/delete-userleg, which pinnedthe exact cross-target shape this PR removes); it was not re-run in full
post-fix given shared verify-lock contention (#11363) — narrowing declared,
not silent: the four directly-affected files were re-run and are green, and
CI runs the full farm regardless.
pnpm --filter @objectstack/plugin-auth typecheck(bothtscinvocations) is green on the built package.Posture
Clause-② is yes — an authenticated caller's accept/reject
distinguishability changes. This PR stays draft,
needs:contract-reviewstays on the card, and this seat does not clear it,mark it ready, arm auto-merge or merge.
Scope
No
packages/specedit; nocontent/docs/releases/**touched. Changesetincluded (
patch,@objectstack/plugin-auth)./admin/remove-userand/admin/ban-usertarget resolution is untouched.Generated by Claude Code