From 057ac0405a72646bc06adff20c2f38a73b9d0185 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 15:36:38 +0000 Subject: [PATCH 1/6] fix(plugin-auth): the vendor-served platform-admin doors refuse a held caller (ADR-0069 D3) The re-authorized impersonation endpoint, the SSO-provider registration before-hook and the admin plugin's role-scalar actions now ask the session gate's own posture carrier (computeAuthGate, the first entry of resolveUserAuthGates) for the acting user in the organization the session operates in, and refuse a held caller with judgeHeldSession, the refusal judgePlatformAdmin gives on the raw platform-admin mounts: 403, the posture's code and message, in the ADR-0112 envelope. Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy Co-authored-by: Claude --- .../src/admin-impersonate-endpoint.ts | 94 ++++++++++++++++++- .../plugins/plugin-auth/src/auth-manager.ts | 66 ++++++++++++- .../plugin-auth/src/platform-admin-gate.ts | 49 ++++++++-- 3 files changed, 197 insertions(+), 12 deletions(-) diff --git a/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.ts b/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.ts index a03a0704b30..d3cafc40dea 100644 --- a/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.ts +++ b/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.ts @@ -102,9 +102,38 @@ * and the dogfood sweep distinguishes the two envelopes on purpose (see * `admin-ban-endpoints.ts`). Keeping them also mints no new public error code, * so nothing here reaches the spec error-code ledger. + * + * ## A held caller is refused first, with the gate's refusal (ADR-0069 D3) + * + * A platform admin whom the authentication policy holds (an expired or + * administrator-forced password, enforced MFA not yet enrolled) is gated out of + * every protected resource until they remediate, and minting a session as + * another user is one. Measured on a real boot before this check: in every held + * state the session gate refused the admin's data read with the posture code, + * while this endpoint answered `200` with a session for the target — and that + * session is not held, so the hold was bypassed outright. + * + * The session `adminMiddleware` hands this handler is a raw better-auth read + * that `customSession` never decorates, so it carries no `authGate` (measured), + * and no ADR-0069 transport seam ever sees a request under the auth namespace. + * So the handler asks the auth service for the caller's posture in the + * organization the session operates in — the session gate's own carrier, the + * first entry of `resolveUserAuthGates` — and refuses a held caller with + * `judgeHeldSession`, the refusal `judgePlatformAdmin` gives on the raw + * platform-admin mounts. Order: identity (the vendor's `401`), then the + * posture, then the authorization predicate below — the order the session gate + * and the judge apply, so a held non-admin reads the posture too. + * + * That refusal is the GATE's envelope (`{ success: false, error: { code, + * message } }`), not the vendor's flat one, on purpose: it is the session + * gate's answer, and a client that recognises a held session reads the same + * body here as on every other protected door. Its codes are the posture + * vocabulary (`AuthGateCodeSchema`), so it mints no code either. + * + * ## The rest of the admin plugin's lane: {@link VENDOR_ROLE_SCALAR_ADMIN_PATHS} */ -import { isPlatformAdminUser } from './platform-admin-gate.js'; +import { isPlatformAdminUser, judgeHeldSession } from './platform-admin-gate.js'; import { IMPERSONATE_USER_PATH, ADMIN_SESSION_COOKIE_KEY } from './impersonation-bearer-rotation.js'; /** @@ -118,6 +147,57 @@ import { IMPERSONATE_USER_PATH, ADMIN_SESSION_COOKIE_KEY } from './impersonation */ export type PlatformAdminOracle = (userId: string) => Promise; +/** + * Answers the session gate's posture carrier for `userId` operating in + * `organizationId`: the first, most urgent entry of the auth service's ONE + * per-user posture computation (`resolveUserAuthGates`, ADR-0069 · #23287 + * ruling C′ item 3), exactly what `customSession` stamps on a session as + * `user.authGate` — or `undefined` when no policy holds the user. + * + * Injected for the same reason as {@link PlatformAdminOracle}. It fails OPEN, + * as the computation it reads does: a lookup that fails holds no one. + */ +export type SessionPostureOracle = (userId: string, organizationId: string | undefined) => Promise; + +/** + * The better-auth `admin` plugin routes that the vendor serves ITSELF and + * authorizes on its own inputs alone — `adminMiddleware` (a session) plus + * `hasPermission` over the legacy `role` scalar — so no ObjectStack judge runs + * on them and nothing on their path reads the ADR-0069 posture. + * + * Measured on a real boot: a caller carrying `role = 'admin'` (a pre-ADR-0068 + * deployment, which `isPlatformAdminUser` still reads) and held by the policy + * in any state was refused by the session gate on a data read and ADMITTED to + * every one of these — the user listing and record, the session listing and + * revocation, and the writes (`update-user` landed, `set-role` answered `200`). + * `auth-manager.ts`'s global before-hook therefore refuses a held caller on + * these paths with the same refusal, asked of the same posture computation. + * + * Every other path the plugin registers is classified, not forgotten + * (`admin-impersonate-endpoint.held-caller.test.ts` reds on a vendor path that + * is in neither list): + * + * - `/admin/impersonate-user` — its own door, this module's endpoint; + * - `/admin/create-user`, `/admin/set-user-password`, `/admin/ban-user`, + * `/admin/unban-user`, `/admin/remove-user` — an ObjectStack mount on the + * path runs `judgePlatformAdmin` (held refused) before anything reaches the + * vendor; + * - `/admin/stop-impersonating` — the EXIT from an impersonation, a + * remediation-shaped request like sign-out, carried by the impersonated + * session; holding it would trap that session rather than protect anything; + * - `/admin/has-permission` — a question that changes nothing and refuses no + * one (no `adminMiddleware`). + */ +export const VENDOR_ROLE_SCALAR_ADMIN_PATHS: ReadonlySet = new Set([ + '/admin/set-role', + '/admin/get-user', + '/admin/update-user', + '/admin/list-users', + '/admin/list-user-sessions', + '/admin/revoke-user-session', + '/admin/revoke-user-sessions', +]); + /** The slice of better-auth's `admin` plugin this module rewrites. */ export interface AdminPluginLike { id: string; @@ -166,6 +246,7 @@ export const USER_NOT_FOUND = { code: 'USER_NOT_FOUND', message: 'User not found export async function applyPlatformAdminImpersonation( plugin: AdminPluginLike, isPlatformAdmin: PlatformAdminOracle, + postureOf: SessionPostureOracle, ): Promise { const vendor = plugin?.endpoints?.impersonateUser; if (!vendor || vendor.path !== IMPERSONATE_USER_PATH || !vendor.options) return false; @@ -202,6 +283,17 @@ export async function applyPlatformAdminImpersonation( const caller = ctx.context.session?.user; const callerId = typeof caller?.id === 'string' ? caller.id : ''; + // ── ADR-0069 D3: a held caller is refused first (see the header) ───── + // The session gate's carrier, asked for the organization this session + // operates in, then the gate's decision and the judge's refusal body. + if (callerId) { + const activeOrgId = ctx.context.session?.session?.activeOrganizationId; + const held = judgeHeldSession({ + authGate: await postureOf(callerId, typeof activeOrgId === 'string' && activeOrgId ? activeOrgId : undefined), + }); + if (held) throw new APIError('FORBIDDEN', held.body); + } + // ── THE changed predicate (1/2): who may impersonate ───────────────── // `isPlatformAdminUser` reads the session user we already hold — which // inside an endpoint is the RAW row (better-auth's `adminMiddleware` diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index cd8f7101593..a0fe90c99ac 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -86,7 +86,9 @@ import { resetVerifiedOnTwoFactorReenrollment } from './two-factor-reenrollment- import { attachSessionToCredentialResponse } from './session-envelope-completion.js'; import { applyPlatformAdminImpersonation, + VENDOR_ROLE_SCALAR_ADMIN_PATHS, } from './admin-impersonate-endpoint.js'; +import { judgeHeldSession } from './platform-admin-gate.js'; import { applyDeclaredInvitationVerificationToListing, } from './list-user-invitations-verification.js'; @@ -2101,6 +2103,25 @@ export class AuthManager { // fall through — the vendor still performs the revoke itself } + // ── ADR-0069 D3: the admin plugin's role-scalar actions refuse a ── + // caller the authentication policy holds. better-auth serves these + // itself and authorizes them on a session plus the legacy `role` + // scalar, so no ObjectStack judge runs and nothing on the path read + // the posture: measured on a real boot, a held caller carrying that + // scalar was admitted to every one of them, writes included. The + // refusal is the session gate's, asked of the session gate's own + // posture carrier (`assertActorNotHeld`), and it runs FIRST — before + // the vendor's authorization and before the record guard below — the + // order the session gate applies. No resolvable session falls through + // to the vendor's own 401. `VENDOR_ROLE_SCALAR_ADMIN_PATHS` + // (`admin-impersonate-endpoint.ts`) classifies every other path the + // plugin registers. + if (VENDOR_ROLE_SCALAR_ADMIN_PATHS.has(ctx?.path)) { + const actor = await this.resolveActor(ctx); + if (actor?.userId) await this.assertActorNotHeld(actor); + // fall through — the vendor still authorizes everything it owns + } + // ── #10069: the ADMIN revoke that identifies NO record must not ── // report success either. better-auth 1.7.1's `admin/revoke-user- // session` handler calls `deleteSession(ctx.body.sessionToken)` @@ -2147,9 +2168,17 @@ export class AuthManager { // `sessionMiddleware` (→ 401). Fail-CLOSED: an unverifiable actor is // denied. (D5.1's `/oauth2/authorize` gate is a different surface — // the OP issuing codes, not the env's RP config.) + // + // ADR-0069 D3: a caller the authentication policy holds is refused + // FIRST, with the session gate's refusal (`assertActorNotHeld`) — + // identity, then posture, then the platform-admin question, the order + // `judgePlatformAdmin` applies on the `/admin/sso/*` bridges. Measured + // on a real boot before this: a held platform admin's registration + // answered `200` and the provider row landed, in every held state. if (ctx?.path === '/sso/register') { const actor = await this.resolveActor(ctx); if (actor?.userId) { + await this.assertActorNotHeld(actor); const ok = await this.isPlatformAdminUserId(actor.userId); if (!ok) { const { APIError } = await import('better-auth/api'); @@ -3607,10 +3636,18 @@ export class AuthManager { // // Every OTHER better-auth-native `/admin/*` route still gates on the // legacy scalar and still refuses platform admins — that is the parent - // card's remaining surface, deliberately untouched here. + // card's remaining surface, deliberately untouched here. (A caller the + // ADR-0069 policy holds is refused on those ahead of the vendor, by the + // global before-hook — `VENDOR_ROLE_SCALAR_ADMIN_PATHS`.) + // + // ADR-0069 D3 — the same endpoint refuses a caller the policy holds, + // asked of the session gate's own posture carrier (`computeAuthGate`, + // the first entry of `resolveUserAuthGates`); see the endpoint module. const rewired = await applyPlatformAdminImpersonation( adminPlugin, (userId: string) => this.isPlatformAdminUserId(userId), + (userId: string, organizationId: string | undefined) => + this.computeAuthGate(userId, organizationId, false), ); if (!rewired) { // The vendor renamed or dropped the endpoint. Say so loudly: the @@ -7183,6 +7220,33 @@ export class AuthManager { return hasPlatformAdminStanding(engine, userId); } + /** + * ADR-0069 D3 at a vendor-served platform-admin door (the SSO-provider + * registration hook, the `admin` plugin's role-scalar actions): refuse + * `actor` when the authentication policy holds them, with the refusal the + * session gate gives, and return otherwise. + * + * These doors are better-auth endpoints whose session `customSession` never + * decorates, so the posture is ASKED, never read off the request: the session + * gate's own carrier ({@link computeAuthGate} — the first entry of + * {@link resolveUserAuthGates}, the ONE per-user posture computation) for the + * organization the actor's session operates in, judged by `judgeHeldSession` + * (`platform-admin-gate.ts`), which is `judgePlatformAdmin`'s held refusal. + * No second computation and no second decision. It fails OPEN exactly where + * that computation does (an unreadable posture holds no one). + * + * Thrown as a better-auth `APIError` carrying the judge's ADR-0112 body, so + * the vendor endpoint answers `403` with the posture's own code and message. + */ + private async assertActorNotHeld(actor: { userId: string; activeOrgId?: string }): Promise { + const held = judgeHeldSession({ + authGate: await this.computeAuthGate(actor.userId, actor.activeOrgId, false), + }); + if (!held) return; + const { APIError } = await import('better-auth/api'); + throw new APIError('FORBIDDEN', held.body); + } + /** * [#8289] Answer `/organization/remove-member`'s PERMISSION denial with the * `403 YOU_ARE_NOT_ALLOWED_TO_*` envelope its siblings use, ahead of the diff --git a/packages/plugins/plugin-auth/src/platform-admin-gate.ts b/packages/plugins/plugin-auth/src/platform-admin-gate.ts index f397cbde455..4fbe016c405 100644 --- a/packages/plugins/plugin-auth/src/platform-admin-gate.ts +++ b/packages/plugins/plugin-auth/src/platform-admin-gate.ts @@ -42,6 +42,20 @@ * which exists so a held user can remediate (change-password, two-factor * enrollment, sign-out) and was never a declaration about administration. * None of those remediation endpoints is judged here, so none changes. + * + * ## The same refusal at the vendor-served doors + * + * Three platform-admin doors are served by better-auth itself rather than by a + * raw mount, so this judge never runs there: the re-authorized impersonation + * endpoint (`admin-impersonate-endpoint.ts`), the SSO-provider registration + * before-hook, and the `admin` plugin's own role-scalar actions (both in + * `auth-manager.ts`'s global before-hook). Their session is a raw better-auth + * read that `customSession` never decorates, so it carries no `authGate`. + * Each door therefore asks the auth service for the acting user's posture — + * the same computation, and the same first entry, that `customSession` puts on + * a session (`resolveUserAuthGates`, ADR-0069 · #23287 ruling C′ item 3) — and + * hands it to {@link judgeHeldSession}, the refusal this judge gives. One + * posture computation, one decision, one refusal body, at every door. */ import { evaluateAuthGate } from '@objectstack/core'; @@ -106,6 +120,29 @@ export function isPlatformAdminUser(sessionUser: unknown): boolean { return u.role === 'admin'; } +/** + * ADR-0069 D3 — the refusal a held session meets on the platform-admin + * surface, or `null` when `sessionUser` holds no posture. + * + * `sessionUser` is anything carrying the session gate's posture carrier, + * `authGate` (the first entry of `resolveUserAuthGates`, as `customSession` + * stamps it). The decision is the gate's own, `evaluateAuthGate`, consulted + * pathless for the reason the module header gives, so every posture code holds, + * whatever its scope: a session IS the password sign-in (#23287, ruling C′ + * item 1), and the session gate refuses each code on every route outside its + * remediation allow-list. The body is the one {@link judgePlatformAdmin} + * answers: `403`, the posture's own `code` and `message` (its remediation + * pointer), in the ADR-0112 envelope. No code of its own. + */ +export function judgeHeldSession(sessionUser: unknown): PlatformAdminRefusal | null { + const held = evaluateAuthGate(sessionUser, ''); + if (!held) return null; + return { + status: 403, + body: { success: false, error: { code: held.code, message: held.message } }, + }; +} + /** * Judge a resolved session for a platform-admin-only route. * @@ -144,16 +181,8 @@ export function judgePlatformAdmin(session: unknown): PlatformAdminVerdict { } // ADR-0069 D3: the gate's shared decision, pathless (see the header). - const held = evaluateAuthGate(user, ''); - if (held) { - return { - ok: false, - refusal: { - status: 403, - body: { success: false, error: { code: held.code, message: held.message } }, - }, - }; - } + const held = judgeHeldSession(user); + if (held) return { ok: false, refusal: held }; if (!isPlatformAdminUser(user)) { return { From 675e95d7edd2e000007f41fabc873d5ff24a8ec0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 15:43:18 +0000 Subject: [PATCH 2/6] test(plugin-auth,verify): pin the held-caller refusal at the vendor-served platform-admin doors A booted pin per door (impersonation, SSO-provider registration, the admin plugin's role-scalar actions) in every held state, with the unheld controls, an organization-only MFA hold and the posture lookup's fail-open; a unit pin that classifies every admin-plugin route for a held caller; judgeHeldSession unit cases. The role-scalar branch skips the session read when no gate feature is active. Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy Co-authored-by: Claude --- ...n-impersonate-endpoint.held-caller.test.ts | 103 +++++ .../plugins/plugin-auth/src/auth-manager.ts | 6 +- .../platform-admin-gate.held-session.test.ts | 29 +- ...eld-session-vendor-admin-doors.pin.test.ts | 379 ++++++++++++++++++ 4 files changed, 514 insertions(+), 3 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/admin-impersonate-endpoint.held-caller.test.ts create mode 100644 packages/verify/src/held-session-vendor-admin-doors.pin.test.ts diff --git a/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.held-caller.test.ts b/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.held-caller.test.ts new file mode 100644 index 00000000000..4bada0395a9 --- /dev/null +++ b/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.held-caller.test.ts @@ -0,0 +1,103 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0069 D3 on the better-auth `admin` plugin's lane: every route the + * installed plugin registers is classified for a held caller, none forgotten. + * + * `VENDOR_ROLE_SCALAR_ADMIN_PATHS` (`admin-impersonate-endpoint.ts`) is the set + * the global before-hook holds: the routes the vendor serves itself and + * authorizes on a session plus the legacy `role` scalar. Every OTHER route the + * plugin registers must be accounted for below, with the reason it is not in + * that set. A vendor bump that adds a route turns this file red until someone + * decides whether a held caller may reach it — the decision is never made by + * omission, which is how a held platform admin kept reaching impersonation. + * + * The behaviour itself — each door refusing a held caller with the session + * gate's code, and the unheld controls — is pinned on a real boot in + * `@objectstack/verify` (`held-session-vendor-admin-doors.pin.test.ts`). + */ + +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it, expect } from 'vitest'; +import { admin } from 'better-auth/plugins/admin'; +import { buildAdminPluginSchema } from './auth-schema-config.js'; +import { VENDOR_ROLE_SCALAR_ADMIN_PATHS } from './admin-impersonate-endpoint.js'; +import { IMPERSONATE_USER_PATH, STOP_IMPERSONATING_PATH } from './impersonation-bearer-rotation.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +/** + * Paths an ObjectStack raw mount serves ahead of the vendor, after + * `judgePlatformAdmin` (which refuses a held session). Proven below by the + * mount's own registration line in `auth-plugin.ts`. + */ +const JUDGED_BY_AN_OBJECTSTACK_MOUNT: readonly string[] = [ + '/admin/create-user', + '/admin/set-user-password', + '/admin/ban-user', + '/admin/unban-user', + '/admin/remove-user', +]; + +/** Every other path, with the reason a held caller is not refused at the hook. */ +const NOT_HELD_AT_THE_HOOK: Readonly> = { + [IMPERSONATE_USER_PATH]: 'its own door: the re-authorized endpoint refuses a held caller in its handler', + [STOP_IMPERSONATING_PATH]: 'the exit from an impersonation, carried by the impersonated session (like sign-out)', + '/admin/has-permission': 'a question that changes nothing and refuses no one', +}; + +const installedAdminPaths = (): string[] => { + const plugin: any = admin({ schema: buildAdminPluginSchema() } as any); + return Object.values(plugin.endpoints as Record).map((e) => e.path).sort(); +}; + +describe('ADR-0069 D3 — the admin plugin lane, classified for a held caller', () => { + it('classifies every path the installed admin plugin registers, exactly once', () => { + const classified = [ + ...VENDOR_ROLE_SCALAR_ADMIN_PATHS, + ...JUDGED_BY_AN_OBJECTSTACK_MOUNT, + ...Object.keys(NOT_HELD_AT_THE_HOOK), + ]; + // Disjoint: a path in two classes is a decision made twice. + expect(new Set(classified).size).toBe(classified.length); + const unclassified = installedAdminPaths().filter((p) => !classified.includes(p)); + expect( + unclassified, + 'an admin-plugin route nobody decided for: hold it (VENDOR_ROLE_SCALAR_ADMIN_PATHS) or classify it here with its reason', + ).toEqual([]); + }); + + it('names no path the installed plugin does not serve (a stale entry holds nothing)', () => { + const installed = installedAdminPaths(); + const stale = [...VENDOR_ROLE_SCALAR_ADMIN_PATHS, ...JUDGED_BY_AN_OBJECTSTACK_MOUNT].filter( + (p) => !installed.includes(p), + ); + expect(stale).toEqual([]); + }); + + it('holds the vendor role-scalar actions the boot measured admitting a held caller', () => { + expect([...VENDOR_ROLE_SCALAR_ADMIN_PATHS].sort()).toEqual([ + '/admin/get-user', + '/admin/list-user-sessions', + '/admin/list-users', + '/admin/revoke-user-session', + '/admin/revoke-user-sessions', + '/admin/set-role', + '/admin/update-user', + ]); + }); + + it('every mount-judged path really is an ObjectStack mount that runs the judge first', () => { + const source = readFileSync(resolve(HERE, 'auth-plugin.ts'), 'utf8'); + for (const path of JUDGED_BY_AN_OBJECTSTACK_MOUNT) { + const mount = source.indexOf(`rawApp.post(\`\${basePath}${path}\``); + expect(mount, `no raw mount for ${path} in auth-plugin.ts`).toBeGreaterThan(-1); + // The judge is the mount's first act (within its opening lines). + expect(source.slice(mount, mount + 400), `${path}'s mount does not open with gateAdmin`).toMatch( + /await gateAdmin\(c\)/, + ); + } + }); +}); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index a0fe90c99ac..463197f18b4 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -2115,8 +2115,10 @@ export class AuthManager { // order the session gate applies. No resolvable session falls through // to the vendor's own 401. `VENDOR_ROLE_SCALAR_ADMIN_PATHS` // (`admin-impersonate-endpoint.ts`) classifies every other path the - // plugin registers. - if (VENDOR_ROLE_SCALAR_ADMIN_PATHS.has(ctx?.path)) { + // plugin registers. With no gate feature active the posture holds no + // one, so the session read is skipped, as the transport seams skip it + // (`isAuthGateActive`, the same caches the computation reads). + if (VENDOR_ROLE_SCALAR_ADMIN_PATHS.has(ctx?.path) && this.isAuthGateActive()) { const actor = await this.resolveActor(ctx); if (actor?.userId) await this.assertActorNotHeld(actor); // fall through — the vendor still authorizes everything it owns diff --git a/packages/plugins/plugin-auth/src/platform-admin-gate.held-session.test.ts b/packages/plugins/plugin-auth/src/platform-admin-gate.held-session.test.ts index 4993a90fa1f..57bca00f98a 100644 --- a/packages/plugins/plugin-auth/src/platform-admin-gate.held-session.test.ts +++ b/packages/plugins/plugin-auth/src/platform-admin-gate.held-session.test.ts @@ -12,7 +12,7 @@ */ import { describe, it, expect } from 'vitest'; -import { judgePlatformAdmin } from './platform-admin-gate.js'; +import { judgeHeldSession, judgePlatformAdmin } from './platform-admin-gate.js'; const ADMIN = { id: 'usr_admin', email: 'admin@example.com', isPlatformAdmin: true, role: 'user' }; const MEMBER = { id: 'usr_member', positions: ['user'], role: 'user' }; @@ -66,3 +66,30 @@ describe('judgePlatformAdmin: a held session (ADR-0069 D3)', () => { expect(judgePlatformAdmin({ user: { ...ADMIN, authGate: { code: 7 } } }).ok).toBe(true); }); }); + +describe('judgeHeldSession: the refusal the vendor-served doors reuse (ADR-0069 D3)', () => { + const RESET_REQUIRED = { code: 'PASSWORD_RESET_REQUIRED', message: 'You must change your password.' }; + + for (const gate of [PASSWORD_EXPIRED, MFA_REQUIRED, RESET_REQUIRED]) { + it(`refuses the ${gate.code} posture whatever its scope, in the judge's envelope`, () => { + // A session IS the password sign-in, so a `credential`-scope posture holds + // it as an `account`-scope one does: the session gate's verdict. + expect(judgeHeldSession({ authGate: gate })).toEqual({ + status: 403, + body: { success: false, error: { code: gate.code, message: gate.message } }, + }); + // Byte-identical to the judge's held refusal on the raw mounts. + const verdict = judgePlatformAdmin({ user: { ...ADMIN, authGate: gate } }); + expect(!verdict.ok && verdict.refusal).toEqual(judgeHeldSession({ authGate: gate })); + }); + } + + it('holds no one without a posture (the computation answered no entry)', () => { + expect(judgeHeldSession({ authGate: undefined })).toBeNull(); + expect(judgeHeldSession({})).toBeNull(); + }); + + it('holds no one on a code outside the closed posture vocabulary', () => { + expect(judgeHeldSession({ authGate: { code: 'NOT_A_POSTURE', message: 'x' } })).toBeNull(); + }); +}); diff --git a/packages/verify/src/held-session-vendor-admin-doors.pin.test.ts b/packages/verify/src/held-session-vendor-admin-doors.pin.test.ts new file mode 100644 index 00000000000..ff6812bc03b --- /dev/null +++ b/packages/verify/src/held-session-vendor-admin-doors.pin.test.ts @@ -0,0 +1,379 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0069 D3 at the platform-admin doors better-auth serves itself, on a real + * boot. + * + * A held session (an expired or administrator-forced password; enforced MFA + * not yet enrolled, globally or by the organization the session operates in) + * is gated out of every protected resource until it remediates. The raw + * platform-admin mounts already refuse it (`judgePlatformAdmin`, + * `held-session-platform-admin.pin.test.ts`). Three doors never reach that + * judge, and nothing on their path read the posture. Measured on this + * composition before the fix, in every held state: + * + * - **impersonation** (`/admin/impersonate-user`, re-authorized in + * `admin-impersonate-endpoint.ts`): a held platform admin answered `200` + * with a session for the target, and that session is not held, so the hold + * was bypassed outright; + * - **SSO-provider registration** (`/sso/register`'s before-hook): a held + * platform admin answered `200` and the provider row landed; + * - **the `admin` plugin's role-scalar actions** (`VENDOR_ROLE_SCALAR_ADMIN_PATHS`): + * a held caller carrying the legacy `role = 'admin'` scalar was admitted to + * every one, writes included. + * + * Each door now asks the session gate's own posture carrier (the first entry + * of `resolveUserAuthGates`) for the acting user in the session's organization + * and refuses a held one with the refusal the session gate and the judge give. + * What this file pins, per door and per held state: `403`, `success: false`, + * the posture's own code, and the effect NEVER landing (a bare status would + * stay green on a refusal raised after the write). Beside them: the unheld + * controls (each door still works), an organization-only MFA requirement + * holding, a held non-admin reading the posture first, and a failing posture + * lookup failing open at the door exactly as it does for the session. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack } from './harness.js'; + +const app = { + manifest: { + id: 'com.example.held-vendor-admin-doors', + namespace: 'heldvendoradmindoors', + version: '0.0.1', + type: 'app', + name: 'Held Vendor Admin Doors Fixture', + }, + objects: [], +}; + +const BOOT_TIMEOUT = 180_000; +const SYS = { context: { isSystem: true } }; +const DAY_MS = 86_400_000; + +const MEMBER_EMAIL = 'held-vendor-doors-member@example.com'; +const MEMBER_PASSWORD = 'HeldVendorDoors!Member1'; +const SCALAR_EMAIL = 'held-vendor-doors-scalar@example.com'; +const SCALAR_PASSWORD = 'HeldVendorDoors!Scalar1'; + +/** + * Read when the auth instance is built, so they precede the boot. + * `OS_SCIM_ENABLED` turns the better-auth `admin` plugin on (SCIM forces it, + * ADR-0134) — the plugin that serves impersonation and the role-scalar actions; + * `OS_SSO_ENABLED` mounts `@better-auth/sso`'s `/sso/register`. + */ +const ENV = { OS_SCIM_ENABLED: 'true', OS_SSO_ENABLED: 'true' } as const; + +/** The `admin` plugin routes the vendor authorizes on the role scalar (`VENDOR_ROLE_SCALAR_ADMIN_PATHS`). */ +const ROLE_SCALAR_CALLS: ReadonlyArray unknown]> = [ + ['POST', '/auth/admin/set-role', (id) => ({ userId: id, role: 'user' })], + ['GET', '/auth/admin/get-user?id=MEMBER', () => undefined], + ['POST', '/auth/admin/update-user', (id) => ({ userId: id, data: { name: 'renamed-by-held-caller' } })], + ['GET', '/auth/admin/list-users?limit=1', () => undefined], + ['POST', '/auth/admin/list-user-sessions', (id) => ({ userId: id })], + ['POST', '/auth/admin/revoke-user-session', () => ({ sessionToken: 'no-such-session-token' })], + ['POST', '/auth/admin/revoke-user-sessions', (id) => ({ userId: id })], +]; + +let stack: Awaited>; +let ql: any; +let auth: any; +let adminId: string; +let memberId: string; +let scalarId: string; +let providerSeq = 0; +const savedEnv: Record = {}; + +beforeAll(async () => { + for (const [k, v] of Object.entries(ENV)) { + savedEnv[k] = process.env[k]; + process.env[k] = v; + } + stack = await bootStack(app); + ql = await stack.kernel.getServiceAsync('objectql'); + auth = await stack.kernel.getServiceAsync('auth'); + adminId = (await sessionOf(await stack.signIn())).userId; + memberId = (await sessionOf(await stack.signUp(MEMBER_EMAIL, MEMBER_PASSWORD))).userId; + scalarId = (await sessionOf(await stack.signUp(SCALAR_EMAIL, SCALAR_PASSWORD))).userId; + // A pre-ADR-0068 deployment's admin: the legacy scalar the vendor reads. + await ql.update('sys_user', { id: scalarId, role: 'admin' }, SYS); +}, BOOT_TIMEOUT); + +afterAll(async () => { + await stack?.stop(); + for (const [k, v] of Object.entries(savedEnv)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } +}, BOOT_TIMEOUT); + +async function sessionOf(token: string): Promise<{ userId: string; organizationId?: string }> { + const res = await stack.apiAs(token, 'GET', '/auth/get-session'); + const body = (await res.json()) as { user?: { id?: string }; session?: { activeOrganizationId?: string } }; + if (!body.user?.id) throw new Error(`get-session answered ${res.status} with no user`); + return { userId: body.user.id, organizationId: body.session?.activeOrganizationId ?? undefined }; +} + +const signInScalar = () => stack.signIn(SCALAR_EMAIL, SCALAR_PASSWORD); +const signInMember = () => stack.signIn(MEMBER_EMAIL, MEMBER_PASSWORD); + +/** Impersonation sessions `actorId` has minted so far. */ +async function impersonationsBy(actorId: string): Promise { + return ql.count('sys_session', { where: { impersonated_by: actorId } }, SYS); +} + +function impersonate(token: string, userId: string) { + return stack.apiAs(token, 'POST', '/auth/admin/impersonate-user', { userId }); +} + +/** A complete OIDC registration that lands offline (`skipDiscovery`), under a fresh provider id. */ +function registerProvider(token: string): { providerId: string; res: Promise } { + const providerId = `held-vendor-doors-${++providerSeq}`; + const res = stack.apiAs(token, 'POST', '/auth/sso/register', { + providerId, + issuer: 'https://idp.example.com', + domain: `${providerId}.example.com`, + oidcConfig: { + clientId: 'client-id', + clientSecret: 'client-secret', + skipDiscovery: true, + authorizationEndpoint: 'https://idp.example.com/authorize', + tokenEndpoint: 'https://idp.example.com/token', + jwksEndpoint: 'https://idp.example.com/jwks', + scopes: ['openid', 'email', 'profile'], + mapping: { email: 'email', name: 'name' }, + }, + }); + return { providerId, res }; +} + +async function providerRows(providerId: string): Promise { + return ql.count('sys_sso_provider', { where: { provider_id: providerId } }, SYS); +} + +async function memberName(): Promise { + const row = await ql.findOne('sys_user', { where: { id: memberId }, fields: ['name'] }, SYS); + return row?.name ?? null; +} + +function updateMemberName(token: string, name: string) { + return stack.apiAs(token, 'POST', '/auth/admin/update-user', { userId: memberId, data: { name } }); +} + +/** The session gate's refusal, as every door here must answer it. */ +async function expectHeldRefusal(res: Response, code: string): Promise { + expect(res.status).toBe(403); + const body = (await res.json()) as { success?: boolean; error?: { code?: string; message?: string } }; + expect(body.success).toBe(false); + expect(body.error?.code).toBe(code); +} + +/** Armed, or every refusal below would be vacuous: the protected data read answers the code. */ +async function expectArmed(token: string, code: string): Promise { + const read = await stack.apiAs(token, 'GET', '/data/sys_user?limit=1'); + const body = (await read.json()) as { error?: { code?: string } }; + if (read.status !== 403 || body.error?.code !== code) { + throw new Error( + `the ${code} hold is not armed: the protected read answered ${read.status} ` + + `${JSON.stringify(body)}, so no refusal in this block would test anything`, + ); + } +} + +const everyone = () => [adminId, memberId, scalarId]; + +describe('controls: unheld callers still pass every door', () => { + it('a platform admin impersonates (200, the session is the target, one impersonation session)', async () => { + const before = await impersonationsBy(adminId); + const res = await impersonate(await stack.signIn(), memberId); + expect(res.status).toBe(200); + const body = (await res.json()) as { user?: { id?: string } }; + expect(body.user?.id).toBe(memberId); + expect(await impersonationsBy(adminId)).toBe(before + 1); + }); + + it('a platform admin registers an SSO provider (200, the row lands)', async () => { + const { providerId, res } = registerProvider(await stack.signIn()); + expect((await res).status).toBe(200); + expect(await providerRows(providerId)).toBe(1); + }); + + it('a role-scalar admin updates a user (200, the write lands)', async () => { + const res = await updateMemberName(await signInScalar(), 'renamed-by-unheld-scalar'); + expect(res.status).toBe(200); + expect(await memberName()).toBe('renamed-by-unheld-scalar'); + }); +}); + +interface HeldState { + readonly code: 'PASSWORD_EXPIRED' | 'MFA_REQUIRED' | 'PASSWORD_RESET_REQUIRED'; + arm(): Promise; + disarm(): Promise; +} + +const HELD_STATES: readonly HeldState[] = [ + { + code: 'PASSWORD_EXPIRED', + async arm() { + auth.applyConfigPatch({ passwordExpiryDays: 1 }); + const stale = new Date(Date.now() - 10 * DAY_MS); + for (const id of everyone()) await ql.update('sys_user', { id, password_changed_at: stale }, SYS); + }, + async disarm() { + auth.applyConfigPatch({ passwordExpiryDays: 0 }); + for (const id of everyone()) await ql.update('sys_user', { id, password_changed_at: new Date() }, SYS); + }, + }, + { + code: 'MFA_REQUIRED', + async arm() { + auth.applyConfigPatch({ mfaRequired: true, mfaGracePeriodDays: 0 }); + const lapsed = new Date(Date.now() - 10 * DAY_MS); + for (const id of everyone()) await ql.update('sys_user', { id, mfa_required_at: lapsed }, SYS); + }, + async disarm() { + auth.applyConfigPatch({ mfaRequired: false }); + }, + }, + { + code: 'PASSWORD_RESET_REQUIRED', + async arm() { + for (const id of everyone()) await ql.update('sys_user', { id, must_change_password: true }, SYS); + // What the administrator's forced-reset route does after setting the flag. + auth.noteMustChangePasswordIssued(); + }, + async disarm() { + for (const id of everyone()) await ql.update('sys_user', { id, must_change_password: false }, SYS); + }, + }, +]; + +for (const state of HELD_STATES) { + describe(`a held caller (${state.code})`, () => { + beforeAll(async () => { + await state.arm(); + await expectArmed(await stack.signIn(), state.code); + }, 60_000); + + afterAll(async () => { + await state.disarm(); + }); + + it(`refuses a platform admin's impersonation 403 ${state.code}, and no session is minted`, async () => { + const before = await impersonationsBy(adminId); + await expectHeldRefusal(await impersonate(await stack.signIn(), memberId), state.code); + expect(await impersonationsBy(adminId)).toBe(before); + }); + + it(`refuses a platform admin's SSO-provider registration 403 ${state.code}, and no row lands`, async () => { + const { providerId, res } = registerProvider(await stack.signIn()); + await expectHeldRefusal(await res, state.code); + expect(await providerRows(providerId)).toBe(0); + }); + + it(`refuses a role-scalar admin's user update 403 ${state.code}, and the write never lands`, async () => { + const before = await memberName(); + await expectHeldRefusal(await updateMemberName(await signInScalar(), `renamed-while-${state.code}`), state.code); + expect(await memberName()).toBe(before); + }); + + it(`refuses a role-scalar admin on every role-scalar action 403 ${state.code}`, async () => { + const token = await signInScalar(); + for (const [method, path, body] of ROLE_SCALAR_CALLS) { + const res = await stack.apiAs(token, method, path.replace('MEMBER', memberId), body(memberId)); + await expectHeldRefusal(res, state.code); + } + }); + + it(`answers a held non-admin at the impersonation door with the posture first (${state.code})`, async () => { + await expectHeldRefusal(await impersonate(await signInMember(), scalarId), state.code); + }); + }); +} + +describe("a hold only the session's organization declares (MFA required by the organization)", () => { + let organizationId: string | undefined; + + beforeAll(async () => { + organizationId = (await sessionOf(await stack.signIn())).organizationId; + if (!organizationId) throw new Error('the platform admin session operates in no organization'); + await ql.update('sys_organization', { id: organizationId, require_mfa: true }, SYS); + // The 60-second "does any organization require MFA" cache, primed as the + // background refresh would after this write. + auth._orgMfaCache = { value: true, at: Date.now() }; + const lapsed = new Date(Date.now() - 10 * DAY_MS); + for (const id of everyone()) await ql.update('sys_user', { id, mfa_required_at: lapsed }, SYS); + await expectArmed(await stack.signIn(), 'MFA_REQUIRED'); + }, 60_000); + + afterAll(async () => { + if (organizationId) await ql.update('sys_organization', { id: organizationId, require_mfa: false }, SYS); + auth._orgMfaCache = { value: false, at: Date.now() }; + }); + + it('holds the admin in that organization and in no other reading (the floor holds no one)', async () => { + const inOrg = await auth.resolveUserAuthGates(adminId, { organizationId }); + expect(inOrg.map((g: { code: string }) => g.code)).toEqual(['MFA_REQUIRED']); + expect(await auth.resolveUserAuthGates(adminId, {})).toEqual([]); + }); + + it('refuses the impersonation 403 MFA_REQUIRED, and no session is minted', async () => { + const before = await impersonationsBy(adminId); + await expectHeldRefusal(await impersonate(await stack.signIn(), memberId), 'MFA_REQUIRED'); + expect(await impersonationsBy(adminId)).toBe(before); + }); + + it('refuses the SSO-provider registration 403 MFA_REQUIRED, and no row lands', async () => { + const { providerId, res } = registerProvider(await stack.signIn()); + await expectHeldRefusal(await res, 'MFA_REQUIRED'); + expect(await providerRows(providerId)).toBe(0); + }); + + it('refuses a role-scalar admin user update 403 MFA_REQUIRED, and the write never lands', async () => { + const before = await memberName(); + await expectHeldRefusal(await updateMemberName(await signInScalar(), 'renamed-under-org-mfa'), 'MFA_REQUIRED'); + expect(await memberName()).toBe(before); + }); +}); + +describe('a posture lookup that fails holds no one, at the door as for the session', () => { + const state = HELD_STATES.find((s) => s.code === 'MFA_REQUIRED')!; + let engine: any; + let original: unknown; + let hadOwn = false; + + beforeAll(async () => { + await state.arm(); + await expectArmed(await stack.signIn(), 'MFA_REQUIRED'); + // Fail exactly the posture computation's own read of the user row. + engine = auth.getDataEngine(); + hadOwn = Object.prototype.hasOwnProperty.call(engine, 'findOne'); + original = engine.findOne; + engine.findOne = function (object: string, query: any, ...rest: unknown[]) { + if (object === 'sys_user' && Array.isArray(query?.fields) && query.fields.includes('mfa_required_at')) { + return Promise.reject(new Error('posture store unavailable (pin)')); + } + return (original as (...a: unknown[]) => unknown).call(this, object, query, ...rest); + }; + }, 60_000); + + afterAll(async () => { + if (hadOwn) engine.findOne = original; + else delete engine.findOne; + await state.disarm(); + }); + + it('the computation answers [] for the held admin (its declared fail-open)', async () => { + const { organizationId } = await sessionOf(await stack.signIn()); + expect(await auth.resolveUserAuthGates(adminId, { organizationId })).toEqual([]); + }); + + it('the session gate admits the data read, and the impersonation door admits the admin (200)', async () => { + const token = await stack.signIn(); + expect((await stack.apiAs(token, 'GET', '/data/sys_user?limit=1')).status).toBe(200); + const before = await impersonationsBy(adminId); + const res = await impersonate(token, memberId); + expect(res.status).toBe(200); + expect(await impersonationsBy(adminId)).toBe(before + 1); + }); +}); From 6a2a58a53fddcc4b5f184aa577204af2f05b0603 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 15:48:52 +0000 Subject: [PATCH 3/6] chore(changeset): plugin-auth minor, the vendor-served platform-admin doors refuse a held caller Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy Co-authored-by: Claude --- .../23335-held-admin-vendor-doors-refused.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .changeset/23335-held-admin-vendor-doors-refused.md diff --git a/.changeset/23335-held-admin-vendor-doors-refused.md b/.changeset/23335-held-admin-vendor-doors-refused.md new file mode 100644 index 00000000000..e00d1980225 --- /dev/null +++ b/.changeset/23335-held-admin-vendor-doors-refused.md @@ -0,0 +1,25 @@ +--- +'@objectstack/plugin-auth': minor +--- + +fix(plugin-auth)!: impersonation, SSO-provider registration and the admin plugin's role-scalar actions refuse a caller the ADR-0069 authentication policy holds + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +ADR-0069 D3 holds a signed-in user out of every protected resource while their password has expired (or an administrator has forced a change) or while enforced MFA is not yet enrolled, until they remediate. REST, the runtime dispatcher and the platform-admin actions plugin-auth mounts on the host server already refuse such a user. Three doors better-auth serves itself did not, because their session is a raw read that never carries the posture: + +- **Impersonation** (`POST /admin/impersonate-user`): a held platform administrator could mint a session as another user, and that session is not held, so the hold was bypassed outright. +- **SSO-provider registration** (`POST /sso/register`): a held platform administrator could register an identity provider. +- **The admin plugin's own actions, judged on the legacy `role` scalar** (set-role, get-user, update-user, list-users, list-user-sessions, revoke-user-session, revoke-user-sessions): a held caller carrying `role = 'admin'` was admitted, writes included. + +From → to. Before: a held caller passed these doors as if unheld. Now: each door asks the auth service for the caller's posture in the organization their session operates in (the same computation, and the same first entry, the session carries) and refuses a held caller with `403`, `{ success: false, error: { code, message } }`, where `code` is the posture's own (`PASSWORD_EXPIRED`, `MFA_REQUIRED` or `PASSWORD_RESET_REQUIRED`) and `message` names the remedy: the refusal the session gate and `judgePlatformAdmin` already give. No new error code. + +- **Order:** an anonymous caller still meets the vendor's `401` first. A held signed-in caller who is not an administrator now meets the posture's `403` code instead of the vendor's `403 YOU_ARE_NOT_ALLOWED_*`: same status, the remedy named. +- **Unchanged:** a caller the policy does not hold is served exactly as before. Stopping an impersonation and the admin plugin's permission question are not held, and sign-in, change-password, two-factor enrollment and sign-out still answer a held session, so remediation works as before. A posture lookup that fails holds no one, as it does for the session. +- **New export:** `judgeHeldSession` (`@objectstack/plugin-auth`), the held refusal `judgePlatformAdmin` gives, now shared with these doors. + +What changes for you. An administrator the policy holds must remediate (change the password, or enroll an authenticator) before impersonating a user, registering an SSO provider or using the admin plugin's user actions, as on every other protected route. From beebec8f3c1adc31eb931318374e1b6210d47d0a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 16:13:23 +0000 Subject: [PATCH 4/6] fix(plugin-auth): a held provider owner cannot rewrite or delete an SSO provider either (ADR-0069 D3) Measured on a real boot: a held platform admin who owned a provider rewrote its issuer (200, landed) and deleted another (200, row gone) through @better-auth/sso's update-provider and delete-provider. The before-hook now refuses a held caller there with the same refusal as registration. Pinned per held state beside the registration door. Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy Co-authored-by: Claude --- .../23335-held-admin-vendor-doors-refused.md | 8 ++-- .../plugins/plugin-auth/src/auth-manager.ts | 23 ++++++++++- .../plugin-auth/src/platform-admin-gate.ts | 6 +-- ...eld-session-vendor-admin-doors.pin.test.ts | 38 ++++++++++++++++++- 4 files changed, 65 insertions(+), 10 deletions(-) diff --git a/.changeset/23335-held-admin-vendor-doors-refused.md b/.changeset/23335-held-admin-vendor-doors-refused.md index e00d1980225..f64ca5769d3 100644 --- a/.changeset/23335-held-admin-vendor-doors-refused.md +++ b/.changeset/23335-held-admin-vendor-doors-refused.md @@ -2,18 +2,18 @@ '@objectstack/plugin-auth': minor --- -fix(plugin-auth)!: impersonation, SSO-provider registration and the admin plugin's role-scalar actions refuse a caller the ADR-0069 authentication policy holds +fix(plugin-auth)!: impersonation, SSO-provider administration and the admin plugin's role-scalar actions refuse a caller the ADR-0069 authentication policy holds Clause-②: no (narrowing) - + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. ADR-0069 D3 holds a signed-in user out of every protected resource while their password has expired (or an administrator has forced a change) or while enforced MFA is not yet enrolled, until they remediate. REST, the runtime dispatcher and the platform-admin actions plugin-auth mounts on the host server already refuse such a user. Three doors better-auth serves itself did not, because their session is a raw read that never carries the posture: - **Impersonation** (`POST /admin/impersonate-user`): a held platform administrator could mint a session as another user, and that session is not held, so the hold was bypassed outright. -- **SSO-provider registration** (`POST /sso/register`): a held platform administrator could register an identity provider. +- **SSO-provider administration** (`POST /sso/register`, `/sso/update-provider`, `/sso/delete-provider`): a held platform administrator could register an identity provider, and rewrite the issuer of, or delete, one they own. - **The admin plugin's own actions, judged on the legacy `role` scalar** (set-role, get-user, update-user, list-users, list-user-sessions, revoke-user-session, revoke-user-sessions): a held caller carrying `role = 'admin'` was admitted, writes included. From → to. Before: a held caller passed these doors as if unheld. Now: each door asks the auth service for the caller's posture in the organization their session operates in (the same computation, and the same first entry, the session carries) and refuses a held caller with `403`, `{ success: false, error: { code, message } }`, where `code` is the posture's own (`PASSWORD_EXPIRED`, `MFA_REQUIRED` or `PASSWORD_RESET_REQUIRED`) and `message` names the remedy: the refusal the session gate and `judgePlatformAdmin` already give. No new error code. @@ -22,4 +22,4 @@ From → to. Before: a held caller passed these doors as if unheld. Now: each do - **Unchanged:** a caller the policy does not hold is served exactly as before. Stopping an impersonation and the admin plugin's permission question are not held, and sign-in, change-password, two-factor enrollment and sign-out still answer a held session, so remediation works as before. A posture lookup that fails holds no one, as it does for the session. - **New export:** `judgeHeldSession` (`@objectstack/plugin-auth`), the held refusal `judgePlatformAdmin` gives, now shared with these doors. -What changes for you. An administrator the policy holds must remediate (change the password, or enroll an authenticator) before impersonating a user, registering an SSO provider or using the admin plugin's user actions, as on every other protected route. +What changes for you. An administrator the policy holds must remediate (change the password, or enroll an authenticator) before impersonating a user, registering or changing an SSO provider or using the admin plugin's user actions, as on every other protected route. diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 463197f18b4..74afc606ae2 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -2142,6 +2142,24 @@ export class AuthManager { // fall through — the vendor still performs the revoke itself } + // ── ADR-0069 D3: nor may a held caller rewrite or delete one ────── + // `@better-auth/sso` lets a provider's OWNER (who, past the + // registration gate below, is a platform admin) update its issuer, + // endpoints and secrets or delete it, on a session alone. Measured on + // a real boot: a held platform admin who owned a provider rewrote its + // issuer to another host (200, landed) and deleted another (200, row + // gone) — login routing, rewritten by a caller the policy holds. The + // same refusal as registration, asked the same way; the vendor still + // decides who may manage the provider. + if ( + (ctx?.path === '/sso/update-provider' || ctx?.path === '/sso/delete-provider') && + this.isAuthGateActive() + ) { + const actor = await this.resolveActor(ctx); + if (actor?.userId) await this.assertActorNotHeld(actor); + // fall through — the vendor's own provider-access check still runs + } + // ── ADR-0135 D6 + ADR-0068 D4: registering an identity provider is ── // a PLATFORM-OPERATOR action. // @@ -7223,8 +7241,9 @@ export class AuthManager { } /** - * ADR-0069 D3 at a vendor-served platform-admin door (the SSO-provider - * registration hook, the `admin` plugin's role-scalar actions): refuse + * ADR-0069 D3 at a vendor-served platform-admin door (SSO-provider + * registration, update and delete; the `admin` plugin's role-scalar + * actions): refuse * `actor` when the authentication policy holds them, with the refusal the * session gate gives, and return otherwise. * diff --git a/packages/plugins/plugin-auth/src/platform-admin-gate.ts b/packages/plugins/plugin-auth/src/platform-admin-gate.ts index 4fbe016c405..9df0358138b 100644 --- a/packages/plugins/plugin-auth/src/platform-admin-gate.ts +++ b/packages/plugins/plugin-auth/src/platform-admin-gate.ts @@ -47,9 +47,9 @@ * * Three platform-admin doors are served by better-auth itself rather than by a * raw mount, so this judge never runs there: the re-authorized impersonation - * endpoint (`admin-impersonate-endpoint.ts`), the SSO-provider registration - * before-hook, and the `admin` plugin's own role-scalar actions (both in - * `auth-manager.ts`'s global before-hook). Their session is a raw better-auth + * endpoint (`admin-impersonate-endpoint.ts`), SSO-provider administration + * (registration, and an owner's update and delete), and the `admin` plugin's + * own role-scalar actions (both in `auth-manager.ts`'s global before-hook). Their session is a raw better-auth * read that `customSession` never decorates, so it carries no `authGate`. * Each door therefore asks the auth service for the acting user's posture — * the same computation, and the same first entry, that `customSession` puts on diff --git a/packages/verify/src/held-session-vendor-admin-doors.pin.test.ts b/packages/verify/src/held-session-vendor-admin-doors.pin.test.ts index ff6812bc03b..60e8b57e712 100644 --- a/packages/verify/src/held-session-vendor-admin-doors.pin.test.ts +++ b/packages/verify/src/held-session-vendor-admin-doors.pin.test.ts @@ -17,7 +17,9 @@ * with a session for the target, and that session is not held, so the hold * was bypassed outright; * - **SSO-provider registration** (`/sso/register`'s before-hook): a held - * platform admin answered `200` and the provider row landed; + * platform admin answered `200` and the provider row landed; and the + * provider's owner (a platform admin) could still rewrite its issuer + * (`/sso/update-provider`) and delete it (`/sso/delete-provider`); * - **the `admin` plugin's role-scalar actions** (`VENDOR_ROLE_SCALAR_ADMIN_PATHS`): * a held caller carrying the legacy `role = 'admin'` scalar was admitted to * every one, writes included. @@ -82,6 +84,8 @@ let adminId: string; let memberId: string; let scalarId: string; let providerSeq = 0; +/** A provider the platform admin registered while unheld, so the admin OWNS it. */ +let ownedProviderId: string; const savedEnv: Record = {}; beforeAll(async () => { @@ -97,6 +101,10 @@ beforeAll(async () => { scalarId = (await sessionOf(await stack.signUp(SCALAR_EMAIL, SCALAR_PASSWORD))).userId; // A pre-ADR-0068 deployment's admin: the legacy scalar the vendor reads. await ql.update('sys_user', { id: scalarId, role: 'admin' }, SYS); + const owned = registerProvider(await stack.signIn()); + ownedProviderId = owned.providerId; + const landed = await owned.res; + if (landed.status !== 200) throw new Error(`the owned provider did not register: ${landed.status} ${await landed.text()}`); }, BOOT_TIMEOUT); afterAll(async () => { @@ -151,6 +159,19 @@ async function providerRows(providerId: string): Promise { return ql.count('sys_sso_provider', { where: { provider_id: providerId } }, SYS); } +async function providerIssuer(providerId: string): Promise { + const row = await ql.findOne('sys_sso_provider', { where: { provider_id: providerId }, fields: ['issuer'] }, SYS); + return row?.issuer ?? null; +} + +function updateProviderIssuer(token: string, issuer: string) { + return stack.apiAs(token, 'POST', '/auth/sso/update-provider', { providerId: ownedProviderId, issuer }); +} + +function deleteOwnedProvider(token: string) { + return stack.apiAs(token, 'POST', '/auth/sso/delete-provider', { providerId: ownedProviderId }); +} + async function memberName(): Promise { const row = await ql.findOne('sys_user', { where: { id: memberId }, fields: ['name'] }, SYS); return row?.name ?? null; @@ -198,6 +219,12 @@ describe('controls: unheld callers still pass every door', () => { expect(await providerRows(providerId)).toBe(1); }); + it("a provider's owner rewrites its issuer (200, the write lands)", async () => { + const res = await updateProviderIssuer(await stack.signIn(), 'https://idp-rotated.example.com'); + expect(res.status).toBe(200); + expect(await providerIssuer(ownedProviderId)).toBe('https://idp-rotated.example.com'); + }); + it('a role-scalar admin updates a user (200, the write lands)', async () => { const res = await updateMemberName(await signInScalar(), 'renamed-by-unheld-scalar'); expect(res.status).toBe(200); @@ -271,6 +298,15 @@ for (const state of HELD_STATES) { expect(await providerRows(providerId)).toBe(0); }); + it(`refuses a provider owner's issuer rewrite and delete 403 ${state.code}; the provider stays`, async () => { + const token = await stack.signIn(); + const before = await providerIssuer(ownedProviderId); + await expectHeldRefusal(await updateProviderIssuer(token, 'https://idp.attacker.example.net'), state.code); + expect(await providerIssuer(ownedProviderId)).toBe(before); + await expectHeldRefusal(await deleteOwnedProvider(token), state.code); + expect(await providerRows(ownedProviderId)).toBe(1); + }); + it(`refuses a role-scalar admin's user update 403 ${state.code}, and the write never lands`, async () => { const before = await memberName(); await expectHeldRefusal(await updateMemberName(await signInScalar(), `renamed-while-${state.code}`), state.code); From 14edf89222ad3f7f50f43318cfbecefd756e0b05 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 16:29:52 +0000 Subject: [PATCH 5/6] refactor(plugin-auth): hand APIError the judge's refusal record by name check:dispatcher-error-vocabulary read `held.body` as a member of an imported code table and reported it unresolved. The record is the judge's own object literal (judgeHeldSession), whose `code` is the posture from the closed AuthGateCodeSchema vocabulary; bind it locally and hand that over. Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy Co-authored-by: Claude --- .../plugins/plugin-auth/src/admin-impersonate-endpoint.ts | 7 ++++++- packages/plugins/plugin-auth/src/auth-manager.ts | 5 ++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.ts b/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.ts index d3cafc40dea..4e2afe6184b 100644 --- a/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.ts +++ b/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.ts @@ -291,7 +291,12 @@ export async function applyPlatformAdminImpersonation( const held = judgeHeldSession({ authGate: await postureOf(callerId, typeof activeOrgId === 'string' && activeOrgId ? activeOrgId : undefined), }); - if (held) throw new APIError('FORBIDDEN', held.body); + if (held) { + // The judge's own record: its object literal (`judgeHeldSession`) + // carries the posture `code`, from the closed `AuthGateCodeSchema`. + const { body } = held; + throw new APIError('FORBIDDEN', body); + } } // ── THE changed predicate (1/2): who may impersonate ───────────────── diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 74afc606ae2..30555c15d1e 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -7265,7 +7265,10 @@ export class AuthManager { }); if (!held) return; const { APIError } = await import('better-auth/api'); - throw new APIError('FORBIDDEN', held.body); + // The judge's own record: its object literal (`judgeHeldSession`) carries + // the posture `code`, from the closed `AuthGateCodeSchema` vocabulary. + const { body } = held; + throw new APIError('FORBIDDEN', body); } /** From e865d3ebd2c4979683b5df0be65a7feef4b3f940 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 17:05:43 +0000 Subject: [PATCH 6/6] =?UTF-8?q?chore(changeset):=20plugin-auth's=20held-ca?= =?UTF-8?q?ller=20changeset=20declares=20Clause-=E2=91=A1=20yes=20(narrowi?= =?UTF-8?q?ng)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changeset ships one new public export, judgeHeldSession, so the declaration is yes (narrowing); the seat amended the claim and the PR body to match. Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy Co-authored-by: Claude --- .changeset/23335-held-admin-vendor-doors-refused.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/23335-held-admin-vendor-doors-refused.md b/.changeset/23335-held-admin-vendor-doors-refused.md index f64ca5769d3..e77a768c44c 100644 --- a/.changeset/23335-held-admin-vendor-doors-refused.md +++ b/.changeset/23335-held-admin-vendor-doors-refused.md @@ -4,7 +4,7 @@ fix(plugin-auth)!: impersonation, SSO-provider administration and the admin plugin's role-scalar actions refuse a caller the ADR-0069 authentication policy holds -Clause-②: no (narrowing) +Clause-②: yes (narrowing)