diff --git a/.changeset/one-id-shaped-platform-admin-judge.md b/.changeset/one-id-shaped-platform-admin-judge.md new file mode 100644 index 0000000000..4b1acb0bab --- /dev/null +++ b/.changeset/one-id-shaped-platform-admin-judge.md @@ -0,0 +1,20 @@ +--- +"@objectstack/core": minor +"@objectstack/plugin-auth": patch +--- + +**Security:** the "is this user id a platform admin?" question is now asked in exactly one place, and the two copies that answered it differently are gone (#10348, #10949). + +ADR-0068 D2 defines platform standing as one thing — an unscoped `admin_full_access` grant, held now. `core/security/resolve-authz-context.ts` is the declared authority for authorization derivation and its header states that every entry point must resolve through it and never re-read the grant tables itself. `plugin-auth`'s `auth-manager.ts` did exactly that twice: once inside the `customSession` callback, and once in the predicate that authorizes `/sso/register` and, through the impersonation oracle, `/admin/impersonate-user`. Both copies are deleted. Both callers — and the session payload — now ask `hasPlatformAdminStanding(engine, userId)`, a projection of `resolveUserAuthzGrants` exported from `@objectstack/core`, so a platform-admin verdict is derived in one place for the whole platform. + +**What that changes, and it is a tightening on all three counts.** The deleted copies applied neither the ADR-0091 validity window nor the ADR-0049 `active` check, and resolved `admin_full_access` by matching a name over a page of the permission-set catalogue. The authority applies both checks before any derivation and resolves the set by id. So: + +- an **expired** platform-admin grant no longer authorizes `/sso/register` or `/admin/impersonate-user`, and no longer appears in the session payload; +- a **deactivated** `admin_full_access` permission set no longer confers platform standing anywhere — the deactivation dialog's promise now holds on these gates too; +- an environment holding **more permission sets than a single catalogue page** can no longer lose the `admin_full_access` row and demote every platform admin at once. + +**One behaviour widens, and it was ruled deliberately** (maintainer, 2026-08-24). The `customSession` copy read without a system identity while the other read with one. The single authority reads as system, so on a strictly org-scoped deployment the session payload stops under-reporting platform admin — the fail-closed drift between the payload and the gates ends. Open-core composition is unaffected: the two reads reached identical rows there already. + +**The org boundary is unchanged and now pinned at both gates.** An org owner, an org admin, a `TENANT_ADMIN`-posture principal and an org-scoped `admin_full_access` grant are all refused — the `PLATFORM_ADMIN` rung derives from the unscoped capability grant alone. The predicate takes an engine and a user id and nothing else: it deliberately does not accept the resolver's caller-supplied seeds, so no part of a request can supply part of its own verdict. + +Population queries are a different kind and are untouched: `ensure-default-organization.ts` asks *which* user is the platform admin, which a per-user predicate cannot express. diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index 5444c00b9d..2ab70e338b 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -88,6 +88,10 @@ export { export { resolveAuthzContext, resolveUserAuthzGrants, + // [#10348] The ONE id-shaped platform-admin predicate (ADR-0068 D2). Every + // surface that only knows a user id asks this instead of re-reading + // `sys_*_permission_set` — the prohibition resolve-authz-context.ts states. + hasPlatformAdminStanding, resolveLocalizationContext, type ResolvedAuthzContext, type ResolveAuthzInput, diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 876f9598ac..0387e0e9ef 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -627,6 +627,62 @@ export async function resolveUserAuthzGrants( return grants; } +/** + * hasPlatformAdminStanding — the ID-SHAPED platform-admin question, asked in + * exactly one place. + * + * ADR-0068 D2 defines PLATFORM standing as one thing: an UNSCOPED + * (`organization_id = null`) `sys_user_permission_set` grant on the + * `admin_full_access` set, held **now**. A surface that only knows a user id — + * a session-payload derivation, a platform-operator route gate, an + * impersonation oracle — asks here, so it never has to re-read the grant tables + * itself, which is the prohibition this module's header states. + * + * It is a PROJECTION of {@link resolveUserAuthzGrants}, never a second + * derivation: the answer is the `PLATFORM_ADMIN` rung of the posture ladder, + * and that rung is derived from the unscoped-grant evidence and nothing else. + * Everything that governs those grants therefore applies here by construction + * and cannot drift from it — the ADR-0091 validity window (§6), the ADR-0049 + * `active` flag on the catalogue row (§6b), the system-identity read, and the + * resolution of `admin_full_access` BY ID rather than by scanning a page of the + * catalogue. Each of those was missing from a hand-written copy of this + * predicate; none of them can be missing from a projection. + * + * ⛔ Read the RUNG — never `positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN)`. + * The positions list is wider on purpose: an ADR-0057 D4 `sys_user_position` + * row may spell that very name, and a platform-RBAC assignment is not the D2 + * capability grant. The two readings genuinely differ, so the narrow one is the + * one that gets a name here. + * + * ⛔ The options are deliberately NOT {@link ResolveUserAuthzGrantsOptions}. + * That type carries caller-supplied seeds (`seedEmail`, `seedPermissions`) for + * transports that already resolved part of a principal; an authorization + * predicate that accepted them would let a caller supply part of its own + * verdict. Clock injection is the only thing a caller may pass, so this + * function's answer is a function of `(ql, userId)` and the stored rows alone. + * + * ⚠️ This is the PER-USER predicate. The POPULATION question ("which user is + * the platform admin?" — `ensure-default-organization.ts`) is a different kind + * and is deliberately not expressible through it; do not widen this to serve + * it. + * + * Fail-CLOSED: an empty id, a missing engine, or any unreadable lookup answers + * `false`. This backs security gates, and an unverifiable actor never passes. + */ +export async function hasPlatformAdminStanding( + ql: any, + userId: string, + opts: { nowMs?: number } = {}, +): Promise { + if (!ql || typeof userId !== 'string' || userId.length === 0) return false; + try { + const grants = await resolveUserAuthzGrants(ql, userId, { nowMs: opts.nowMs }); + return grants.posture === 'PLATFORM_ADMIN'; + } catch { + return false; + } +} + // ── Localization (ADR-0053 Phase 2) ───────────────────────────────────────── function isValidTimeZone(tz: string): boolean { diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 11291e3b1d..9fd8eb6c3b 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -11,6 +11,11 @@ import type { OidcProvidersConfig, } from '@objectstack/spec/system'; import type { IDataEngine } from '@objectstack/core'; +// [#10348] The ONE id-shaped platform-admin predicate (ADR-0068 D2). +// `auth-manager` used to re-derive that standing itself, in two spellings +// that had drifted from the declared authority and from each other; both +// now ask the authority. Nothing in this file reads the grant tables. +import { hasPlatformAdminStanding } from '@objectstack/core'; import type { IEmailService, ISmsService } from '@objectstack/spec/contracts'; import { readEnvWithDeprecation, @@ -3030,7 +3035,7 @@ export class AuthManager { // `roles: string[]` array (ADR-0068 D1/D2): the stored `user.role` scalar // split on commas, PLUS the active membership mapped to canonical // `org_owner`/`org_admin`/`org_member`, PLUS `platform_admin` when the user - // holds the admin_full_access permission set. `user.isPlatformAdmin` is a + // resolves as a platform admin (ADR-0068 D2). `user.isPlatformAdmin` is a // derived alias of `'platform_admin' in positions`. // // IMPORTANT: `user.role` is NOT overwritten anymore — consumers must gate @@ -3043,9 +3048,10 @@ export class AuthManager { // Better-auth's `sys_user` table doesn't carry a `role` column. We derive // it from two sources: // - // 1. **Platform admin** — a `sys_user_permission_set` row that points at - // the `admin_full_access` permission set with `organization_id = null` - // (seeded by `bootstrapPlatformAdmin`). + // 1. **Platform admin** — the ADR-0068 D2 standing, resolved through + // `core/security/resolve-authz-context.ts` (the single authority for + // authorization derivation) and never re-read here. See + // `isPlatformAdminUserId` below. // 2. **Organization admin** — a `sys_member` row in the user's *active* // organization (`session.activeOrganizationId`) with role `owner` or // `admin`. Org owners/admins are entitled to manage org-scoped @@ -3061,29 +3067,6 @@ export class AuthManager { plugins.push(customSession(async ({ user, session }) => { if (!user?.id) return { user, session }; - const isPlatformAdmin = async (): Promise => { - try { - const links = await dataEngine.find('sys_user_permission_set', { - where: { user_id: user.id }, - limit: 50, - }); - const platformLinks = (Array.isArray(links) ? links : []).filter( - (l: any) => !l.organization_id, - ); - if (platformLinks.length === 0) return false; - const sets = await dataEngine.find('sys_permission_set', { limit: 50 }); - const adminSet = (Array.isArray(sets) ? sets : []).find( - (r: any) => r.name === 'admin_full_access', - ); - if (!adminSet) return false; - return platformLinks.some( - (l: any) => l.permission_set_id === adminSet.id, - ); - } catch { - return false; - } - }; - // ADR-0068 D2 — surface CANONICAL org_* role names (not a boolean flag): // a membership owner/admin/member maps to org_owner/org_admin/org_member. const activeOrgRoles = async (): Promise => { @@ -3112,7 +3095,10 @@ export class AuthManager { // positions[] (identity names + position names), with NO singular // overwrite. isPlatformAdmin is a DERIVED alias of // `'platform_admin' in positions`, retained for back-compat clients. - const platformAdmin = await isPlatformAdmin(); + // [#10348] Asked through the ONE authority, exactly as `/sso/register` + // and `/admin/impersonate-user` ask it — so the session payload can no + // longer disagree with the gates about who a platform admin is. + const platformAdmin = await this.isPlatformAdminUserId(user.id); const orgRoles = await activeOrgRoles(); const storedRole = typeof (user as any).role === 'string' ? (user as any).role : ''; const positions = Array.from(new Set([ @@ -4503,48 +4489,51 @@ export class AuthManager { } /** - * ADR-0068 D2, asked on its own: is `userId` a PLATFORM admin — a - * `sys_user_permission_set` row pointing at the `admin_full_access` - * permission set with `organization_id = null` (seeded by - * `bootstrapPlatformAdmin`)? + * ADR-0068 D2, asked on its own: is `userId` a PLATFORM admin? + * + * ⭐ [#10348] This is NOT a judge. It is this manager's engine binding over + * `hasPlatformAdminStanding` — the one place in the tree that turns a user id + * into that boolean, in `core/security/resolve-authz-context.ts`, whose header + * states that every entry point must resolve authorization through it and + * never re-read `sys_*_permission_set` itself. This file used to do exactly + * what that forbids, twice: here, and again inside the `customSession` + * callback. Both copies are gone; both callers land here, and this method + * performs no derivation of its own. Adding one back is the regression. * - * Deliberately does NOT admit organization owners/admins. Platform-admin - * routes must not be reachable by whoever happens to own an org (ADR-0068). - * [#10009] This replaced an `isOrgOrPlatformAdmin` predicate that admitted - * both; once `/sso/register` stopped asking the org question, that wider - * predicate had no caller left and was removed rather than parked. + * The three call sites are `/sso/register`'s ADR-0024 before-hook, the + * `/admin/impersonate-user` oracle (both the caller and the protected-target + * question), and the `customSession` payload — which is why the payload can + * no longer report a different answer from the gates. * - * ⛔ The legacy `user.role === 'admin'` scalar is NOT consulted here. This - * asks the permission-set question only — the channel ADR-0068 D2 keeps. + * [#10949] What the copies had drifted away from, and what the authority + * applies: the ADR-0091 validity window on the grant, the ADR-0049 `active` + * flag on the catalogue row, and a lookup of `admin_full_access` BY ID rather + * than by name over a page of the catalogue. The first two mean an expired or + * deactivated grant no longer authorizes anything; the third means an + * environment with more permission sets than that page held can no longer + * demote every platform admin at once. * - * Reads through `withSystemReadContext` so the lookups are not themselves - * RLS-scoped to the acting — possibly non-privileged — user, and fails CLOSED - * (returns false) on any lookup error: this backs a security gate, and an - * unverifiable actor must never pass. + * Deliberately does NOT admit organization owners/admins — the authority + * derives the `PLATFORM_ADMIN` rung from the UNSCOPED capability grant alone, + * so an org owner/admin (and a TENANT_ADMIN-posture principal) is refused. + * [#10009] That boundary was established when `/sso/register` stopped asking + * the org question; it is pinned, at both gates, in + * `platform-admin-standing.consolidation.test.ts`. + * + * ⛔ The legacy `user.role === 'admin'` scalar is NOT consulted. ⛔ Nor is any + * caller-supplied seed: the predicate takes `(engine, userId)` and reads the + * stored rows, so nothing about a request can supply part of its own verdict. + * + * The authority reads with system identity, so the lookups are not themselves + * RLS-scoped to the acting — possibly non-privileged — user. Fails CLOSED + * (returns false) on an empty id, a missing engine, or any lookup error: this + * backs security gates, and an unverifiable actor must never pass. */ private async isPlatformAdminUserId(userId: string): Promise { if (!userId) return false; const engine = this.getDataEngine(); if (!engine) return false; - try { - const sys = withSystemReadContext(engine); - const links = await sys.find('sys_user_permission_set', { - where: { user_id: userId }, - limit: 50, - }); - const platformLinks = (Array.isArray(links) ? links : []).filter( - (l: any) => !l.organization_id, - ); - if (platformLinks.length === 0) return false; - const sets = await sys.find('sys_permission_set', { limit: 50 }); - const adminSet = (Array.isArray(sets) ? sets : []).find( - (r: any) => r.name === 'admin_full_access', - ); - if (!adminSet) return false; - return platformLinks.some((l: any) => l.permission_set_id === adminSet.id); - } catch { - return false; - } + return hasPlatformAdminStanding(engine, userId); } /** diff --git a/packages/plugins/plugin-auth/src/platform-admin-standing.consolidation.test.ts b/packages/plugins/plugin-auth/src/platform-admin-standing.consolidation.test.ts new file mode 100644 index 0000000000..9d38571fde --- /dev/null +++ b/packages/plugins/plugin-auth/src/platform-admin-standing.consolidation.test.ts @@ -0,0 +1,505 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The ID-SHAPED platform-admin question, asked at the two gates that turn its +// answer into authorization: `/sso/register` and `/admin/impersonate-user`. +// +// Both gates used to ask a judge that lived in `auth-manager.ts` and re-derived +// the ADR-0068 D2 standing itself. `core/security/resolve-authz-context.ts` +// declares that every entry point must resolve authorization through it and +// never re-read `sys_*_permission_set` — so the judge was a second +// implementation of a predicate that already had an owner, and the two had +// drifted apart in ways nothing could see while both existed. +// +// This suite pins the answer at the GATES rather than at the predicate, because +// that is the only place the drift was ever observable. Every case is asserted +// in BOTH directions on the same fixture family (a genuine platform admin is +// admitted; the near-miss principal is refused), and every refusal is asserted +// as STATUS **and** `code` (ADR-0112) — a bare "it refused" cannot tell this +// gate's answer apart from a validation error or from the vendor's own. +// +// Three properties are pinned that a widened implementation would score green +// without: +// +// • an ORG owner / org admin — and a TENANT_ADMIN-posture principal — is NOT +// a platform admin at either gate. #10009/#10390 spent a whole card taking +// that wider reading off `/sso/register`; pinning only "the platform admin +// is admitted" would go green against a gate that admits everyone. +// • a grant outside its ADR-0091 validity window, and a DEACTIVATED +// `admin_full_access` row (ADR-0049), authorize NOTHING. Four distinct +// refusals, two per gate, deliberately not collapsed into one case that +// could pass for the wrong reason. +// • the `admin_full_access` row is resolved by IDENTITY, not by scanning a +// page of the catalogue. Pinned with a catalogue larger than any fixed page +// — the environment shape in which a page scan silently demotes every +// platform admin at once. +// +// Real pipeline throughout: requests go in as `Request` objects through +// `AuthManager.handleRequest`, over a real better-auth instance carrying the +// real `admin` and `@better-auth/sso` plugins. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; +import { AuthManager } from './auth-manager'; +// ⚠️ Imported from a sibling TEST file on purpose — the same trade +// `admin-impersonate-endpoint.test.ts` measured and documented at its own +// import of it: re-registering that file's `describe`s here is cheaper than +// minting a second engine double (a second looseness risk plus new +// `check:engine-double-contract` ledger entries) and far cheaper than moving it +// to a plain `.ts` helper, which would remove it from that gate's sight +// entirely (the gate discovers doubles by walking `*.test.ts` only). +import { createMemoryEngine } from './impersonation-bearer-rotation.test'; + +const SECRET = 'test-secret-at-least-32-chars-long!!'; +const PASSWORD = 'S3cure!Passw0rd-10348'; +const BASE = 'http://localhost:3000/api/v1/auth'; +const PS_ADMIN = 'ps_admin_full_access'; +const PS_ORG_ADMIN = 'ps_organization_admin'; + +/** Bigger than any fixed page a catalogue scan could have read. */ +const DECOY_PERMISSION_SETS = 60; + +const makeManager = (engine: any) => + new AuthManager({ + secret: SECRET, + baseUrl: 'http://localhost:3000', + dataEngine: engine, + // `/admin/impersonate-user` lives on better-auth's `admin` plugin and + // `/sso/register` on `@better-auth/sso`; both are opt-in in this repo. + plugins: { admin: true, sso: true }, + } as any); + +const signUp = (manager: AuthManager, email: string, name: string) => + manager.handleRequest( + new Request(`${BASE}/sign-up/email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password: PASSWORD, name }), + }), + ); + +const signIn = (manager: AuthManager, email: string) => + manager.handleRequest( + new Request(`${BASE}/sign-in/email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password: PASSWORD }), + }), + ); + +const bearerFrom = (response: Response): string => { + const token = response.headers.get('set-auth-token'); + if (!token) throw new Error('no set-auth-token on the response'); + return token; +}; + +const userRows = (engine: any) => (engine.tables.get('sys_user') ?? []) as any[]; + +const userIdFor = (engine: any, email: string): string => { + const row = userRows(engine).find((r) => r.email === email); + if (!row) throw new Error(`no sys_user row for ${email}`); + return String(row.id); +}; + +const jsonOf = async (res: Response) => { + const text = await res.text(); + try { return JSON.parse(text); } catch { return { __raw: text }; } +}; + +// ── the two gates ─────────────────────────────────────────────────────────── + +/** + * The ObjectStack refusal on `/sso/register` (ADR-0024 + ADR-0068 D4). + * + * ⭐ The pin is on OUR gate's answer, so "admitted" means exactly "this + * refusal was not issued" — and that is deliberate rather than a compromise. + * Measured on this fixture family: a near-miss principal gets + * `403 SSO_REGISTER_FORBIDDEN` (our before-hook fired) and a platform admin + * gets the vendor's own judgment of the body instead (`400 VALIDATION_ERROR` + * on this deliberately incomplete SAML shape) — two answers that cannot be + * confused, from one request shape. Pinning our gate rather than a successful + * registration also keeps the suite valid across a vendor schema bump, which a + * body pinned to today's schema would not be. Every "admitted" case below + * carries an IN-TEST control proving the refusal is reachable on the same + * engine with the same body, so admission is never a vacuous pass. + */ +const SSO_REGISTER_BODY = { + providerId: 'pin-idp', + issuer: 'https://idp.example.com/entity', + domain: 'idp.example.com', + samlConfig: { + entryPoint: 'https://idp.example.com/sso', + cert: 'MIICertificateBodyForThePin', + callbackUrl: 'http://localhost:3000/api/v1/auth/sso/saml2/sp/acs/pin-idp', + identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + spMetadata: { entityID: 'http://localhost:3000/api/v1/auth/sso/saml2/sp/metadata?providerId=pin-idp' }, + }, +}; + +const ssoRegister = (manager: AuthManager, bearer: string) => + manager.handleRequest( + new Request(`${BASE}/sso/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', authorization: `Bearer ${bearer}` }, + body: JSON.stringify(SSO_REGISTER_BODY), + }), + ); + +/** `{ refused, status, code }` for the `/sso/register` gate. */ +const ssoVerdict = async (manager: AuthManager, bearer: string) => { + const res = await ssoRegister(manager, bearer); + const body = await jsonOf(res); + const code = body?.code ?? body?.error?.code; + return { refused: res.status === 403 && code === 'SSO_REGISTER_FORBIDDEN', status: res.status, code }; +}; + +const impersonate = (manager: AuthManager, bearer: string, userId: string) => + manager.handleRequest( + new Request(`${BASE}/admin/impersonate-user`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', authorization: `Bearer ${bearer}` }, + body: JSON.stringify({ userId }), + }), + ); + +/** `{ refused, status, code }` for the `/admin/impersonate-user` gate. */ +const impersonateVerdict = async (manager: AuthManager, bearer: string, userId: string) => { + const res = await impersonate(manager, bearer, userId); + const body = await jsonOf(res); + const code = body?.code ?? body?.error?.code; + return { + refused: res.status === 403 && code === 'YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS', + status: res.status, + code, + }; +}; + +// ── fixture seeding ───────────────────────────────────────────────────────── + +interface StandingShape { + /** Rows written into `sys_permission_set` BEFORE `admin_full_access`. */ + decoySets?: number; + /** ADR-0049: the `admin_full_access` catalogue row is switched off. */ + deactivatedSet?: boolean; + /** ADR-0091: the grant carries a `valid_until` already in the past. */ + expiredGrant?: boolean; + /** ADR-0068 D2 says PLATFORM standing is the ORG-LESS link; this scopes it. */ + orgScopedGrant?: boolean; +} + +const seedPlatformAdmin = async (engine: any, userId: string, shape: StandingShape = {}) => { + for (let i = 0; i < (shape.decoySets ?? 0); i += 1) { + await engine.insert('sys_permission_set', { id: `ps_decoy_${i}`, name: `decoy_set_${i}` }); + } + await engine.insert('sys_permission_set', { + id: PS_ADMIN, + name: ADMIN_FULL_ACCESS, + ...(shape.deactivatedSet ? { active: false } : {}), + }); + await engine.insert('sys_user_permission_set', { + user_id: userId, + permission_set_id: PS_ADMIN, + organization_id: shape.orgScopedGrant ? 'org_pin' : null, + ...(shape.expiredGrant ? { valid_until: new Date(Date.now() - 60_000).toISOString() } : {}), + }); +}; + +/** An org OWNER/ADMIN — administrative inside one tenant, and nothing more. */ +const seedOrgAdmin = async (engine: any, userId: string, role: 'owner' | 'admin') => { + await engine.insert('sys_organization', { id: 'org_pin', name: 'Pin Org', slug: 'pin-org' }); + await engine.insert('sys_member', { organization_id: 'org_pin', user_id: userId, role }); +}; + +/** + * A principal whose resolved posture is TENANT_ADMIN — the strongest near-miss + * there is. It holds the `organization_admin` capability grant, org-scoped. + */ +const seedTenantAdmin = async (engine: any, userId: string) => { + await engine.insert('sys_organization', { id: 'org_pin', name: 'Pin Org', slug: 'pin-org' }); + await engine.insert('sys_member', { organization_id: 'org_pin', user_id: userId, role: 'owner' }); + await engine.insert('sys_permission_set', { id: PS_ORG_ADMIN, name: 'organization_admin' }); + await engine.insert('sys_user_permission_set', { + user_id: userId, + permission_set_id: PS_ORG_ADMIN, + organization_id: 'org_pin', + }); +}; + +/** + * Two signed-in principals over one engine: `caller` (the one under test) and + * `target` (an ordinary user for the impersonation body). The caller's standing + * is seeded by `seed` BEFORE sign-in, so the session it gets is the one the + * product would mint for that standing. + */ +const arrange = async (seed?: (engine: any, callerId: string) => Promise) => { + const engine = createMemoryEngine(); + const manager = makeManager(engine); + + await signUp(manager, 'caller@example.com', 'Caller'); + await signUp(manager, 'target@example.com', 'Target'); + + const callerId = userIdFor(engine, 'caller@example.com'); + const targetId = userIdFor(engine, 'target@example.com'); + if (seed) await seed(engine, callerId); + + // The premise every case below rests on: standing is never the legacy scalar. + // If a future seed starts writing `role: 'admin'`, the admissions would go + // green through the vendor's own gate instead of ObjectStack's. + const callerRow = userRows(engine).find((r) => String(r.id) === callerId); + expect(callerRow.role ?? 'user').not.toBe('admin'); + + const callerBearer = bearerFrom(await signIn(manager, 'caller@example.com')); + // `target` holds NO standing of any kind — the in-test control for every + // "admitted" case: the same gate, same engine, same body, refused. + const targetBearer = bearerFrom(await signIn(manager, 'target@example.com')); + return { engine, manager, callerId, targetId, callerBearer, targetBearer }; +}; + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => vi.restoreAllMocks()); + +// ─────────────────────────────────────────────────────────────────────────── +// PIN 1 — the genuine ADR-0068 D2 platform admin is admitted at BOTH gates. +// ─────────────────────────────────────────────────────────────────────────── +describe('a genuine platform admin (ADR-0068 D2 org-less admin_full_access) is ADMITTED', () => { + it('/sso/register admits (and refuses the standing-less control on the same engine)', async () => { + const { manager, callerBearer, targetBearer } = await arrange((e, id) => seedPlatformAdmin(e, id)); + const v = await ssoVerdict(manager, callerBearer); + expect(v, JSON.stringify(v)).toMatchObject({ refused: false }); + expect(await ssoVerdict(manager, targetBearer)).toMatchObject({ + refused: true, + status: 403, + code: 'SSO_REGISTER_FORBIDDEN', + }); + }); + + it('/admin/impersonate-user admits', async () => { + const { manager, callerBearer, targetId } = await arrange((e, id) => seedPlatformAdmin(e, id)); + const res = await impersonate(manager, callerBearer, targetId); + expect(res.status, await res.clone().text()).toBe(200); + expect((await jsonOf(res))?.user?.id).toBe(targetId); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// PIN 2 — the #10009/#10390 boundary: administrative INSIDE an org is not +// platform standing. Without this, a widened implementation scores green on +// PIN 1 alone. +// ─────────────────────────────────────────────────────────────────────────── +describe('an org owner / org admin / TENANT_ADMIN principal is NOT admitted', () => { + const nearMisses: Array<[string, (e: any, id: string) => Promise]> = [ + ['org owner (sys_member.role = owner)', (e, id) => seedOrgAdmin(e, id, 'owner')], + ['org admin (sys_member.role = admin)', (e, id) => seedOrgAdmin(e, id, 'admin')], + ['TENANT_ADMIN posture (organization_admin capability grant)', seedTenantAdmin], + ['an ORG-SCOPED admin_full_access grant', (e, id) => seedPlatformAdmin(e, id, { orgScopedGrant: true })], + ]; + + for (const [label, seed] of nearMisses) { + it(`/sso/register refuses ${label}`, async () => { + const { manager, callerBearer } = await arrange(seed); + const v = await ssoVerdict(manager, callerBearer); + expect(v, JSON.stringify(v)).toMatchObject({ refused: true, status: 403, code: 'SSO_REGISTER_FORBIDDEN' }); + }); + + it(`/admin/impersonate-user refuses ${label}`, async () => { + const { manager, callerBearer, targetId } = await arrange(seed); + const v = await impersonateVerdict(manager, callerBearer, targetId); + expect(v, JSON.stringify(v)).toMatchObject({ + refused: true, + status: 403, + code: 'YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS', + }); + }); + } + + it('a plain member with no standing at all is refused at both gates', async () => { + const { manager, callerBearer, targetId } = await arrange(); + expect(await ssoVerdict(manager, callerBearer)).toMatchObject({ refused: true, code: 'SSO_REGISTER_FORBIDDEN' }); + expect(await impersonateVerdict(manager, callerBearer, targetId)).toMatchObject({ + refused: true, + code: 'YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS', + }); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// PIN 3 — fail-closed floor. +// ─────────────────────────────────────────────────────────────────────────── +describe('fail-closed floor', () => { + it('an empty user id is refused WITHOUT reading anything', async () => { + const engine = createMemoryEngine(); + const manager = makeManager(engine); + const findSpy = vi.spyOn(engine, 'find'); + expect(await (manager as any).isPlatformAdminUserId('')).toBe(false); + expect(findSpy).not.toHaveBeenCalled(); + }); + + it('an unknown user id is refused', async () => { + const engine = createMemoryEngine(); + const manager = makeManager(engine); + expect(await (manager as any).isPlatformAdminUserId('usr_does_not_exist')).toBe(false); + }); + + it('a standing lookup that THROWS is refused at both gates (never an exception out of the gate)', async () => { + const { engine, manager, callerBearer, targetId } = await arrange((e, id) => seedPlatformAdmin(e, id)); + + // Break the standing reads only — after sign-in, so the session itself is + // real. Anything the resolver reads to answer "is this a platform admin" + // now fails; the gate must refuse rather than admit or throw. + const realFind = engine.find.bind(engine); + engine.find = async (name: string, q: any = {}) => { + if (name.startsWith('sys_user_permission_set') || name === 'sys_permission_set') { + throw new Error('standing lookup unavailable'); + } + return realFind(name, q); + }; + + expect(await ssoVerdict(manager, callerBearer)).toMatchObject({ refused: true, code: 'SSO_REGISTER_FORBIDDEN' }); + expect(await impersonateVerdict(manager, callerBearer, targetId)).toMatchObject({ + refused: true, + code: 'YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS', + }); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// PIN 4 (#10949 axis ①) — a grant that has lapsed, and a catalogue row that has +// been switched off, authorize NOTHING. Four distinct refusals. +// ─────────────────────────────────────────────────────────────────────────── +describe('ADR-0091 validity window — an EXPIRED admin_full_access grant authorizes nothing', () => { + it('/sso/register refuses the expired grant', async () => { + const { manager, callerBearer } = await arrange((e, id) => seedPlatformAdmin(e, id, { expiredGrant: true })); + const v = await ssoVerdict(manager, callerBearer); + expect(v, JSON.stringify(v)).toMatchObject({ refused: true, status: 403, code: 'SSO_REGISTER_FORBIDDEN' }); + }); + + it('/admin/impersonate-user refuses the expired grant', async () => { + const { manager, callerBearer, targetId } = await arrange((e, id) => + seedPlatformAdmin(e, id, { expiredGrant: true }), + ); + const v = await impersonateVerdict(manager, callerBearer, targetId); + expect(v, JSON.stringify(v)).toMatchObject({ + refused: true, + status: 403, + code: 'YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS', + }); + }); +}); + +describe('ADR-0049 active flag — a DEACTIVATED admin_full_access row authorizes nothing', () => { + it('/sso/register refuses while the catalogue row is switched off', async () => { + const { manager, callerBearer } = await arrange((e, id) => seedPlatformAdmin(e, id, { deactivatedSet: true })); + const v = await ssoVerdict(manager, callerBearer); + expect(v, JSON.stringify(v)).toMatchObject({ refused: true, status: 403, code: 'SSO_REGISTER_FORBIDDEN' }); + }); + + it('/admin/impersonate-user refuses while the catalogue row is switched off', async () => { + const { manager, callerBearer, targetId } = await arrange((e, id) => + seedPlatformAdmin(e, id, { deactivatedSet: true }), + ); + const v = await impersonateVerdict(manager, callerBearer, targetId); + expect(v, JSON.stringify(v)).toMatchObject({ + refused: true, + status: 403, + code: 'YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS', + }); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// PIN 5 (#10949 axis ②) — the row is resolved by IDENTITY. A catalogue bigger +// than any fixed page must not demote the platform admin. +// ─────────────────────────────────────────────────────────────────────────── +describe('admin_full_access is resolved by identity, not by scanning a page of the catalogue', () => { + it(`/sso/register still admits with ${DECOY_PERMISSION_SETS} other permission sets ahead of it`, async () => { + const { manager, callerBearer, targetBearer } = await arrange((e, id) => + seedPlatformAdmin(e, id, { decoySets: DECOY_PERMISSION_SETS }), + ); + const v = await ssoVerdict(manager, callerBearer); + expect(v, JSON.stringify(v)).toMatchObject({ refused: false }); + expect(await ssoVerdict(manager, targetBearer)).toMatchObject({ + refused: true, + status: 403, + code: 'SSO_REGISTER_FORBIDDEN', + }); + }); + + it(`/admin/impersonate-user still admits with ${DECOY_PERMISSION_SETS} other permission sets ahead of it`, async () => { + const { manager, callerBearer, targetId } = await arrange((e, id) => + seedPlatformAdmin(e, id, { decoySets: DECOY_PERMISSION_SETS }), + ); + const res = await impersonate(manager, callerBearer, targetId); + expect(res.status, await res.clone().text()).toBe(200); + }); + + it('the same catalogue does NOT start admitting a plain member (the pin is not vacuous)', async () => { + const { manager, callerBearer, targetId } = await arrange(async (e) => { + for (let i = 0; i < DECOY_PERMISSION_SETS; i += 1) { + await e.insert('sys_permission_set', { id: `ps_decoy_${i}`, name: `decoy_set_${i}` }); + } + await e.insert('sys_permission_set', { id: PS_ADMIN, name: ADMIN_FULL_ACCESS }); + }); + expect(await ssoVerdict(manager, callerBearer)).toMatchObject({ refused: true, code: 'SSO_REGISTER_FORBIDDEN' }); + expect(await impersonateVerdict(manager, callerBearer, targetId)).toMatchObject({ refused: true }); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// PIN 6 — verdict parity on the session payload. `customSession` derives +// `positions[]` / `isPlatformAdmin` from the same question, and the answer for +// every shape above must be the SAME answer the gates give. +// ─────────────────────────────────────────────────────────────────────────── +describe('the session payload agrees with the gates, shape for shape', () => { + const payloadFor = async (manager: AuthManager, bearer: string) => { + const auth: any = await manager.getAuthInstance(); + const session = await auth.api + .getSession({ headers: new Headers({ authorization: `Bearer ${bearer}` }) }) + .catch(() => null); + return session?.user ?? null; + }; + + it('a genuine platform admin carries platform_admin', async () => { + const { manager, callerBearer } = await arrange((e, id) => seedPlatformAdmin(e, id)); + const user = await payloadFor(manager, callerBearer); + expect(user?.isPlatformAdmin).toBe(true); + expect(user?.positions).toContain('platform_admin'); + }); + + it('an org owner does NOT carry platform_admin', async () => { + const { manager, callerBearer } = await arrange((e, id) => seedOrgAdmin(e, id, 'owner')); + const user = await payloadFor(manager, callerBearer); + expect(user?.isPlatformAdmin).toBe(false); + expect(user?.positions ?? []).not.toContain('platform_admin'); + }); + + it('an EXPIRED grant does NOT carry platform_admin', async () => { + const { manager, callerBearer } = await arrange((e, id) => seedPlatformAdmin(e, id, { expiredGrant: true })); + const user = await payloadFor(manager, callerBearer); + expect(user?.isPlatformAdmin).toBe(false); + expect(user?.positions ?? []).not.toContain('platform_admin'); + }); + + it('a DEACTIVATED admin_full_access row does NOT carry platform_admin', async () => { + const { manager, callerBearer } = await arrange((e, id) => seedPlatformAdmin(e, id, { deactivatedSet: true })); + const user = await payloadFor(manager, callerBearer); + expect(user?.isPlatformAdmin).toBe(false); + expect(user?.positions ?? []).not.toContain('platform_admin'); + }); + + it(`still carries platform_admin with ${DECOY_PERMISSION_SETS} other permission sets ahead of it`, async () => { + const { manager, callerBearer } = await arrange((e, id) => + seedPlatformAdmin(e, id, { decoySets: DECOY_PERMISSION_SETS }), + ); + const user = await payloadFor(manager, callerBearer); + expect(user?.isPlatformAdmin).toBe(true); + expect(user?.positions).toContain('platform_admin'); + }); + + it('the stored role scalar is still never overwritten (ADR-0068 D2)', async () => { + const { manager, callerBearer } = await arrange((e, id) => seedPlatformAdmin(e, id)); + const user = await payloadFor(manager, callerBearer); + expect(user?.role ?? 'user').not.toBe('admin'); + }); +});