From 9747deb479d66ce946497afb283a3a396c1e85bc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 12:59:05 +0000 Subject: [PATCH] fix(plugin-auth): answer the admin permission query from the platform-authz predicate Shade the vendor's admin permission-query route with an ObjectStack raw mount: a platform admin's query is evaluated against the vendor's own admin access-control statements with only the identity signal replaced (ADR-0068 predicate instead of the retired legacy role scalar), so the admin now gets the answer real execution gives. Every other caller and every body the vendor refuses to evaluate is delegated through handleRequest, so the plain member's own negative answer, the enveloped anonymous refusal, and the vendor's validation bytes all stand unchanged. The mount shadows the vendor-declared ledger row; the standing dogfood sweep reclassifies the route from NOT_AN_AUTHORIZATION_ANSWER to ADMITTED with the answer pinned in both directions. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01UQgPSniH1GFM9ZDeGyuGUa --- .../admin-has-permission-adr0068-answer.md | 5 + .../src/admin-has-permission-endpoint.test.ts | 297 ++++++++++++++++++ .../src/admin-has-permission-endpoint.ts | 208 ++++++++++++ .../plugins/plugin-auth/src/auth-plugin.ts | 78 ++++- ...in-platform-admin-standing.dogfood.test.ts | 50 ++- 5 files changed, 634 insertions(+), 4 deletions(-) create mode 100644 .changeset/admin-has-permission-adr0068-answer.md create mode 100644 packages/plugins/plugin-auth/src/admin-has-permission-endpoint.test.ts create mode 100644 packages/plugins/plugin-auth/src/admin-has-permission-endpoint.ts diff --git a/.changeset/admin-has-permission-adr0068-answer.md b/.changeset/admin-has-permission-adr0068-answer.md new file mode 100644 index 0000000000..f943583f01 --- /dev/null +++ b/.changeset/admin-has-permission-adr0068-answer.md @@ -0,0 +1,5 @@ +--- +'@objectstack/plugin-auth': patch +--- + +`POST /api/v1/auth/admin/has-permission` now answers an ObjectStack platform admin from the ADR-0068 platform-authz predicate. The vendor evaluated this permission query on the legacy `user.role === 'admin'` scalar that ADR-0068 D2 stopped synthesizing, so a genuine platform admin was answered `success: false` — indistinguishable from a plain member. The route is now shaded by an ObjectStack raw mount: a platform admin's query is evaluated against the vendor's own admin access-control statements with only the identity signal replaced (an ungranted or unknown permission still answers `false`), while anonymous callers, plain members, and every request body the vendor refuses to evaluate are delegated to the vendor unchanged, byte for byte. diff --git a/packages/plugins/plugin-auth/src/admin-has-permission-endpoint.test.ts b/packages/plugins/plugin-auth/src/admin-has-permission-endpoint.test.ts new file mode 100644 index 0000000000..ea2d04826a --- /dev/null +++ b/packages/plugins/plugin-auth/src/admin-has-permission-endpoint.test.ts @@ -0,0 +1,297 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #11900 — `/admin/has-permission` answers a PLATFORM ADMIN from the ADR-0068 +// predicate, and changes NOTHING else. +// +// ── The defect this pins ──────────────────────────────────────────────────── +// +// The vendor evaluates this permission QUERY on the legacy +// `user.role === 'admin'` scalar ADR-0068 D2 stopped synthesizing, so a +// genuine ObjectStack platform admin was answered `200 {"success":false}` — +// byte-identical to a plain member. A wrong ANSWER, not a refusal: no error to +// notice, and any caller trusting it as "this admin may not do X" is silently +// wrong. Maintainer ruling 2026-08-25 (option B per the card body's +// lettering): shade the route (#9652 pattern), answer from the predicate. +// +// ── Why every leg drives the REAL mount chain ─────────────────────────────── +// +// Raw-mount-vs-catch-all ordering does not exist inside `AuthManager` — a test +// driving `handleRequest` directly would bypass the mount under test entirely +// (the `admin-remove-user-gate-ordering.test.ts` reading). So the fixture +// mounts `AuthPlugin.registerAuthRoutes` on a real Hono app in front of a real +// `AuthManager` on the installed better-auth, and every assertion reads a +// status and a body off a real `Response`. +// +// ── Why the admin is granted, never scalared ──────────────────────────────── +// +// ⛔ The subject is made a platform admin the ADR-0068 D2 way — an unscoped +// `admin_full_access` grant — and the fixture ASSERTS the legacy scalar is +// absent. A `role = 'admin'` fixture would be answered `true` by the UNSHADED +// vendor too: it passes with or without this card's change and measures +// nothing (the `admin-impersonate-endpoint.test.ts` discipline). +// +// ── The contrast is the load-bearing half ─────────────────────────────────── +// +// The failure was a wrong ANSWER, so the answer is asserted in BOTH +// directions, twice over: +// +// • caller contrast — the admin's `true` means nothing unless the plain +// member's own `{"error":null,"success":false}` stays exactly as it is +// (a build that answers `true` for everyone satisfies the admin leg alone; +// the member's `true` would be the LEAK the non-admin dogfood sweep pins); +// • query contrast — the admin's `true` for a granted statement means +// nothing unless an UNGRANTED one still answers `false` (a build that +// echoes the predicate unconditionally satisfies the granted leg alone, +// and is just a new wrong-200 pointing the other way). +// +// Plus the delegated remainder, unchanged: anonymous 401 (enveloped), and the +// vendor's own 400 for every body shape it refuses to evaluate — asserted for +// the ADMIN caller, because the shading must not put an answer where the +// vendor's validation order puts a refusal. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Hono } from 'hono'; +import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; +import { AuthManager } from './auth-manager'; +import { AuthPlugin } from './auth-plugin'; +import { createMemoryEngine } from './impersonation-bearer-rotation.test'; +import { inviteForAudienceGate } from './audience-gate-test-support'; +import { readEvaluatedPermissionQuery } from './admin-has-permission-endpoint'; +import type { PluginContext } from '@objectstack/core'; + +const SECRET = 'test-secret-at-least-32-chars-long!!'; +const PASSWORD = 'S3cure!Passw0rd-11900'; +const ORIGIN = 'http://localhost:3000'; +const BASE = '/api/v1/auth'; +const ROUTE = '/admin/has-permission'; +const PS_ADMIN = 'ps_admin_full_access'; + +const mockCtx = (): PluginContext => + ({ + registerService: vi.fn(), + getService: vi.fn((name: string) => (name === 'manifest' ? { register: vi.fn() } : undefined)), + getServices: vi.fn(() => new Map()), + hook: vi.fn(), + trigger: vi.fn(), + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, + getKernel: vi.fn(), + }) as any; + +/** Status, parsed JSON (when any), and the raw text for failure messages. */ +async function answerOf(res: Response): Promise<{ status: number; json: any; text: string }> { + const text = await res.text(); + let json: any; + try { + json = JSON.parse(text); + } catch { + json = undefined; + } + return { status: res.status, json, text }; +} + +/** + * One deployment, served through the REAL mount chain: `admin` holds the + * ADR-0068 grant (and provably NOT the scalar), `member` is a plain + * authenticated user. + */ +async function stage() { + const engine = createMemoryEngine(); + const manager = new AuthManager({ + secret: SECRET, + baseUrl: ORIGIN, + dataEngine: engine, + plugins: { admin: true }, + } as any); + + const direct = (path: string, body: unknown) => + manager.handleRequest( + new Request(`${ORIGIN}${BASE}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + ); + + for (const [email, name] of [ + ['admin.11900@example.com', 'Granted Platform Admin'], + ['member.11900@example.com', 'Plain Member'], + ]) { + // [#11767] default audience posture is invite_only: fixture users beyond + // the first enter through the invitation carve-out. + await inviteForAudienceGate(manager, email); + const res = await direct('/sign-up/email', { email, password: PASSWORD, name }); + expect(res.status, `sign-up ${email}: ${await res.clone().text()}`).toBe(200); + } + + const users = (engine.tables.get('sys_user') ?? []) as any[]; + const adminId = String(users.find((r) => r.email === 'admin.11900@example.com')!.id); + + // The ADR-0068 D2 grant — an ORG-LESS `admin_full_access` link. ⛔ NOT the + // legacy scalar (see header). + await engine.insert('sys_permission_set', { id: PS_ADMIN, name: ADMIN_FULL_ACCESS }); + await engine.insert('sys_user_permission_set', { + user_id: adminId, + permission_set_id: PS_ADMIN, + organization_id: null, + }); + + const bearerFor = async (email: string) => { + const res = await direct('/sign-in/email', { email, password: PASSWORD }); + const token = res.headers.get('set-auth-token'); + expect(token, `sign-in ${email} must mint a bearer or the legs below prove nothing`).toBeTruthy(); + return token!; + }; + const adminBearer = await bearerFor('admin.11900@example.com'); + const memberBearer = await bearerFor('member.11900@example.com'); + + // The REAL route registration — raw mounts ahead of the catch-all. + const app = new Hono(); + const ctx = mockCtx(); + const plugin = new AuthPlugin({ secret: SECRET }); + await plugin.init(ctx); + (plugin as any).authManager = manager; + (plugin as any).registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx); + + const fire = (body: unknown, bearer?: string) => + app.request(`${ORIGIN}${BASE}${ROUTE}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + origin: ORIGIN, + ...(bearer ? { authorization: `Bearer ${bearer}` } : {}), + }, + body: JSON.stringify(body), + }); + + return { engine, fire, adminId, adminBearer, memberBearer }; +} + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => vi.restoreAllMocks()); + +// ─────────────────────────────────────────────────────────────────────────── +// THE FIX, WITH BOTH CONTRASTS — one staging, every caller +// ─────────────────────────────────────────────────────────────────────────── + +describe('#11900 — /admin/has-permission answers the ADR-0068 platform admin', () => { + it('admin true / same-query member false / ungranted-query admin false — both contrasts on one staging', async () => { + const { engine, fire, adminId, adminBearer, memberBearer } = await stage(); + + // Control: the subject's standing is the GRANT, not the scalar. Without + // this the true-leg below could be riding the vendor's own gate. + const adminRow = (engine.tables.get('sys_user') ?? []).find( + (r: any) => String(r.id) === adminId, + ); + expect( + adminRow?.role, + 'fixture control: the admin must NOT carry the legacy scalar — a scalared fixture is answered ' + + 'true by the UNSHADED vendor and measures nothing', + ).not.toBe('admin'); + + const granted = { permissions: { user: ['list'] } }; + + // ⭐ THE FIX. Before the mount this answered {"error":null,"success":false} + // — the wrong-200 the card measured. + const admin = await answerOf(await fire(granted, adminBearer)); + expect(admin.status, `admin granted-query: ${admin.text}`).toBe(200); + expect(admin.json, 'the platform admin must get the answer real execution gives').toEqual({ + error: null, + success: true, + }); + + // ⛔ CALLER CONTRAST — the load-bearing negative. The member's own + // negative ANSWER must stay exactly as it is: same envelope, same keys, + // same verdict. A `true` here is the leak; a refusal here is a different + // regression (the gate swallowing a self-scoped query). + const member = await answerOf(await fire(granted, memberBearer)); + expect(member.status, `member granted-query: ${member.text}`).toBe(200); + expect(member.json, 'the plain member’s negative answer must not move').toEqual({ + error: null, + success: false, + }); + + // ⛔ QUERY CONTRAST — the predicate decides WHO, the vendor's statements + // still decide WHAT. `user: ['impersonate-admins']` is in the vendor's + // statement vocabulary but NOT granted to its admin role; an unknown + // resource is outside the vocabulary entirely. Both must stay `false` for + // the admin, exactly as they would for a legacy-scalar admin — a mount + // that echoes the predicate unconditionally fails here. + for (const ungranted of [ + { permissions: { user: ['impersonate-admins'] } }, + { permissions: { 'not-a-vendor-resource': ['read'] } }, + { permissions: {} }, // the vendor's empty query is a `false`, both roles + ]) { + const a = await answerOf(await fire(ungranted, adminBearer)); + expect(a.status, `admin ungranted-query ${JSON.stringify(ungranted)}: ${a.text}`).toBe(200); + expect( + a.json, + `an ungranted permission must still answer false to the admin — ${JSON.stringify(ungranted)}`, + ).toEqual({ error: null, success: false }); + } + }, 120_000); + + it('the delegated remainder is untouched: anonymous 401, vendor 400s in vendor order', async () => { + const { fire, adminBearer } = await stage(); + + // Anonymous with an evaluable body → the vendor lane's enveloped 401 + // (#10349), delegated. The mount must never mint an answer for a caller + // it did not resolve. + const anon = await answerOf(await fire({ permissions: { user: ['list'] } })); + expect(anon.status, `anonymous: ${anon.text}`).toBe(401); + expect(anon.json?.error?.code, `anonymous code: ${anon.text}`).toBe('UNAUTHENTICATED'); + + // Bodies the vendor refuses to EVALUATE must keep the vendor's own 400 — + // for the ADMIN caller. The shading must not put a confident answer where + // the vendor's validation order puts a refusal (that would be a new + // wrong-200), so every one of these delegates: + const refused: Array<[string, unknown]> = [ + ['no permission key at all', {}], + ['singular `permission` only (zod-valid, handler-refused)', { permission: { user: ['list'] } }], + ['both keys (the schema xor)', { permission: { user: ['list'] }, permissions: { user: ['list'] } }], + ['non-string action element', { permissions: { user: [1] } }], + ['permissions not a record', { permissions: 'user' }], + ['non-string role alongside a valid query', { role: 5, permissions: { user: ['list'] } }], + ]; + for (const [label, body] of refused) { + const a = await answerOf(await fire(body, adminBearer)); + expect(a.status, `${label}: expected the vendor's own 400, got ${a.status} ${a.text}`).toBe(400); + expect(a.json?.success, `${label}: a refused body must never read as an answer`).not.toBe(true); + } + }, 120_000); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// The acceptance mirror, both directions (see the module header for why a +// looser OR stricter set than the vendor's is each its own wrong-200) +// ─────────────────────────────────────────────────────────────────────────── + +describe('#11900 — readEvaluatedPermissionQuery mirrors the vendor-evaluated set', () => { + it('accepts exactly the bodies the installed vendor handler evaluates', () => { + // Evaluated by the vendor → returned for answering. + expect(readEvaluatedPermissionQuery({ permissions: { user: ['list'] } })).toEqual({ + user: ['list'], + }); + expect( + readEvaluatedPermissionQuery({ userId: 42, role: 'user', permissions: { a: [] } }), + 'userId is dead on the wire and any JSON value coerces; a string role passes zod', + ).toEqual({ a: [] }); + expect(readEvaluatedPermissionQuery({ permissions: {} }), 'the empty query IS evaluated (to false)').toEqual({}); + + // Refused (or never evaluated) by the vendor → undefined → delegate. + expect(readEvaluatedPermissionQuery(undefined)).toBeUndefined(); + expect(readEvaluatedPermissionQuery('permissions')).toBeUndefined(); + expect(readEvaluatedPermissionQuery({})).toBeUndefined(); + expect(readEvaluatedPermissionQuery({ permission: { user: ['list'] } }), 'singular-only dies in the handler').toBeUndefined(); + expect( + readEvaluatedPermissionQuery({ permission: { user: ['list'] }, permissions: { user: ['list'] } }), + 'both keys fail the schema xor', + ).toBeUndefined(); + expect(readEvaluatedPermissionQuery({ permissions: { user: 'list' } })).toBeUndefined(); + expect(readEvaluatedPermissionQuery({ permissions: { user: [1] } })).toBeUndefined(); + expect(readEvaluatedPermissionQuery({ permissions: ['user'] })).toBeUndefined(); + expect(readEvaluatedPermissionQuery({ role: 5, permissions: { user: ['list'] } })).toBeUndefined(); + }); +}); diff --git a/packages/plugins/plugin-auth/src/admin-has-permission-endpoint.ts b/packages/plugins/plugin-auth/src/admin-has-permission-endpoint.ts new file mode 100644 index 0000000000..8b3a6645b5 --- /dev/null +++ b/packages/plugins/plugin-auth/src/admin-has-permission-endpoint.ts @@ -0,0 +1,208 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#11900] `POST /admin/has-permission` — answer a platform admin's permission + * QUERY from the ADR-0068 predicate, not the retired legacy scalar. + * + * ## The defect this shades away + * + * better-auth's `admin` plugin evaluates `hasPermission` against the legacy + * `user.role === 'admin'` scalar (this repo constructs `admin({ schema })` + * only, so the vendor default `adminRoles: ['admin']` applies), and ADR-0068 + * D2 deliberately stopped synthesizing that scalar. So the vendor's own + * permission-query endpoint told a genuine ObjectStack platform admin "no" — + * a `200 {"success":false}` byte-identical to a plain member's answer. Unlike + * the `403`s of the #9652 family this is not a visible refusal: it is an + * authoritative-looking WRONG ANSWER, and any caller that trusts it gets + * wrong permission logic with no error to log. Maintainer ruling 2026-08-25 + * (option B per the card body's lettering): shade the route with the #9652 + * raw-mount pattern and answer from the ADR-0068 predicate, so a platform + * admin's query returns the answer real execution would give. + * + * ## What the shading changes, and the ONE row it changes + * + * The raw mount in `auth-plugin.ts` intercepts the route ahead of the + * catch-all and takes over EXACTLY ONE row of the behaviour table: a caller + * who is a platform admin under ADR-0068 (`isPlatformAdminUser`, the same + * predicate every shaded `/admin/*` mount trusts) sending a body the vendor's + * own handler would EVALUATE. Every other caller and every other body shape + * is DELEGATED through `AuthManager.handleRequest` — the #12029 gate-then- + * delegate seam — so the vendor's native bytes stand: an anonymous caller + * still gets the enveloped 401, a plain member still gets its own + * `200 {"error":null,"success":false}` negative (pinned by the non-admin + * dogfood sweep — a `true` there would be the leak), and a malformed body + * still gets the vendor's own 400, in the vendor's own validation order. + * + * ## The answer is the vendor's own evaluation with the IDENTITY fixed + * + * ⛔ Not `success: true` unconditionally. The predicate decides WHO is an + * admin; the vendor's access-control statements still decide WHAT an admin + * may do, exactly as they would for a caller carrying the legacy scalar. The + * evaluation mirrors the vendor's `hasPermission` (`dist/plugins/admin/ + * has-permission.mjs`, reproduced because the vendor does not export a + * server-side entry for it — the same reading `admin-revoke-user-session- + * match-guard.ts` records) with the role input replaced: instead of the + * caller's stored `role` scalar, the roles the mounted plugin counts as admin + * (`adminRoles`, default `['admin']`) are evaluated over the live plugin's + * role table (`roles`, default: the vendor's exported `defaultRoles`). So a + * granted statement answers `true`, and a permission set the vendor's admin + * role does NOT grant — an unknown resource, an ungranted action — still + * answers `false`, exactly as it would to a legacy-scalar admin. Answering + * `true` to everything would replace one wrong-200 with another. + * + * ## Fail direction + * + * Every uncertainty delegates: an unreadable body, a shape outside the set + * the vendor evaluates, an unreadable live-options object. Delegation can + * only reproduce the vendor's measured native behaviour — it can never mint + * a `true` for a caller the predicate did not admit. The only path to + * `success: true` runs through `isPlatformAdminUser` (or the vendor's own + * `adminUserIds` short-circuit, mirrored below for option fidelity; this + * repo configures none). + * + * ## Why the evaluated-body set is spelled out here + * + * If this module's acceptance were LOOSER than the vendor's, a platform + * admin would get `success: true` for a body the vendor refuses as invalid — + * a new wrong-200 in the opposite direction. If it were STRICTER, the + * delegated remainder would answer that admin from the legacy scalar — the + * very defect this module exists to close, resurfacing on the excluded + * shapes. So {@link readEvaluatedPermissionQuery} mirrors, key for key, the + * set of bodies the installed vendor handler actually evaluates (zod schema + * AND the handler's own `permissions` guard), and the integration test pins + * both directions. + */ + +/** What the evaluator needs from the plugin — the LIVE better-auth context. */ +export interface AdminHasPermissionDeps { + /** + * `AuthManager.getAuthContext()` — better-auth's own `$context`, whose + * `options.plugins` entry for `id: 'admin'` retains the very options object + * the mounted plugin runs on. Read live so `adminUserIds` / `adminRoles` / + * custom `roles` configured on the plugin are honoured without a second + * source of truth (the `admin-revoke-user-session-match-guard.ts` + * discipline). + */ + getAuthContext(): Promise; +} + +/** The wire answer shape — the vendor's own, key order included. */ +export interface AdminHasPermissionAnswer { + error: null; + success: boolean; +} + +const isPlainObject = (v: unknown): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v); + +/** + * The permission-query bodies the installed vendor handler EVALUATES, and no + * others (see the header for why both directions matter). Measured on + * better-auth 1.7.1 (`dist/plugins/admin/routes.mjs`, `userHasPermission`): + * + * - the zod body schema is `{ userId?: coerce.string, role?: string }` + * intersected with `xor(permission | permissions)`, each a + * `Record` — so `permission` AND `permissions` together + * fail validation (400), and a non-string action element fails too; + * - the handler then evaluates ONLY `body.permissions` (plural): a + * singular-`permission` body passes zod but dies on the handler's own + * `no permission(s) were passed` 400 before any evaluation; + * - `userId` cannot fail coercion for any JSON value and is DEAD on the wire + * (the resolved session always wins in the vendor's own handler), so it is + * ignored here for the same reason; + * - `role` is read by the vendor only when NO session resolved, which cannot + * happen on this branch (the mount resolved one) — but a non-string `role` + * still fails zod ahead of that, so it must fail here too. + * + * Returns the `permissions` record to evaluate, or `undefined` for "not a + * body the vendor evaluates — delegate, and let the vendor answer with its + * own validation bytes". + */ +export function readEvaluatedPermissionQuery( + body: unknown, +): Record | undefined { + if (!isPlainObject(body)) return undefined; + if ('permission' in body) return undefined; // xor half, or handler-400 — vendor's answer either way + if ('role' in body && typeof body.role !== 'string') return undefined; + const permissions = body.permissions; + if (!isPlainObject(permissions)) return undefined; + for (const actions of Object.values(permissions)) { + if (!Array.isArray(actions)) return undefined; + if (!actions.every((a) => typeof a === 'string')) return undefined; + } + return permissions as Record; +} + +/** The vendor's `AccessControl` role shape, as much of it as the mirror reads. */ +type AuthorizingRole = { + authorize?: (permissions: unknown) => { success?: boolean } | undefined; +}; + +/** + * Answer the query as the vendor would answer a caller carrying the plugin's + * admin role(s) — the vendor's own `hasPermission` with only the identity + * signal replaced (see header). `callerUserId` feeds the vendor's + * `adminUserIds` short-circuit, mirrored for option fidelity. + */ +export async function answerPermissionQueryAsAdmin( + deps: AdminHasPermissionDeps, + callerUserId: string, + permissions: Record, +): Promise { + let adminOptions: Record | undefined; + try { + const ctx = (await deps.getAuthContext()) as { + options?: { plugins?: Array<{ id?: string; options?: Record }> }; + } | null; + adminOptions = (Array.isArray(ctx?.options?.plugins) ? ctx.options.plugins : []).find( + (p) => p?.id === 'admin', + )?.options; + } catch { + // Unreadable live options → run on the vendor's own defaults, exactly as + // the vendor itself would with an unconfigured plugin. + adminOptions = undefined; + } + const opts = (adminOptions ?? {}) as { + adminUserIds?: unknown; + adminRoles?: unknown; + roles?: unknown; + }; + + // The vendor's first line, verbatim in spirit: adminUserIds are admins for + // ANY query. (This repo configures none; mirrored so a deployment that does + // gets the vendor's own answer, not a stricter one.) + if ( + callerUserId && + Array.isArray(opts.adminUserIds) && + opts.adminUserIds.some((x) => String(x) === callerUserId) + ) { + return { error: null, success: true }; + } + + const { defaultRoles } = await import('better-auth/plugins/admin/access'); + const acRoles: Record = + opts.roles && typeof opts.roles === 'object' + ? (opts.roles as Record) + : (defaultRoles as unknown as Record); + // `adminRoles?: string | string[]`, comma-splittable when a string — the + // vendor's own normalization (`admin.mjs`), default `['admin']`. + const adminRoles = Array.isArray(opts.adminRoles) + ? opts.adminRoles.map((r) => String(r)) + : typeof opts.adminRoles === 'string' + ? opts.adminRoles.split(',') + : ['admin']; + + let success = false; + for (const role of adminRoles) { + try { + if (acRoles[role]?.authorize?.(permissions)?.success) { + success = true; + break; + } + } catch { + // An authorizer that throws grades as not-permitted — the same reading + // the revoke-guard mirror records. + } + } + return { error: null, success }; +} diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 46e63e2d3a..a86c3aa7e5 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -64,7 +64,7 @@ import { isDevAdminSeedArmed, warnIfWalledOwnerCannotVerify, } from './walled-owner-verification-path.js'; -import { judgePlatformAdmin, type PlatformAdminActor } from './platform-admin-gate.js'; +import { judgePlatformAdmin, isPlatformAdminUser, type PlatformAdminActor } from './platform-admin-gate.js'; import { runAdminBanUser, runAdminUnbanUser, @@ -2252,6 +2252,82 @@ export class AuthPlugin implements Plugin { } }); + // ── #11900: /admin/has-permission — a QUERY answered from the ADR-0068 + // predicate, everything else delegated byte-for-byte ──────────────── + // + // The vendor evaluates this permission QUERY on the legacy + // `user.role === 'admin'` scalar ADR-0068 D2 stopped synthesizing, so a + // genuine platform admin was told `200 {"success":false}` — + // byte-identical to a plain member's answer. Not a refusal like the + // #9652 family: a confident WRONG ANSWER on a published + // authorization-answer surface. Maintainer ruling 2026-08-25 (option B + // per the card body's lettering): shade it, answering from the + // ADR-0068 predicate. + // + // ⚠️ Unlike `remove-user` above, gate-then-delegate CANNOT carry this + // fix: delegation preserves the vendor's answer, and here the vendor's + // answer to the admitted caller IS the defect. And unlike `ban-user`, + // gate-then-reimplement cannot either: `gateAdmin`'s 403 would replace + // the plain member's own `200 {"error":null,"success":false}` — the + // correct negative ANSWER to a permission question, pinned by the + // non-admin dogfood sweep (a `true` there would be the leak, but so + // would turning the answer into a refusal). So this mount branches on + // the predicate WITHOUT refusing anyone: + // + // platform admin + a body the vendor would evaluate + // → answered here, from the vendor's own access-control statements + // with only the identity signal replaced (an ungranted or unknown + // permission still answers `false` — see + // admin-has-permission-endpoint.ts for why unconditional `true` + // would be a new wrong-200); + // everyone and everything else — anonymous (enveloped 401), plain + // member (its own negative), any body the vendor refuses (vendor + // 400, vendor ordering) + // → delegated through `handleRequest`, native bytes standing. + // + // Ledger: `POST /api/v1/auth/admin/has-permission` stays a + // `BETTER_AUTH_MOUNTED_SURFACE` row; `check:auth-mount-ledger` accounts + // for this mount as "shadowing a vendor-declared path" (the #12029 + // worked reading — a shadow is accounted for, not a new row). + // + // Pinned by `admin-has-permission-endpoint.test.ts` (both directions, + // full table) and the two dogfood sweeps (admin standing + non-admin + // negative). + rawApp.post(`${basePath}/admin/has-permission`, async (c: any) => { + try { + const authApi = await this.authManager!.getApi(); + const session = await (authApi as any).getSession({ headers: c.req.raw.headers }); + const user = (session as { user?: { id?: unknown } } | null | undefined)?.user; + if (user?.id && isPlatformAdminUser(user)) { + const { readEvaluatedPermissionQuery, answerPermissionQueryAsAdmin } = await import( + './admin-has-permission-endpoint.js' + ); + // Parse from a CLONE: on the delegate path below the original + // request body must reach the vendor undisturbed. + let body: unknown; + try { + body = await c.req.raw.clone().json(); + } catch { + body = undefined; // unreadable → the vendor's own 400, below + } + const query = readEvaluatedPermissionQuery(body); + if (query) { + const answer = await answerPermissionQueryAsAdmin( + { getAuthContext: () => this.authManager!.getAuthContext() }, + String(user.id), + query, + ); + return c.json(answer, 200); + } + } + return await this.authManager!.handleRequest(c.req.raw); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + ctx.logger.error('[AuthPlugin] admin/has-permission failed', err); + return c.json({ success: false, error: { code: 'INTERNAL_ERROR', message: err.message } }, 500); + } + }); + rawApp.post(`${basePath}/admin/set-user-password`, async (c: any) => { try { const actor = await gateAdmin(c); diff --git a/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts b/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts index 7c0b845ff4..81ae091f39 100644 --- a/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts +++ b/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts @@ -31,7 +31,13 @@ // `/admin/stop-impersonating` and silently detach the #8243 bearer-rotation // hook. It is a better-auth PLUGIN endpoint with only the authorization // predicate replaced, and since #11686 that predicate is the consolidated -// authority `hasPlatformAdminStanding`. +// authority `hasPlatformAdminStanding`. `has-permission` (#11900, ruled +// 2026-08-25) is a third shape: a permission QUERY, raw-mounted WITHOUT +// the refusing judge — the platform admin's query is answered from the +// vendor's own access-control statements with only the identity signal +// replaced, and every other caller and body is delegated so the plain +// member's negative answer and the vendor's validation bytes stand +// (`admin-has-permission-endpoint.ts`). // // ⭐ REFUSED BY DESIGN — the eight routes below answer a platform admin // `403 YOU_ARE_NOT_ALLOWED_TO_*`, and that is a RULED OUTCOME, not a gap @@ -209,6 +215,12 @@ function refusedByDesignFor(targetUserId: string): Record { const ADMITTED = [ 'POST /api/v1/auth/admin/ban-user', 'POST /api/v1/auth/admin/unban-user', + // #11900 — a permission QUERY, not an operation: the mount answers a + // platform admin from the ADR-0068 predicate over the vendor's own + // statements and never refuses anyone (non-admins are delegated to the + // vendor's own negative answer, which the sibling file pins). Its "answer, + // not refusal" contrast is pinned individually below. + 'POST /api/v1/auth/admin/has-permission', 'POST /api/v1/auth/admin/create-user', 'POST /api/v1/auth/admin/set-user-password', 'POST /api/v1/auth/admin/unlock-user', @@ -227,8 +239,9 @@ const ADMITTED = [ * with WHY, so this is a classification and not a mute button. */ const NOT_AN_AUTHORIZATION_ANSWER: Record = { - 'POST /api/v1/auth/admin/has-permission': - 'a permission QUERY, not an operation. It answers 200 {success:false} to the platform admin too, because the vendor evaluates it against the legacy role scalar — the same mismatch, but the shape is an answer, not a refusal', + // (`has-permission` moved OUT of this classification by #11900: the mount + // now answers the platform admin from the ADR-0068 predicate, so its 200 IS + // an authorization answer and it lives in ADMITTED, pinned both ways below.) 'POST /api/v1/auth/admin/stop-impersonating': 'self-scoped: it ends the CALLER\'s own impersonation. A non-impersonating caller — admin or not — gets 400', 'GET /api/v1/auth/admin/oauth2/resources': 'oidcProvider plugin not enabled at this boot — 404 to everyone', @@ -480,6 +493,37 @@ describe('#9482: what an ObjectStack platform admin gets from every /admin/ rout expect(client.disabled, 'toggle-disabled answered 200 but the stored row did not move').toBeTruthy(); }, 300_000); + it('C-11900 — has-permission gives the platform admin a REAL authorization answer, both directions', async () => { + // ⭐ Before #11900 this exact query answered the platform admin + // `200 {"error":null,"success":false}` — byte-identical to a plain + // member, because the vendor evaluated the legacy role scalar ADR-0068 D2 + // retired. The identity control above has already proven the subject + // carries NO scalar, so a `true` here can only be the ADR-0068 mount. + const granted = await expectAdmitted( + 'POST /api/v1/auth/admin/has-permission', + { permissions: { user: ['list'] } }, + 'has-permission moved onto an ObjectStack raw mount answering from the ADR-0068 predicate by #11900', + ); + expect(granted.json.error, `has-permission granted-query error slot: ${granted.body}`).toBeNull(); + + // ⛔ The query contrast — the predicate decides WHO is an admin; the + // vendor's own statements still decide WHAT an admin may do. An UNGRANTED + // action must stay `false` for the very same admin, or the mount is an + // unconditional echo of the predicate — a new wrong-200 pointing the + // other way. (The CALLER contrast — the plain member's own + // `success:false` on the granted query — is the sibling non-admin file's + // standing pin and must stay green beside this one.) + const ungranted = await fire('POST /api/v1/auth/admin/has-permission', { + permissions: { user: ['impersonate-admins'] }, + }); + expect(ungranted.status, `has-permission ungranted-query: ${ungranted.body}`).toBe(200); + expect( + ungranted.json.success, + 'an action the vendor admin role does not grant must still answer the platform admin false — ' + + `a true here means the answer stopped being an evaluation: ${ungranted.body}`, + ).toBe(false); + }, 300_000); + // ── 2. THE BY-DESIGN REFUSALS ───────────────────────────────────────────── it('the eight consumer-less admin routes refuse the platform admin BY DESIGN, each with its ruled code', async () => {