fix(plugin-auth): serve /admin/ban-user and /admin/unban-user with the ADR-0068 platform-admin gate - #9970
Conversation
…e ADR-0068 platform-admin gate better-auth's admin plugin authorizes on the legacy `user.role === 'admin'` scalar that ADR-0068 D2 stopped synthesizing, and its option surface at the installed 1.7.1 cannot be pointed at ObjectStack's predicate. Mount both routes as ObjectStack raw routes ahead of the catch-all, carrying the platform-admin gate — the create-user / set-user-password pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
… routes Moves /admin/ban-user and /admin/unban-user from the better-auth-gate bucket (refusal side only) into objectstack-gate, which asserts the full contrast: anon 401 UNAUTHENTICATED, member 403 PERMISSION_DENIED, platform admin NOT refused. Adds the changeset. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
📓 Docs Drift CheckThis PR changes 1 package(s): 33 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. 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 f4ef3fd964be59afa01fbb263170f5d31625ebae && git checkout f4ef3fd964be59afa01fbb263170f5d31625ebae
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6aceca97143088c9c33e568c5dd49e5b7cfdf347 d9a099951478b643d754a998856924dd6642105c && git checkout -B drift-repro 6aceca97143088c9c33e568c5dd49e5b7cfdf347 && git merge --no-ff d9a099951478b643d754a998856924dd6642105c
node scripts/docs-audit/affected-docs.mjs --json 6aceca97143088c9c33e568c5dd49e5b7cfdf347
|
Part of #9652
The first deliverable: the measured branch answer is Option 2
The 2026-08-18 ruling asked for a measurement before code: can better-auth's
adminplugin be pointed at ObjectStack's predicate (Option 1), or must theroutes be re-implemented as ObjectStack raw mounts (Option 2)?
Measured against the installed better-auth
1.7.1(the family moved ontostable in #9869 /
03520ebef, so the card's rc-era text was not used).The vendor cannot express the predicate. Evidence, in the order it decides
the question:
The whole option surface.
AdminOptionsdeclares exactlydefaultRole,adminRoles,defaultBanReason,defaultBanExpiresIn,impersonationSessionDuration,schema,ac,roles,adminUserIds,bannedUserMessage,allowImpersonatingAdmins. There is no predicatehook, no roles-source callback, and no async resolver — the shapes the
ruling asked me to look for do not exist.
Authorization has exactly two inputs. Every gated route calls
hasPermission({ userId, role: session.user.role, options: opts, permissions }),whose body short-circuits on
options.adminUserIds?.includes(userId)andotherwise splits
session.user.roleon commas againstoptions.roles.adminUserIdscannot carry a dynamic predicate.optsis built once atplugin construction (
const opts = { ...options, ... }), so the array isfrozen there, while ObjectStack's platform-admin set is a per-request read of
sys_user_permission_setfor a row pointing atadmin_full_accesswithorganization_id = null. Every way to make that array dynamic is worse thanthe bug: mutating a shared array leaves a demoted admin a stale pass
(fail-open — turning a broken-capability defect into a security one), and
overriding
includesneeds a synchronous answer to an asynchronous question.The session-scoped middle path is mechanically shut — this is the one the
dispatch explicitly asked me to watch for, and it is the reason the answer is
not "configure it". Every
/admin/*route mountsadminMiddleware, whichcalls
getAuthoritativeSessionFromCtx. On any deployment carrying adatabase— i.e. every ObjectStack deployment, sincehasServerSessionStore(options)is!!options.database || !!options.secondaryStorage— that helper sets
ctx.context.session = nulland re-reads the session fromthe DB with
disableCookieCache: true. Anything ObjectStack writes ontothe in-memory session user is discarded before
hasPermissionsees it.customSessionis not a second door either: it overrides the/get-sessionendpoint, not the session the admin routes resolve internally.
So the only input the vendor will accept is the persisted
user.rolescalar,and producing that is Option 3 — permanently vetoed. Option 2 it is.
What this PR ships
/admin/ban-userand/admin/unban-userbecome ObjectStack raw mounts carryingthe ADR-0068 gate, joining
create-user/set-user-password/unlock-user/import-users/oauth2/toggle-disabled. These are the two routes that (a) asys_useraction actually calls and (b) re-implement faithfully — the vendorhandlers are
internalAdapter.updateUserplusdeleteUserSessions, mirroredfield for field (
banned/banReason/banExpires/updatedAt, defaultreason
'No reason'). A banned user is still refused at sign-in by the vendor'sown untouched session hook (
BANNED_USER).Two supporting extractions, both of which close a class rather than an instance:
platform-admin-gate.ts— the ADR-0068 gate existed as four near-identicalinline copies. One exported judge now serves every ObjectStack
/admin/*mount.
role === 'admin'survives only as the back-compat fallback it alreadywas; nothing synthesizes it.
last-local-credential.ts—silently detaches every better-auth hook keyed on that path.
/admin/ban-usercarried one: the break-glass "never remove the last localpassword login" guard in
auth-manager.ts. It is now one module with two callsites rather than a guard a future raw mount can forget. This trap is written
into the module header because it is the main hazard in finishing
impersonate_userandset_user_rolestill 403 every platform admin — neither route is safely raw-mountable, and each blocks for a different reason #9968/finding: seven better-auth/admin/routes refuse platform admins and have no ObjectStack consumer — decide whether they are capability or just vendor surface #9969.A
501 NOT_IMPLEMENTEDguard mirrors create-user's: without it the mounts wouldanswer
200on a deployment with no admin plugin, writingbanned: truewhilethe vendor hook that enforces a ban is not loaded — a ban the console reports
as succeeding and the banned user signs straight through.
What this PR deliberately does not ship
impersonate_userandset_user_rolestill 403 every platform admin — neither route is safely raw-mountable, and each blocks for a different reason #9968 —impersonate-user(the user-visible residue) andset-role. Theconsole's "Impersonate User" button still 403s for platform admins after this
PR.
impersonate-useris not safely raw-mountable: the vendor handler mintsa session and signs cookies with helpers that exist only inside a better-auth
endpoint context, its
admin_sessioncookie payload is a parsing contract with/admin/stop-impersonating, and shadowing it would detachrotateCallerBearerOnImpersonation— reintroducing the better-auth bearer plugin lets a bearer session silently shadow an impersonation the server just created — /admin/impersonate-user returns 200 and is a no-op for any bearer client #8243 defect withnothing to notice.
set-roleis one line to re-implement, and that line writesthe exact legacy scalar Option 3 forbids (it still feeds
positions[]viastoredRole.split(',')), so shipping it would hand an admin a supported UIpath to resurrect the dual identity representation one user at a time.
/admin/routes refuse platform admins and have no ObjectStack consumer — decide whether they are capability or just vendor surface #9969 — the seven routes with no ObjectStack consumer at all(
remove-user,revoke-user-session,revoke-user-sessions,list-user-sessions,update-user,list-users,get-user). Namedexplicitly there so the record needs no re-derivation.
Verification
Ablation, per the standing lane clause — the predicate wiring removed,
plugin-authrebuilt, and the mutation proven to have reacheddist(
ablation-dist-preflight ... --absentgives marker absent from all 12 builtfiles) before any colour was read:
The platform admin and the plain member receive byte-identical refusals —
the defect, reproduced by construction at this branch point, which also
re-confirms the card's repro set on 1.7.1. The pin goes red in that state
(1 failed / 6 passed) and the control holds: the universal-invariant test — no
anonymous caller and no plain member gets a 2xx from any of the 31 derived
/admin/routes — stayed green through the ablated leg.The restore leg was rebuilt too, and the marker proven present in
dist(2 built files) before re-reading:
7 passed (7). The source file was restoredbyte-identically, verified by
git hash-object(
9a502aae96527ac9d87cf2a88efe2214287905e7before and after), not by a matchingdiffstat.
Gates and suites, all at final head
d9a09995:pnpm --filter @objectstack/plugin-auth typechecktsc --noEmit, no output)pnpm --filter @objectstack/plugin-auth testadmin-route-nonadmin-refusalcheck:slot-lookup·check:test-source-alias·check:type-source-resolution·check-affected-docscheck:route-envelope·check:changeset-gate-self-tests·check:objectui-changeset·check:engine-double-contract·check:where-matcher·check:query-options-erasure·check:nul-bytescheck:liveness·check:empty-state·check:strictness-ledger·check:variant-docscheck-adr-0087-registration·check-changeset-no-major·check-empty-changesetThe set beyond the four named at dispatch came from re-deriving against the
actual diff with
node scripts/pm/dispatch-gates.mjs(no hand-built path list).check:route-envelopewas the notable addition — it is triggered byauth-plugin.tsspecifically and the dispatch list did not name it.One declared narrowing:
check:type-check-debt --re-measurewas not runlocally — it requires the whole workspace closure built and CI runs it on every
PR regardless.
The refactor's oracle is the pre-existing pins on the five already-gated routes:
the
objectstack-gatebucket asserts anonymous401 UNAUTHENTICATED, member403 PERMISSION_DENIEDand admin-not-refused on each of its routes, and it isgreen across all 7 (5 pre-existing + 2 new) after the gate was collapsed into
one judge — zero behaviour change on the five.
Rejection cases assert
codeandstatusper ADR-0112, and the twoenvelopes are kept apart on purpose: ObjectStack's
{success,error:{code}}wherethese mounts answer, better-auth's flat
{message,code}where the vendor stilldoes.
Notes
auth-plugin.ts(two new mounts plus the gate collapse) and does not touchregister-sso-provider.ts.role = 'admin'(e.g.remove-user-atomicity.test.ts)are left alone — they cover
/admin/remove-user, which this PR does notre-mount, so the workaround is still load-bearing there.
Generated by Claude Code