Measured 2026-08-18 while pinning identity-auth.admin-lifecycle-operations (#9482). Filed unassigned; not fixed there — the fix is a product decision, and #9482 is a tests-only card.
What happens
On a stock showcase boot with the better-auth admin plugin on (OS_SCIM_ENABLED=true, the documented ADR-0071 derivation), the seeded platform admin is refused by every better-auth-native /admin/ endpoint:
POST /api/v1/auth/admin/ban-user 403 {"code":"YOU_ARE_NOT_ALLOWED_TO_BAN_USERS"}
POST /api/v1/auth/admin/unban-user 403 {"code":"YOU_ARE_NOT_ALLOWED_TO_BAN_USERS"}
POST /api/v1/auth/admin/set-role 403 {"code":"YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE"}
POST /api/v1/auth/admin/impersonate-user 403 {"code":"YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS"}
POST /api/v1/auth/admin/revoke-user-sessions 403 {"code":"YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS"}
POST /api/v1/auth/admin/list-user-sessions 403 {"code":"YOU_ARE_NOT_ALLOWED_TO_LIST_USERS_SESSIONS"}
GET /api/v1/auth/admin/list-users 403 {"code":"YOU_ARE_NOT_ALLOWED_TO_LIST_USERS"}
GET /api/v1/auth/admin/get-user 403 {"code":"YOU_ARE_NOT_ALLOWED_TO_GET_USER"}
The admin identity is correct in every ObjectStack sense — get-session returns positions: ["user","platform_admin"], isPlatformAdmin: true — and sys_user.role is 'user'.
Why
Two decisions that are each individually reasonable meet head-on:
auth-manager.ts constructs the vendor plugin as admin({ schema: buildAdminPluginSchema() }) — no adminRoles, no adminUserIds — so better-auth's default adminRoles: ['admin'] applies and its adminMiddleware authorizes on the user.role scalar.- ADR-0068 D2 deliberately stopped producing that scalar.
auth-manager.ts says so in as many words: "rather than synthesizing user.role = 'admin', both paths now contribute CANONICAL names to user.positions", and "consumers must gate on positions[] / isPlatformAdmin … never on user.role === 'admin'".
Confirmed by construction, same boot: writing role = 'admin' onto that same admin row flips ban-user, unban-user, list-users and impersonate-user to 200 immediately (ban then persists banned: true / ban_reason, and the banned user's sign-in is refused 403 BANNED_USER). So the endpoints work; only the identity predicate does not match.
Corroboration this is not a harness artifact
admin-user-endpoints.ts documents the same mechanism as the reason it exists: /admin/create-user and /admin/set-user-password are re-implemented as ObjectStack raw mounts because the stock endpoint's adminMiddleware "would 403 a platform admin whose legacy role scalar was never synthesized". Those two, plus unlock-user, import-users and oauth2/toggle-disabled, carry the ADR-0068 gate and work correctly (member 403 PERMISSION_DENIED, admin 2xx). The rest were never re-implemented.remove-user-atomicity.test.ts works around it in-process: userRow(engine, admin.userId).role = 'admin' with the comment "better-auth's admin plugin authorizes by user.role (default adminRoles: ['admin'])". That is why no existing test observes this — the unit tests synthesize the scalar a real deployment never gets.
User-visible impact
sys_user's ban_user, unban_user and impersonate_user actions are type: 'api' proxies onto exactly these routes, gated requiresFeature: 'admin'. On any deployment where the admin plugin is on (SCIM forces it, ADR-0071), the console renders Ban / Unban / Impersonate buttons that 403 for a platform admin.
This fails closed — no unauthorized access, and QA run #9401's "no access-control hole" verdict is unaffected. It is a broken admin capability, not an escalation. Same defect class as #4839 (an admin exemption reading a field nothing populates).
Options (a decision, not an obvious fix)
- Configure the vendor to ObjectStack's predicate —
admin({ adminRoles: [...] }) or adminUserIds, if better-auth can be pointed at positions[]. Smallest change; depends on what the vendor's option surface allows. - Re-implement the remaining routes as ObjectStack raw mounts with the ADR-0068 gate, as
create-user / set-user-password already are. Consistent with the established pattern; the most code. - Synthesize
role = 'admin' for platform admins again. Directly contradicts ADR-0068 D2 and would need that ADR revisited, not just an edit.
Option 1 or 2 keeps ADR-0068 intact; 3 reopens it. Recommending 1 if the vendor supports a custom predicate, otherwise 2.
Repro
packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts (landing on #9482) derives the whole /admin/ surface and records this bucket; it deliberately asserts neither side of the admin's answer, so it stays green before and after a fix.
Measured 2026-08-18 while pinning
identity-auth.admin-lifecycle-operations(#9482). Filed unassigned; not fixed there — the fix is a product decision, and #9482 is a tests-only card.What happens
On a stock showcase boot with the better-auth
adminplugin on (OS_SCIM_ENABLED=true, the documented ADR-0071 derivation), the seeded platform admin is refused by every better-auth-native/admin/endpoint:The admin identity is correct in every ObjectStack sense —
get-sessionreturnspositions: ["user","platform_admin"],isPlatformAdmin: true— andsys_user.roleis'user'.Why
Two decisions that are each individually reasonable meet head-on:
auth-manager.tsconstructs the vendor plugin asadmin({ schema: buildAdminPluginSchema() })— noadminRoles, noadminUserIds— so better-auth's defaultadminRoles: ['admin']applies and itsadminMiddlewareauthorizes on theuser.rolescalar.auth-manager.tssays so in as many words: "rather than synthesizinguser.role = 'admin', both paths now contribute CANONICAL names touser.positions", and "consumers must gate onpositions[]/isPlatformAdmin… never onuser.role === 'admin'".Confirmed by construction, same boot: writing
role = 'admin'onto that same admin row flipsban-user,unban-user,list-usersandimpersonate-userto 200 immediately (ban then persistsbanned: true/ban_reason, and the banned user's sign-in is refused403 BANNED_USER). So the endpoints work; only the identity predicate does not match.Corroboration this is not a harness artifact
admin-user-endpoints.tsdocuments the same mechanism as the reason it exists:/admin/create-userand/admin/set-user-passwordare re-implemented as ObjectStack raw mounts because the stock endpoint's adminMiddleware "would 403 a platform admin whose legacyrolescalar was never synthesized". Those two, plusunlock-user,import-usersandoauth2/toggle-disabled, carry the ADR-0068 gate and work correctly (member 403 PERMISSION_DENIED, admin 2xx). The rest were never re-implemented.remove-user-atomicity.test.tsworks around it in-process:userRow(engine, admin.userId).role = 'admin'with the comment "better-auth's admin plugin authorizes byuser.role(defaultadminRoles: ['admin'])". That is why no existing test observes this — the unit tests synthesize the scalar a real deployment never gets.User-visible impact
sys_user'sban_user,unban_userandimpersonate_useractions aretype: 'api'proxies onto exactly these routes, gatedrequiresFeature: 'admin'. On any deployment where the admin plugin is on (SCIM forces it, ADR-0071), the console renders Ban / Unban / Impersonate buttons that 403 for a platform admin.This fails closed — no unauthorized access, and QA run #9401's "no access-control hole" verdict is unaffected. It is a broken admin capability, not an escalation. Same defect class as #4839 (an admin exemption reading a field nothing populates).
Options (a decision, not an obvious fix)
admin({ adminRoles: [...] })oradminUserIds, if better-auth can be pointed atpositions[]. Smallest change; depends on what the vendor's option surface allows.create-user/set-user-passwordalready are. Consistent with the established pattern; the most code.role = 'admin'for platform admins again. Directly contradicts ADR-0068 D2 and would need that ADR revisited, not just an edit.Option 1 or 2 keeps ADR-0068 intact; 3 reopens it. Recommending 1 if the vendor supports a custom predicate, otherwise 2.
Repro
packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts(landing on #9482) derives the whole/admin/surface and records this bucket; it deliberately asserts neither side of the admin's answer, so it stays green before and after a fix.