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 () => {