Skip to content
25 changes: 25 additions & 0 deletions .changeset/23335-held-admin-vendor-doors-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@objectstack/plugin-auth': minor
---

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-②: yes (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) A runtime authentication narrowing at three doors better-auth serves inside plugin-auth (the re-authorized impersonation endpoint, SSO-provider registration, update and delete, and the admin plugin's own role-scalar actions), not a metadata change. No spec key, option, config field, response field or stored shape is removed, renamed or re-shaped, and a caller the policy does not hold receives the same answers as before, so there is no tombstone and nothing for `objectstack migrate meta` to rewrite. What narrows is which callers these doors serve: a user who holds an ADR-0069 posture (an expired or administrator-forced password, or enforced MFA not yet enrolled, globally or by the organization the session operates in) is now refused with the session gate's 403 before the door acts. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers these doors and this diff adds none (not registered / already-registered); the one added export is an addition, so nothing published is retired or re-typed (not runtime-interface-only / type-surface-only / runtime-export-retired). -->

**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 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.

- **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 or changing an SSO provider or using the admin plugin's user actions, as on every other protected route.
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>> = {
[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<string, { path: string }>).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\)/,
);
}
});
});
99 changes: 98 additions & 1 deletion packages/plugins/plugin-auth/src/admin-impersonate-endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -118,6 +147,57 @@ import { IMPERSONATE_USER_PATH, ADMIN_SESSION_COOKIE_KEY } from './impersonation
*/
export type PlatformAdminOracle = (userId: string) => Promise<boolean>;

/**
* 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<unknown>;

/**
* 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<string> = 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;
Expand Down Expand Up @@ -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<boolean> {
const vendor = plugin?.endpoints?.impersonateUser;
if (!vendor || vendor.path !== IMPERSONATE_USER_PATH || !vendor.options) return false;
Expand Down Expand Up @@ -202,6 +283,22 @@ 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) {
// 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 ─────────────────
// `isPlatformAdminUser` reads the session user we already hold — which
// inside an endpoint is the RAW row (better-auth's `adminMiddleware`
Expand Down
Loading
Loading