Skip to content

Commit 0be162c

Browse files
fix(plugin-auth)!: impersonation, SSO-provider administration and the admin plugin's role-scalar actions refuse a held caller (ADR-0069 D3) (#23521)
Fixes #23335 Clause-②: yes (narrowing) ## What changes ADR-0069 D3 gates a held session (an expired or administrator-forced password, enforced MFA not yet enrolled) out of every protected resource until it remediates. REST, the runtime dispatcher and the platform-admin raw mounts (`judgePlatformAdmin`, PR #23360) already refuse one. The doors better-auth serves itself never did: their session is a raw better-auth read that `customSession` never decorates, and no ADR-0069 transport seam sees a request under the auth namespace. This PR closes the card's remaining leg. Each door now asks the session gate's own posture carrier for the acting user, in the organization their session operates in: `AuthManager.computeAuthGate`, the first entry of `IAuthService.resolveUserAuthGates` (#23287's one per-user posture computation, PR #23440). It then refuses a held caller with `judgeHeldSession`, the held refusal `judgePlatformAdmin` already gives (factored out of it, byte-identical). No new gate, no second posture computation, no new error code. ### Doors and verdicts (security card) | door | served by | a held caller before (measured, every held state) | after | |---|---|---|---| | `POST /admin/impersonate-user` | better-auth endpoint rebuilt by `applyPlatformAdminImpersonation` | platform admin: **200**, a session minted as the target, and that session is not held, so the hold was bypassed outright | **403** with the posture code, no session minted | | `POST /sso/register` | `@better-auth/sso`, authorized by the global before-hook | platform admin: **200**, the provider row landed | **403** with the posture code, no row | | `POST /sso/update-provider`, `POST /sso/delete-provider` | `@better-auth/sso` (provider owner) | owning platform admin: **200**, issuer rewritten to another host / row deleted | **403** with the posture code, provider unchanged | | `set-role`, `get-user`, `update-user`, `list-users`, `list-user-sessions`, `revoke-user-session`, `revoke-user-sessions` (all under `/admin/`) | better-auth `admin` plugin, authorized on the legacy `role` scalar | caller carrying `role = 'admin'`: **200** on every one, `update-user` landed | **403** with the posture code, the write never lands | The refusal, measured on the boot: `403 {"success":false,"error":{"code":"MFA_REQUIRED","message":"Multi-factor authentication is required. Please set up an authenticator app to continue."}}`, the same `code` and `message` (the remediation pointer) the session gate gives on a data read and the same body `judgePlatformAdmin` gives on the raw platform-admin mounts. ### Posture to verdict (H1, measured) | posture held | scope (ruling C′ item 1) | session gate on a non-allow-listed route (data read) | these doors | |---|---|---|---| | `PASSWORD_EXPIRED` | credential | 403 `PASSWORD_EXPIRED` | 403 `PASSWORD_EXPIRED` | | `PASSWORD_RESET_REQUIRED` | credential | 403 `PASSWORD_RESET_REQUIRED` | 403 `PASSWORD_RESET_REQUIRED` | | `MFA_REQUIRED` (global floor) | account | 403 `MFA_REQUIRED` | 403 `MFA_REQUIRED` | | `MFA_REQUIRED` (only the session's organization requires it) | account | 403 `MFA_REQUIRED` | 403 `MFA_REQUIRED` | | none, or the posture lookup failed | none | 200 | admitted, unchanged | Source of that verdict: the session gate holds every scope. `computeAuthGate` puts the first entry of `resolveUserAuthGates` on `user.authGate` ("Every scope holds a session, because a session IS the password sign-in"), and `evaluateAuthGate` refuses whatever posture that carrier holds, without reading its scope. The API-key seam's `account`-only rule is not reused here: a key is not a session, and these doors are session doors. ### What an attacker who holds only a held administrator's password can no longer reach - Mint a session as any non-admin user (impersonation), which previously bypassed the hold outright because the minted session is not held. - Register an identity provider, or rewrite the issuer and endpoints of one the administrator owns, or delete it: login routing for the environment. - Through a legacy `role = 'admin'` scalar: list and read users, list and revoke their sessions, update users, set roles. Still reachable for a held caller, on purpose: sign-in, change-password, two-factor enrollment, sign-out (remediation, unchanged); `/admin/stop-impersonating` (the exit from an impersonation, carried by the impersonated session); `/admin/has-permission` (a question that refuses no one). ## How each door asks (H2, H3, H5) - **H2, where the posture comes from (measured).** With a probe line in the impersonation handler (built into dist, then removed), the caller `adminMiddleware` hands the endpoint carried no `authGate` in 15 of 15 calls (five states, three actors), and no `isPlatformAdmin` either: it is the raw `sys_user` row. `GET /auth/get-session` on the same session did carry `authGate`. So every door asks; none reads the request. - **The ask.** Impersonation: an injected `SessionPostureOracle`, wired in `auth-manager.ts` to `computeAuthGate(userId, organizationId)`, asked at the start of the handler with `ctx.context.session.session.activeOrganizationId`. SSO and the role-scalar actions: the global before-hook resolves the actor (`resolveActor`, user id and active organization) and calls `assertActorNotHeld`, which asks the same `computeAuthGate`. The role-scalar and SSO update/delete branches skip the session read when no gate feature is active (`isAuthGateActive`, the same caches the computation reads), as the transport seams do. - **H3, the refusal.** `judgeHeldSession(sessionUser)` (`platform-admin-gate.ts`) is `evaluateAuthGate(sessionUser, '')` plus the judge's envelope; `judgePlatformAdmin` now calls it. Inside better-auth it is thrown as `APIError('FORBIDDEN', record)` with that record, so the vendor lane answers the gate's nested envelope, not its own flat one. The codes are the closed `AuthGateCodeSchema` vocabulary. - **Order.** Identity (the vendor's 401) first, then the posture, then authorization, as the session gate and the judge apply it. A held signed-in caller who is not an administrator now reads the posture code instead of the vendor's `403 YOU_ARE_NOT_ALLOWED_*` (same status). Pinned at the impersonation door. - **H5, organization.** The organization passed is the one the session operates in. Pinned: with only the organization's `require_mfa` set, `resolveUserAuthGates(admin, { organizationId })` answers `[MFA_REQUIRED]` and `resolveUserAuthGates(admin, {})` answers `[]`, and impersonation, registration and a role-scalar write are refused `MFA_REQUIRED`. - **Fail-open.** A posture read that fails answers `[]` (the computation's own contract), so the door admits, exactly as the session gate does. Pinned on the boot with the posture read failing. ## Measured first (H4: the role-scalar vendor actions) Real boot (`@objectstack/verify` `bootStack`, `OS_SCIM_ENABLED` for the `admin` plugin, `OS_SSO_ENABLED`), before the fix (plugin-auth built from `f9b421716`), a signed-up member carrying `sys_user.role = 'admin'`: | action | unheld | PASSWORD_EXPIRED | MFA_REQUIRED | PASSWORD_RESET_REQUIRED | MFA by organization | |---|---|---|---|---|---| | `list-users`, `get-user`, `list-user-sessions` | 200 | 200 | 200 | 200 | 200 | | `update-user` (the name changed) | 200 | 200 | 200 | 200 | 200 | | `set-role`, `revoke-user-sessions`, `revoke-user-session` | 200 | 200 | 200 | 200 | 200 | | protected data read (control) | 200 | 403 | 403 | 403 | 403 | The ADR-0068 platform admin (no scalar) is refused there by the vendor with `403 YOU_ARE_NOT_ALLOWED_*`, held or not. So the held-caller check applies to these seven actions, as the dispatch's surface rule names. `VENDOR_ROLE_SCALAR_ADMIN_PATHS` (`admin-impersonate-endpoint.ts`) holds them, and a unit pin classifies every route the installed `admin` plugin registers (held here; impersonation's own door; a raw mount that runs `judgePlatformAdmin` first; the exit; the question), so a vendor route nobody decided for turns red. ## Tests Final head `14edf8922` unless stated. - New booted pin, `packages/verify/src/held-session-vendor-admin-doors.pin.test.ts`, 28 cases: - unheld controls: impersonation 200 with one session minted, registration 200 with the row landed, owner issuer rewrite 200 with the row changed, scalar `update-user` 200 with the name changed; - per held state (`PASSWORD_EXPIRED`, `MFA_REQUIRED`, `PASSWORD_RESET_REQUIRED`), six cases each: impersonation 403 with the code and no session minted; registration 403 and no row; owner rewrite and delete 403 and the provider unchanged; scalar `update-user` 403 and the name unchanged; all seven role-scalar actions 403 with the code; a held non-admin at impersonation reads the posture code; - organization-only MFA: the computation reads `[MFA_REQUIRED]` with the organization and `[]` without; impersonation, registration and scalar `update-user` refused; - fail-open: with the posture read failing, the computation answers `[]`, the data read is 200 and impersonation is 200. - With the existing `held-session-platform-admin.pin.test.ts`: `Test Files 2 passed (2) · Tests 41 passed (41)`. - New unit pin, `admin-impersonate-endpoint.held-caller.test.ts` (4 cases: every installed `admin` plugin path classified exactly once, no stale entry, the held set, every mount-judged path really opens with `gateAdmin`). `platform-admin-gate.held-session.test.ts` gains 5 cases for `judgeHeldSession`, including the byte-identity with the judge. Targeted plugin-auth run (9 files, `auth-manager.test.ts` included): `Tests 426 passed (426)`. - **Ablation, the impersonation door's check** (`scripts/ablation-replace.mjs`, anchor 1 to 0, blob `4e2afe618` to `67beda690`; plugin-auth rebuilt; `ablation-dist-preflight` marker present in `dist/index.js` and `dist/index.mjs`): the booted pin went `7 failed | 21 passed (28)`, exactly the seven impersonation cases (three held states, the three held non-admin cases, the organization case). Registration, the SSO owner doors, the role-scalar actions, the controls and fail-open stayed green. Restore: blob equals HEAD `4e2afe618`, `git diff HEAD` empty, plugin-auth rebuilt, preflight `--absent` green with the whole tree clean, pins `41 passed (41)`. - `@objectstack/plugin-auth` full suite at `beebec8f3` (the head before a refactor that only binds the refusal record to a local name), 4 shards: 36 + 35 + 35 + 35 files, 779 + 928 (10 skipped) + 520 + 597 = 2824 passed. - `@objectstack/verify` full suite at `beebec8f3`, 3 shards: 40 files, 107 + 126 + 114 = 347 passed. - Typecheck (`tsc --noEmit` plus `check:test-typecheck`): plugin-auth exit 0 at `14edf8922`; verify exit 0 at `beebec8f3`. - eslint `--no-inline-config` on the 6 changed `.ts` files: 0 errors, 0 warnings. The repo-wide lint run is CI's. ## Gates - Derived at `14edf8922`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` names 68 families for this diff (the PM's lead listed 50 at `ad5a7d7c3`). - All 68 run locally at `14edf8922`, each exit code captured before any pipe: 68 of 68 exit 0. The two dist-reading families first needed state: `check:dual-build-cjs-loads` answered `PREREQUISITE NOT MET` (exit 3, missing dist) until a full workspace build (turbo, 76 tasks, exit 0); `check:dispatcher-error-vocabulary` found the `held.body` record (see Acceptance notes), fixed in `14edf8922`. - Reconciliation: `dispatch-gates.mjs --ran` reports `68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN`. - Changeset gates: `check:adr-0087-registration` reads `not-required (no-migration-prescription)` on `.changeset/23335-held-admin-vendor-doors-refused.md` (BREAKING, bang, clause-② narrowing); `check:changeset-no-major` and `check:empty-changeset` exit 0. - Families CI owns (lint, the artifact roster, the type-check lanes) are read from this PR's check-runs, not re-run here. ## Acceptance notes - **In-place fix under the bounded exemption: SSO `update-provider` and `delete-provider`.** Measured on the boot at `ad8dee166` (registration already fixed there): a held (`MFA_REQUIRED`) platform admin who owned a provider rewrote its issuer to another host (200, landed) and deleted another (200, row gone). Same defect class as the registration door, fixed with the same mechanical branch in the same file, pinned in the same file and covered by the same gate families. The claim's file surface named the registration hook only; the seat may record the widening. - `check:dispatcher-error-vocabulary` first read `APIError('FORBIDDEN', held.body)` as a member of an imported code table and reported it unresolved. The record is the judge's own object literal, whose `code` comes from the closed posture vocabulary, so it is now bound to a local name and handed over (`14edf8922`, no behaviour change). - **New export:** `judgeHeldSession` from `@objectstack/plugin-auth`, via the package's `export *` of `platform-admin-gate.ts`. No new error code. - Not changed, and reads only: a held provider owner still gets `GET /sso/get-provider` and `GET /sso/providers` (sanitized provider details; measured 200). The vendor's `/sso/request-domain-verification` and `/sso/verify-domain` were not measured (domain verification is off in this boot); their `/admin/sso/*` bridges are judged. - `/admin/oauth2/*` (the oauth-provider plugin) is `SERVER_ONLY` in the installed 1.7.3 (read from source), so it is no HTTP door. - The booted pins live in `packages/verify`, beside #23360's, because plugin-auth cannot depend on verify. The organization-only case primes `_orgMfaCache` as the background refresh would, the precedent `auth-manager.test.ts` uses. --- _Generated by [Claude Code](https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 869a381 commit 0be162c

7 files changed

Lines changed: 797 additions & 13 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/plugin-auth': minor
3+
---
4+
5+
fix(plugin-auth)!: impersonation, SSO-provider administration and the admin plugin's role-scalar actions refuse a caller the ADR-0069 authentication policy holds
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- 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). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
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:
14+
15+
- **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.
16+
- **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.
17+
- **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.
18+
19+
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.
20+
21+
- **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.
22+
- **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.
23+
- **New export:** `judgeHeldSession` (`@objectstack/plugin-auth`), the held refusal `judgePlatformAdmin` gives, now shared with these doors.
24+
25+
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.
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0069 D3 on the better-auth `admin` plugin's lane: every route the
5+
* installed plugin registers is classified for a held caller, none forgotten.
6+
*
7+
* `VENDOR_ROLE_SCALAR_ADMIN_PATHS` (`admin-impersonate-endpoint.ts`) is the set
8+
* the global before-hook holds: the routes the vendor serves itself and
9+
* authorizes on a session plus the legacy `role` scalar. Every OTHER route the
10+
* plugin registers must be accounted for below, with the reason it is not in
11+
* that set. A vendor bump that adds a route turns this file red until someone
12+
* decides whether a held caller may reach it — the decision is never made by
13+
* omission, which is how a held platform admin kept reaching impersonation.
14+
*
15+
* The behaviour itself — each door refusing a held caller with the session
16+
* gate's code, and the unheld controls — is pinned on a real boot in
17+
* `@objectstack/verify` (`held-session-vendor-admin-doors.pin.test.ts`).
18+
*/
19+
20+
import { readFileSync } from 'node:fs';
21+
import { dirname, resolve } from 'node:path';
22+
import { fileURLToPath } from 'node:url';
23+
import { describe, it, expect } from 'vitest';
24+
import { admin } from 'better-auth/plugins/admin';
25+
import { buildAdminPluginSchema } from './auth-schema-config.js';
26+
import { VENDOR_ROLE_SCALAR_ADMIN_PATHS } from './admin-impersonate-endpoint.js';
27+
import { IMPERSONATE_USER_PATH, STOP_IMPERSONATING_PATH } from './impersonation-bearer-rotation.js';
28+
29+
const HERE = dirname(fileURLToPath(import.meta.url));
30+
31+
/**
32+
* Paths an ObjectStack raw mount serves ahead of the vendor, after
33+
* `judgePlatformAdmin` (which refuses a held session). Proven below by the
34+
* mount's own registration line in `auth-plugin.ts`.
35+
*/
36+
const JUDGED_BY_AN_OBJECTSTACK_MOUNT: readonly string[] = [
37+
'/admin/create-user',
38+
'/admin/set-user-password',
39+
'/admin/ban-user',
40+
'/admin/unban-user',
41+
'/admin/remove-user',
42+
];
43+
44+
/** Every other path, with the reason a held caller is not refused at the hook. */
45+
const NOT_HELD_AT_THE_HOOK: Readonly<Record<string, string>> = {
46+
[IMPERSONATE_USER_PATH]: 'its own door: the re-authorized endpoint refuses a held caller in its handler',
47+
[STOP_IMPERSONATING_PATH]: 'the exit from an impersonation, carried by the impersonated session (like sign-out)',
48+
'/admin/has-permission': 'a question that changes nothing and refuses no one',
49+
};
50+
51+
const installedAdminPaths = (): string[] => {
52+
const plugin: any = admin({ schema: buildAdminPluginSchema() } as any);
53+
return Object.values(plugin.endpoints as Record<string, { path: string }>).map((e) => e.path).sort();
54+
};
55+
56+
describe('ADR-0069 D3 — the admin plugin lane, classified for a held caller', () => {
57+
it('classifies every path the installed admin plugin registers, exactly once', () => {
58+
const classified = [
59+
...VENDOR_ROLE_SCALAR_ADMIN_PATHS,
60+
...JUDGED_BY_AN_OBJECTSTACK_MOUNT,
61+
...Object.keys(NOT_HELD_AT_THE_HOOK),
62+
];
63+
// Disjoint: a path in two classes is a decision made twice.
64+
expect(new Set(classified).size).toBe(classified.length);
65+
const unclassified = installedAdminPaths().filter((p) => !classified.includes(p));
66+
expect(
67+
unclassified,
68+
'an admin-plugin route nobody decided for: hold it (VENDOR_ROLE_SCALAR_ADMIN_PATHS) or classify it here with its reason',
69+
).toEqual([]);
70+
});
71+
72+
it('names no path the installed plugin does not serve (a stale entry holds nothing)', () => {
73+
const installed = installedAdminPaths();
74+
const stale = [...VENDOR_ROLE_SCALAR_ADMIN_PATHS, ...JUDGED_BY_AN_OBJECTSTACK_MOUNT].filter(
75+
(p) => !installed.includes(p),
76+
);
77+
expect(stale).toEqual([]);
78+
});
79+
80+
it('holds the vendor role-scalar actions the boot measured admitting a held caller', () => {
81+
expect([...VENDOR_ROLE_SCALAR_ADMIN_PATHS].sort()).toEqual([
82+
'/admin/get-user',
83+
'/admin/list-user-sessions',
84+
'/admin/list-users',
85+
'/admin/revoke-user-session',
86+
'/admin/revoke-user-sessions',
87+
'/admin/set-role',
88+
'/admin/update-user',
89+
]);
90+
});
91+
92+
it('every mount-judged path really is an ObjectStack mount that runs the judge first', () => {
93+
const source = readFileSync(resolve(HERE, 'auth-plugin.ts'), 'utf8');
94+
for (const path of JUDGED_BY_AN_OBJECTSTACK_MOUNT) {
95+
const mount = source.indexOf(`rawApp.post(\`\${basePath}${path}\``);
96+
expect(mount, `no raw mount for ${path} in auth-plugin.ts`).toBeGreaterThan(-1);
97+
// The judge is the mount's first act (within its opening lines).
98+
expect(source.slice(mount, mount + 400), `${path}'s mount does not open with gateAdmin`).toMatch(
99+
/await gateAdmin\(c\)/,
100+
);
101+
}
102+
});
103+
});

‎packages/plugins/plugin-auth/src/admin-impersonate-endpoint.ts‎

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,38 @@
102102
* and the dogfood sweep distinguishes the two envelopes on purpose (see
103103
* `admin-ban-endpoints.ts`). Keeping them also mints no new public error code,
104104
* so nothing here reaches the spec error-code ledger.
105+
*
106+
* ## A held caller is refused first, with the gate's refusal (ADR-0069 D3)
107+
*
108+
* A platform admin whom the authentication policy holds (an expired or
109+
* administrator-forced password, enforced MFA not yet enrolled) is gated out of
110+
* every protected resource until they remediate, and minting a session as
111+
* another user is one. Measured on a real boot before this check: in every held
112+
* state the session gate refused the admin's data read with the posture code,
113+
* while this endpoint answered `200` with a session for the target — and that
114+
* session is not held, so the hold was bypassed outright.
115+
*
116+
* The session `adminMiddleware` hands this handler is a raw better-auth read
117+
* that `customSession` never decorates, so it carries no `authGate` (measured),
118+
* and no ADR-0069 transport seam ever sees a request under the auth namespace.
119+
* So the handler asks the auth service for the caller's posture in the
120+
* organization the session operates in — the session gate's own carrier, the
121+
* first entry of `resolveUserAuthGates` — and refuses a held caller with
122+
* `judgeHeldSession`, the refusal `judgePlatformAdmin` gives on the raw
123+
* platform-admin mounts. Order: identity (the vendor's `401`), then the
124+
* posture, then the authorization predicate below — the order the session gate
125+
* and the judge apply, so a held non-admin reads the posture too.
126+
*
127+
* That refusal is the GATE's envelope (`{ success: false, error: { code,
128+
* message } }`), not the vendor's flat one, on purpose: it is the session
129+
* gate's answer, and a client that recognises a held session reads the same
130+
* body here as on every other protected door. Its codes are the posture
131+
* vocabulary (`AuthGateCodeSchema`), so it mints no code either.
132+
*
133+
* ## The rest of the admin plugin's lane: {@link VENDOR_ROLE_SCALAR_ADMIN_PATHS}
105134
*/
106135

107-
import { isPlatformAdminUser } from './platform-admin-gate.js';
136+
import { isPlatformAdminUser, judgeHeldSession } from './platform-admin-gate.js';
108137
import { IMPERSONATE_USER_PATH, ADMIN_SESSION_COOKIE_KEY } from './impersonation-bearer-rotation.js';
109138

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

150+
/**
151+
* Answers the session gate's posture carrier for `userId` operating in
152+
* `organizationId`: the first, most urgent entry of the auth service's ONE
153+
* per-user posture computation (`resolveUserAuthGates`, ADR-0069 · #23287
154+
* ruling C′ item 3), exactly what `customSession` stamps on a session as
155+
* `user.authGate` — or `undefined` when no policy holds the user.
156+
*
157+
* Injected for the same reason as {@link PlatformAdminOracle}. It fails OPEN,
158+
* as the computation it reads does: a lookup that fails holds no one.
159+
*/
160+
export type SessionPostureOracle = (userId: string, organizationId: string | undefined) => Promise<unknown>;
161+
162+
/**
163+
* The better-auth `admin` plugin routes that the vendor serves ITSELF and
164+
* authorizes on its own inputs alone — `adminMiddleware` (a session) plus
165+
* `hasPermission` over the legacy `role` scalar — so no ObjectStack judge runs
166+
* on them and nothing on their path reads the ADR-0069 posture.
167+
*
168+
* Measured on a real boot: a caller carrying `role = 'admin'` (a pre-ADR-0068
169+
* deployment, which `isPlatformAdminUser` still reads) and held by the policy
170+
* in any state was refused by the session gate on a data read and ADMITTED to
171+
* every one of these — the user listing and record, the session listing and
172+
* revocation, and the writes (`update-user` landed, `set-role` answered `200`).
173+
* `auth-manager.ts`'s global before-hook therefore refuses a held caller on
174+
* these paths with the same refusal, asked of the same posture computation.
175+
*
176+
* Every other path the plugin registers is classified, not forgotten
177+
* (`admin-impersonate-endpoint.held-caller.test.ts` reds on a vendor path that
178+
* is in neither list):
179+
*
180+
* - `/admin/impersonate-user` — its own door, this module's endpoint;
181+
* - `/admin/create-user`, `/admin/set-user-password`, `/admin/ban-user`,
182+
* `/admin/unban-user`, `/admin/remove-user` — an ObjectStack mount on the
183+
* path runs `judgePlatformAdmin` (held refused) before anything reaches the
184+
* vendor;
185+
* - `/admin/stop-impersonating` — the EXIT from an impersonation, a
186+
* remediation-shaped request like sign-out, carried by the impersonated
187+
* session; holding it would trap that session rather than protect anything;
188+
* - `/admin/has-permission` — a question that changes nothing and refuses no
189+
* one (no `adminMiddleware`).
190+
*/
191+
export const VENDOR_ROLE_SCALAR_ADMIN_PATHS: ReadonlySet<string> = new Set([
192+
'/admin/set-role',
193+
'/admin/get-user',
194+
'/admin/update-user',
195+
'/admin/list-users',
196+
'/admin/list-user-sessions',
197+
'/admin/revoke-user-session',
198+
'/admin/revoke-user-sessions',
199+
]);
200+
121201
/** The slice of better-auth's `admin` plugin this module rewrites. */
122202
export interface AdminPluginLike {
123203
id: string;
@@ -166,6 +246,7 @@ export const USER_NOT_FOUND = { code: 'USER_NOT_FOUND', message: 'User not found
166246
export async function applyPlatformAdminImpersonation(
167247
plugin: AdminPluginLike,
168248
isPlatformAdmin: PlatformAdminOracle,
249+
postureOf: SessionPostureOracle,
169250
): Promise<boolean> {
170251
const vendor = plugin?.endpoints?.impersonateUser;
171252
if (!vendor || vendor.path !== IMPERSONATE_USER_PATH || !vendor.options) return false;
@@ -202,6 +283,22 @@ export async function applyPlatformAdminImpersonation(
202283
const caller = ctx.context.session?.user;
203284
const callerId = typeof caller?.id === 'string' ? caller.id : '';
204285

286+
// ── ADR-0069 D3: a held caller is refused first (see the header) ─────
287+
// The session gate's carrier, asked for the organization this session
288+
// operates in, then the gate's decision and the judge's refusal body.
289+
if (callerId) {
290+
const activeOrgId = ctx.context.session?.session?.activeOrganizationId;
291+
const held = judgeHeldSession({
292+
authGate: await postureOf(callerId, typeof activeOrgId === 'string' && activeOrgId ? activeOrgId : undefined),
293+
});
294+
if (held) {
295+
// The judge's own record: its object literal (`judgeHeldSession`)
296+
// carries the posture `code`, from the closed `AuthGateCodeSchema`.
297+
const { body } = held;
298+
throw new APIError('FORBIDDEN', body);
299+
}
300+
}
301+
205302
// ── THE changed predicate (1/2): who may impersonate ─────────────────
206303
// `isPlatformAdminUser` reads the session user we already hold — which
207304
// inside an endpoint is the RAW row (better-auth's `adminMiddleware`

0 commit comments

Comments
 (0)