diff --git a/.changeset/request-scoped-grants-memo.md b/.changeset/request-scoped-grants-memo.md new file mode 100644 index 00000000000..434b3cbd634 --- /dev/null +++ b/.changeset/request-scoped-grants-memo.md @@ -0,0 +1,22 @@ +--- +'@objectstack/core': patch +'@objectstack/plugin-auth': patch +--- + +perf(core,plugin-auth): an authenticated request resolves its caller's grants once, not twice + +Clause-②: no + +`resolveAuthzContext` learns who the caller is from the transport's `getSession`. Against `@objectstack/plugin-auth` that is better-auth's `getSession`, whose `customSession` hook resolves the principal's grants for the session payload's `positions[]` and `isPlatformAdmin`; `resolveAuthzContext` then resolved the same grants again, with the same arguments, for the request's envelope. Every authenticated request on a door that resolves identity through `resolveAuthzContext` with a better-auth session read (the runtime dispatcher and the REST server among them) paid every grant read twice: eight reads per resolution for a caller in an organization. + +`resolveAuthzContext` now runs inside a request-scoped grants memo. A resolution that already completed inside the same call, with the same user, organization and seeds, is served to the next caller that asks for exactly that resolution, so the hook's resolution serves the resolver's own. The decision a request is authorised with is unchanged: + +- The memo lives for one `resolveAuthzContext` call (an `AsyncLocalStorage` scope, closed when the call settles). Nothing is cached across requests; the cross-request grants cache keeps its own default-off switch, `OS_AUTHZ_GRANTS_CACHE_TTL_MS`. +- An entry is served only when a fresh read would agree with it. No write may have started, been executed at the driver, or still be in flight on the engine since the first resolution opened: the engine's write epoch (bumped when a write starts) and a write observer the memo registers as an engine middleware (counting writes into the chain and, after the driver step, out of it) must both read what they read at the open, with nothing inside the chain. The observer sees statement execution, not commit visibility. A write inside an `engine.transaction()` becomes visible only at its COMMIT, outside every chain. On driver-sql (not on the Turso remote face, which opens no transaction), a request whose step 2 falls inside that one commit round trip is authorised as of its first resolution, and the next request reads fresh — the same answer as a write from another process. No grant validity boundary may lie between the two clocks. The organization must be the same (the session arm's dropped-claim re-resolution is its own entry). A `bypassGrantsCache` caller is never served, a failed resolution is never stored, and an engine without the write-epoch seam or `registerMiddleware` declines entirely. +- `plugin-auth`'s hook now passes the session's email as its seed email, exactly as `resolveAuthzContext` does for the same session, so the two calls ask for the same resolution. The seed reaches only the envelope's `email`, which the hook does not read: the payload's `positions[]` and `isPlatformAdmin` are unchanged, and platform-admin standing still compares the stored `sys_user.email`, never a seed. + +Where the saving does not apply, the request reads the grants twice, as before — always the safe direction: + +- a request that meets a concurrent write on its engine; +- a request whose session read itself writes: the first request on a fresh auth instance generates its signing key, and with an idle timeout configured `enforceSessionControls` stamps `sys_session.last_activity_at` about once a minute per session; +- the first `resolveAuthzContext` call on an engine, which registers the write observer and stores nothing for it. diff --git a/packages/core/src/security/request-grants-memo.ts b/packages/core/src/security/request-grants-memo.ts new file mode 100644 index 00000000000..dd88ea4ee3c --- /dev/null +++ b/packages/core/src/security/request-grants-memo.ts @@ -0,0 +1,334 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The request-scoped grants memo — one {@link resolveAuthzContext} call + * resolves a principal's grants ONCE, however many readers inside it ask. + * + * ## Why a resolution is asked for twice inside one request + * + * `resolveAuthzContext` learns WHO the caller is by calling the transport's + * `getSession`. Against `@objectstack/plugin-auth` that call runs better-auth's + * `getSession` endpoint, whose `customSession` hook builds the session + * payload's `positions[]` / `isPlatformAdmin` by asking + * `resolveUserAuthzGrants` — the one authority, on purpose (a second + * derivation there is the drift `resolve-authz-context.ts` exists to end). + * `resolveAuthzContext` then resolves the same principal's grants AGAIN, with + * the same arguments, to build the request's envelope. Nothing is written + * between the two on a healthy request, so the second resolution re-issues + * every grant read of the first and gets the same rows back. Measured + * downstream on a hosted composition: 16 of the 23 serial tenant-DB round + * trips an authenticated request makes before its handler were these two + * resolutions, eight each. + * + * ## The scope — one `resolveAuthzContext` call, never longer + * + * `resolveAuthzContext` opens a scope ({@link withRequestGrantsMemo}) around its + * whole body, the `getSession` call included, so the hook's resolution and the + * resolver's own run inside the SAME scope; `resolveUserAuthzGrants` consults + * it ({@link openRequestGrantsMemo}). The scope is an `AsyncLocalStorage` + * store, so two requests interleaving across their awaits each see only their + * own, and it is CLOSED when `resolveAuthzContext` settles: a continuation that + * outlives the resolution (a background task the session read started) reads + * nothing and stores nothing. No envelope survives the request — ⛔ this is not + * a cache and must not become one; the cross-request cache is + * `resolve-user-grants-cache.ts`, behind its own ruled default-off switch. The + * only module-level state is the per-engine write observer below: two + * counters per engine, no grant data. + * + * A host whose async context does not propagate (WebContainer's + * `node:async_hooks`) simply finds no scope, and every resolution is fresh — + * the behaviour before this module existed. + * + * ## What an entry answers for — it is served only when a fresh read would agree + * + * - **Same call.** The key is the `resolveUserAuthzGrants` arguments that + * shape the answer — user, tenant, seed email, seed permissions — spelled as + * the cross-request cache spells them, so a different organization (the + * session arm's claim-drop re-resolution), a different seed or a different + * user is a different entry, and the engine (`ql`) is the outer key. Seeds + * are part of the key because they are part of the answer (see + * `resolve-user-grants-cache.ts`, "Keying"). + * - **No write has landed, or is landing, since the resolution opened.** Two + * signals, both read when the resolution OPENS (before its first read) and + * again when the entry is looked up: + * 1. The engine's write epoch. The engine bumps it when a write STARTS, + * ahead of its middleware chain (and for non-write reasons: a declared + * permission set, a peer's hint). It says nothing about when a write + * LANDS, so on its own it would serve an entry read while a write that + * had already bumped was still in flight, after that write landed. + * 2. The engine write observer: a middleware this module registers on + * first sight of an engine, counting every write that enters it and, + * in a `finally` around `next()`, every write whose driver step has + * settled. The driver step runs INSIDE that `next()`, so a write cannot + * be EXECUTED at the driver without first moving `started`, and cannot + * finish executing without moving `completed`. The observer sees + * statement execution, not commit visibility: on an + * `engine.transaction()` the rows become visible to other connections + * only at the driver COMMIT, outside every chain (residuals below). + * An entry is served only when the epoch, `started` and `completed` all read + * what they read at the open AND no write is inside the observer + * (`started === completed`). So a write that started before the open and + * lands after it, one that starts after it, and one still in flight at the + * lookup all make step 2 read afresh. A `ql` without the epoch seam or + * without `registerMiddleware` declines entirely: an answer whose staleness + * cannot be observed is not served, not even for milliseconds. + * - **No validity boundary in between.** An ADR-0091 window flips with no + * write at all, so an entry is served only to a call whose clock lies in + * `[resolvedAt, nextBoundary)` — the interval on which every `isGrantActive` + * verdict the resolution made is unchanged. + * - **Bypass is bypass.** A `bypassGrantsCache` caller reads nothing from the + * memo and writes nothing into it, exactly as it treats the grants cache. + * - **Failures are not remembered.** Only a resolution that completed is + * stored; a read that threw (`AuthzStoreUnavailableError`) leaves the next + * caller to issue its own reads, as before. + * + * Served values are clones — the hook puts its `positions` array into the + * session payload and the resolver puts its own into the request context, and + * downstream code may mutate either; the two must never alias. + * + * ⚠️ What the observer cannot see, stated exactly. The engine snapshots its + * middleware list when a write starts, so a write that STARTED before the + * observer was registered on that engine never passes it. The observer is + * registered at the entry of the first `resolveAuthzContext` call that names + * the engine (and on first sight of any other engine a resolution reads), and + * the scope that registers it stores nothing for that engine — that request + * reads twice. What stays open is narrower: a write that began before that + * first call and is still in flight when a LATER request's resolution opens, + * landing between that resolution and its step 2. It is invisible to the + * epoch (it bumped before) and to the observer (it never enters it). Writes + * from another process are invisible here too: their rows are read as of the + * first resolution, a few milliseconds earlier than the second used to read + * them — the same answer a write committing just after the second read always + * got. + * + * The same holds for a write executed on an `engine.transaction()` whose + * COMMIT lands between the first resolution and step 2. Its statements pass + * the observer, which settles per statement, but the rows become visible only + * at the driver commit, which runs outside every middleware chain. The window + * is that one commit round trip after the transaction's last statement. It is + * reachable on driver-sql deployments (SCIM through the better-auth adapter, + * REST `/batch`), and not on the Turso remote face, which declares + * `transactionsUnsupported` and so opens no transaction. The answer is the + * out-of-process one: that request is authorised as of the first resolution, + * and the next request reads fresh. Closing it engine-side (an objectql epoch + * bump after an owned transaction's commit) is out of scope here. + * + * The cost: on an engine with a concurrent write, step 2 reads afresh. That + * includes a session read that writes inside the request (the first request on + * a fresh auth instance generates its signing key; `enforceSessionControls` + * stamps `sys_session.last_activity_at` about once a minute per session when an + * idle timeout is configured) — the safe direction, with no saving on that + * request. + */ + +import { AsyncLocalStorage } from 'node:async_hooks'; + +import type { ResolveUserAuthzGrantsOptions, UserAuthzGrants } from './resolve-authz-context.js'; + +// ── The engine write observer ──────────────────────────────────────────────── + +/** + * Per-engine write counters, advanced by {@link observeEngineWrites}'s + * middleware. `started - completed` is the number of writes inside the + * observer right now. + */ +interface EngineWriteObserver { + started: number; + completed: number; +} + +/** + * Keyed on the engine instance: two engines in one process never share + * counters, and a dropped engine takes its counters with it. `null` records an + * engine whose middleware registration THREW — poisoned, never memoised + * behind, as the grants cache poisons a half-wired seam. + */ +const engineWriteObservers = new WeakMap(); + +/** + * The engine operations that read. Everything else the chain runs is counted + * as a write — including an operation this list has never heard of, so a new + * write verb is observed by default (the safe direction: a new READ verb would + * only cost a declined memo while one is in flight). The engine's own epoch + * advances for `insert` / `update` / `delete`. + */ +const READ_OPERATIONS: ReadonlySet = new Set(['find', 'findOne', 'count', 'aggregate']); + +interface MiddlewareSeam { + registerMiddleware?: unknown; +} + +/** + * Fetch — and on first sight of an engine, register — the write observer. + * Returns `{ observer, attachedNow }`, or `undefined` for an engine without + * `registerMiddleware` and for one whose registration threw. + */ +function observeEngineWrites(ql: object): { observer: EngineWriteObserver; attachedNow: boolean } | undefined { + const existing = engineWriteObservers.get(ql); + if (existing !== undefined) return existing ? { observer: existing, attachedNow: false } : undefined; + + const register = (ql as MiddlewareSeam).registerMiddleware; + if (typeof register !== 'function') return undefined; + + const observer: EngineWriteObserver = { started: 0, completed: 0 }; + try { + (register as ( + fn: (ctx: { operation?: unknown }, next: () => Promise) => Promise, + ) => void).call(ql, async (ctx, next) => { + const operation = ctx?.operation; + if (typeof operation === 'string' && READ_OPERATIONS.has(operation)) return next(); + observer.started += 1; + try { + await next(); + } finally { + observer.completed += 1; + } + }); + } catch { + engineWriteObservers.set(ql, null); + return undefined; + } + engineWriteObservers.set(ql, observer); + return { observer, attachedNow: true }; +} + +// ── The request scope ──────────────────────────────────────────────────────── + +interface RequestGrantsMemoEntry { + /** A private clone of the resolved envelope. Never handed out directly. */ + value: UserAuthzGrants; + /** The engine write epoch read when the resolution opened, before any read. */ + epochAtOpen: number; + /** The observer's `started` when the resolution opened. */ + startedAtOpen: number; + /** The observer's `completed` when the resolution opened. */ + completedAtOpen: number; + /** The clock every `isGrantActive` verdict of the resolution was taken at. */ + resolvedAtMs: number; + /** The earliest validity boundary after `resolvedAtMs`, if any row has one. */ + nextBoundaryMs: number | undefined; +} + +interface RequestGrantsMemoScope { + /** False once the owning `resolveAuthzContext` call has settled. */ + open: boolean; + /** Outer key: the engine. Inner key: {@link memoKey}. */ + entries: WeakMap>; + /** + * Engines whose observer THIS scope registered. A write already in flight at + * registration never passes the observer, so this scope stores nothing for + * them. + */ + attachedHere: WeakSet; +} + +const scopeStorage = new AsyncLocalStorage(); + +/** + * Run `fn` — one `resolveAuthzContext` body — inside a fresh memo scope, and + * close the scope when it settles. Nested calls each get their own scope. + * `ql` is the engine the body resolves against (`undefined` when it carries no + * write epoch, so the memo can never serve there); its write observer is + * registered here, before any read, when this is the first call to name it. + */ +export async function withRequestGrantsMemo(ql: unknown, fn: () => Promise): Promise { + const scope: RequestGrantsMemoScope = { open: true, entries: new WeakMap(), attachedHere: new WeakSet() }; + if (ql && typeof ql === 'object' && observeEngineWrites(ql)?.attachedNow) scope.attachedHere.add(ql); + try { + return await scopeStorage.run(scope, fn); + } finally { + scope.open = false; + scope.entries = new WeakMap(); + } +} + +/** + * JSON, not delimiters — a seed permission is caller-supplied text and must not + * be able to alias another key by containing a separator. The spelling is the + * grants cache's (`grantsCacheKey`): `null` and `undefined` collapse, which is + * behaviour-preserving because the resolver only ever tests the tenant and the + * seed email for truthiness, and an absent seed list resolves exactly as `[]`. + */ +function memoKey(userId: string, opts: ResolveUserAuthzGrantsOptions): string { + return JSON.stringify([ + userId, + opts.tenantId ?? null, + opts.seedEmail ?? null, + Array.isArray(opts.seedPermissions) ? opts.seedPermissions : [], + ]); +} + +/** One resolution's interaction with the memo, opened at the top of `resolveUserAuthzGrants`. */ +export interface RequestGrantsMemoAttempt { + /** The envelope an earlier resolution in this request produced, cloned for the caller. */ + hit?: UserAuthzGrants; + /** + * Store a freshly resolved envelope. `resolvedAtMs` is the clock the + * resolution's validity verdicts were taken at; `nextBoundaryMs` is + * `nextGrantValidityBoundary` over the rows those verdicts were taken on. + */ + commit(grants: UserAuthzGrants, resolvedAtMs: number, nextBoundaryMs: number | undefined): void; +} + +/** + * Open the memo for one resolution. Returns `undefined` — the plain fresh + * path, no side effects beyond registering the engine's write observer — outside + * a `resolveAuthzContext` scope or after it closed, for a `bypassGrantsCache` + * caller, and for a `ql` that is not an object, carries no write epoch + * (`epochNow` undefined) or cannot register a middleware. + * + * `epochNow` is the engine's write epoch as `resolveUserAuthzGrants` reads it + * (`readWriteEpoch`), passed in rather than re-derived so that the one + * structural check of that seam stays where it is. + */ +export function openRequestGrantsMemo( + ql: unknown, + userId: string, + opts: ResolveUserAuthzGrantsOptions, + epochNow: number | undefined, +): RequestGrantsMemoAttempt | undefined { + if (opts.bypassGrantsCache) return undefined; + const scope = scopeStorage.getStore(); + if (!scope || !scope.open) return undefined; + if (!ql || typeof ql !== 'object' || epochNow === undefined) return undefined; + const observed = observeEngineWrites(ql); + if (!observed) return undefined; + if (observed.attachedNow) scope.attachedHere.add(ql); + const { observer } = observed; + + const key = memoKey(userId, opts); + const now = opts.nowMs ?? Date.now(); + const existing = scope.entries.get(ql)?.get(key); + if ( + existing + && existing.epochAtOpen === epochNow + && existing.startedAtOpen === observer.started + && existing.completedAtOpen === observer.completed + && observer.started === observer.completed + && existing.resolvedAtMs <= now + && (existing.nextBoundaryMs === undefined || now < existing.nextBoundaryMs) + ) { + return { hit: structuredClone(existing.value), commit: () => {} }; + } + + const startedAtOpen = observer.started; + const completedAtOpen = observer.completed; + return { + commit(grants, resolvedAtMs, nextBoundaryMs) { + if (!scope.open || scope.attachedHere.has(ql)) return; + let perEngine = scope.entries.get(ql); + if (!perEngine) { + perEngine = new Map(); + scope.entries.set(ql, perEngine); + } + perEngine.set(key, { + value: structuredClone(grants), + epochAtOpen: epochNow, + startedAtOpen, + completedAtOpen, + resolvedAtMs, + nextBoundaryMs, + }); + }, + }; +} diff --git a/packages/core/src/security/resolve-authz-context.request-grants-memo.test.ts b/packages/core/src/security/resolve-authz-context.request-grants-memo.test.ts new file mode 100644 index 00000000000..2e76142b67e --- /dev/null +++ b/packages/core/src/security/resolve-authz-context.request-grants-memo.test.ts @@ -0,0 +1,766 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The request-scoped grants memo (`request-grants-memo.ts`): inside one + * `resolveAuthzContext` call, a session read that resolved the principal's + * grants — plugin-auth's `customSession` hook does, for the payload's + * `positions[]` — serves the resolver's own step 2 instead of every grant read + * being issued a second time. + * + * What these pins hold, in the order the suite states them: + * + * 1. EQUIVALENCE, per caller class. For every principal shape the resolver + * discriminates — platform administrator (both anchors), organization + * owner, admin and member, a non-member whose claimed organization is + * dropped (walled) or stands (single), an API-key principal with scopes and + * a stamped organization, and an anonymous request — the envelope a request + * resolves with the memo serving step 2 is deep-equal to the envelope step + * 2 resolves on its own. "On its own" is the baseline here because, before + * the memo, step 2 never saw the session read's resolution at all: the + * baseline's session read resolves nothing, so its step 2 is exactly the + * computation the pre-memo code ran. + * 2. THE COUNT. With the memo, one request issues exactly ONE resolution's + * grant reads; through an engine without the write-epoch seam (the memo + * declines there) it issues two — the pre-memo count, measured in the + * same suite. The first request on an engine registers the write observer + * and reads twice. + * 3. ISOLATION. Two interleaved requests from different callers keep their + * own grants; nothing outlives the request; a continuation the request + * started reads afresh once the request settled; a session read against a + * second engine and a nested `resolveAuthzContext` serve nothing to the + * outer step 2. + * 4. FRESHNESS. A write that bumped the epoch BEFORE the first resolution + * opened and lands before step 2, a write still in flight at step 2, a + * write that starts and lands in between, a non-write epoch bump, a + * validity boundary between the two clocks, a `bypass` caller and a failed + * first read all make step 2 read afresh — and a step-2 clock later than + * the first resolution's but inside its validity window is served. + * 5. NO ALIASING. The session payload's arrays and the envelope's are + * distinct objects. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { hashApiKey } from './api-key.js'; +import { resetPlatformAdminEmailMemo } from './platform-admin.js'; +import { + resolveAuthzContext, + resolveUserAuthzGrants, + type ResolvedAuthzContext, + type UserAuthzGrants, +} from './resolve-authz-context.js'; +import { makeRecordingQl, type RecordedCall } from './__tests__/resolve-authz-context.batch-equivalence.testkit.js'; + +const T0 = Date.UTC(2026, 0, 1); +const HOUR = 3_600_000; +const DAY = 86_400_000; +const iso = (ms: number) => new Date(ms).toISOString(); +const API_KEY = 'osk_request_memo_key'; + +const ENV_KEYS = ['OS_TENANCY_POSTURE', 'OS_MULTI_ORG_ENABLED', 'OS_PLATFORM_OWNER_EMAIL'] as const; +let ambient: Record = {}; + +beforeEach(() => { + ambient = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + for (const k of ENV_KEYS) delete process.env[k]; + resetPlatformAdminEmailMemo(); +}); + +afterEach(() => { + for (const k of ENV_KEYS) { + if (ambient[k] === undefined) delete process.env[k]; + else process.env[k] = ambient[k]; + } + resetPlatformAdminEmailMemo(); +}); + +/** Every grant row the matrix needs, fresh per call so a test may mutate its copy. */ +function makeTables(): Record { + return { + sys_user: [ + { id: 'u_padmin', email: 'padmin@x.com' }, + { id: 'u_root', email: 'root@x.com', email_verified: true }, + { id: 'u_owner', email: 'owner@x.com' }, + { id: 'u_admin', email: 'admin@x.com' }, + { id: 'u_member', email: 'member@x.com', ai_access: 1 }, + { id: 'u_outsider', email: 'outsider@x.com' }, + ], + sys_member: [ + { user_id: 'u_owner', organization_id: 'org_a', role: 'owner' }, + { user_id: 'u_admin', organization_id: 'org_a', role: 'admin' }, + { user_id: 'u_member', organization_id: 'org_a', role: 'member' }, + { user_id: 'u_member', organization_id: 'org_b', role: 'owner' }, + { user_id: 'u_outsider', organization_id: 'org_b', role: 'member' }, + ], + sys_user_position: [ + { user_id: 'u_member', position: 'auditor', organization_id: 'org_a' }, + { user_id: 'u_member', position: 'temp_role', organization_id: null, valid_until: iso(T0 + DAY) }, + { user_id: 'u_outsider', position: 'auditor', organization_id: 'org_a' }, + ], + sys_position: [ + { id: 'p_auditor', name: 'auditor', organization_id: 'org_a' }, + { id: 'p_temp', name: 'temp_role', organization_id: null }, + { id: 'p_orgadmin', name: 'org_admin', organization_id: 'org_a' }, + { id: 'p_everyone', name: 'everyone', organization_id: 'org_a' }, + ], + sys_position_permission_set: [ + { position_id: 'p_auditor', permission_set_id: 'ps_read' }, + { position_id: 'p_temp', permission_set_id: 'ps_temp' }, + { position_id: 'p_orgadmin', permission_set_id: 'ps_orgadmin' }, + { position_id: 'p_everyone', permission_set_id: 'ps_base' }, + ], + sys_user_permission_set: [ + { user_id: 'u_padmin', permission_set_id: 'ps_admin', organization_id: null }, + { user_id: 'u_member', permission_set_id: 'ps_tools', organization_id: 'org_a' }, + ], + sys_permission_set: [ + { id: 'ps_admin', name: 'admin_full_access', system_permissions: ['manage_users'] }, + { id: 'ps_read', name: 'read_all', tab_permissions: { crm: 'visible' } }, + { id: 'ps_temp', name: 'temp_tools' }, + { id: 'ps_orgadmin', name: 'organization_admin', tab_permissions: { crm: 'default_on' } }, + { id: 'ps_base', name: 'base_access', tab_permissions: { crm: 'default_off' } }, + { id: 'ps_tools', name: 'org_tools', system_permissions: '["export_reports"]' }, + ], + // An API key owned by the organization member, stamped with org_a, with two scopes. + sys_api_key: [ + { + id: 'key_member', + key: hashApiKey(API_KEY), + revoked: false, + user_id: 'u_member', + active_organization_id: 'org_a', + scopes: '["data:read","reports:export"]', + }, + ], + }; +} + +/** A write-epoch seam shaped like `@objectstack/objectql`'s (`readWriteEpoch` checks all three members). */ +function makeEpoch() { + return { + current: 0, + bump(_reason: string) { this.current += 1; }, + subscribe(_listener: (epoch: number, reason: string) => void) { return () => {}; }, + }; +} + +type Middleware = (ctx: { object: string; operation: string }, next: () => Promise) => Promise; + +type Ql = ReturnType & { + writeEpoch?: ReturnType; + registerMiddleware?: (fn: Middleware) => void; + /** How many middlewares are registered — the observer is one. */ + middlewareCount: () => number; + /** + * A write, run the way `ObjectQL.executeWithMiddleware` runs one: the epoch + * is bumped FIRST, synchronously, then the middleware chain runs and `land` + * — the driver step — is the innermost call. + */ + write: (object: string, operation: 'insert' | 'update' | 'delete', land: () => Promise) => Promise; +}; + +/** Run `ctx` through `middlewares`, `innermost` at the bottom — the engine's onion. */ +async function runChain(middlewares: Middleware[], ctx: { object: string; operation: string }, innermost: () => Promise) { + const applicable = [...middlewares]; + let index = 0; + const next = async (): Promise => { + if (index < applicable.length) { + const mw = applicable[index++]; + await mw(ctx, next); + } else { + await innermost(); + } + }; + await next(); +} + +/** + * The recording double, with the two engine seams the memo requires: the + * write epoch and `registerMiddleware`. Reads go through the middleware chain, + * as the engine's do; writes go through {@link Ql.write}. + */ +function makeQl(tables: Record, opts: { epoch?: boolean; middleware?: boolean } = {}): Ql { + const recording = makeRecordingQl(tables); + const middlewares: Middleware[] = []; + const find = recording.find.bind(recording); + const ql = recording as Ql; + if (opts.epoch !== false) ql.writeEpoch = makeEpoch(); + if (opts.middleware !== false) ql.registerMiddleware = (fn: Middleware) => { middlewares.push(fn); }; + ql.middlewareCount = () => middlewares.length; + ql.find = async (object: string, q: any) => { + let rows: any; + await runChain(middlewares, { object, operation: 'find' }, async () => { rows = await find(object, q); }); + return rows; + }; + ql.write = (object, operation, land) => { + ql.writeEpoch?.bump('write'); + return runChain(middlewares, { object, operation }, land); + }; + return ql; +} + +/** + * A double the memo has already seen: one anonymous request (no reads) has + * registered the write observer, so the NEXT request is the first that may be + * served — the request that registers it stores nothing for that engine. + */ +async function armedQl(tables: Record): Promise { + const ql = makeQl(tables); + await resolveAuthzContext({ ql, headers: {} }); + expect(ql.calls.length).toBe(0); + expect(ql.middlewareCount()).toBe(1); + return ql; +} + +interface SessionFixture { + user: { id: string; email?: string }; + session: { id: string; activeOrganizationId: string | null; token: string }; +} + +const sessionOf = (userId: string, email: string, activeOrganizationId: string | null): SessionFixture => ({ + user: { id: userId, email }, + session: { id: `sess_${userId}`, activeOrganizationId, token: `tok_${userId}` }, +}); + +/** + * A session read that resolves the principal's grants the way plugin-auth's + * `customSession` hook asks for them: the session's active organization as the + * tenant and the session's email as the seed. `nowMs` is injected only so the + * fixtures' validity windows are deterministic; the memo keys on neither clock. + */ +function sessionReadThatResolves( + ql: Ql, + fixture: SessionFixture | null, + nowMs: number = T0, + sink?: { payload?: any; grants?: UserAuthzGrants }, +) { + return async () => { + if (!fixture) return null; + let grants: UserAuthzGrants | undefined; + try { + grants = await resolveUserAuthzGrants(ql, fixture.user.id, { + tenantId: fixture.session.activeOrganizationId ?? undefined, + seedEmail: fixture.user.email ? String(fixture.user.email) : undefined, + nowMs, + }); + } catch { + // The hook fails closed to an empty positions[] and lets the request go on. + } + const payload = { + user: { ...fixture.user, positions: grants?.positions ?? [], isPlatformAdmin: grants?.posture === 'PLATFORM_ADMIN' }, + session: fixture.session, + }; + if (sink) { sink.payload = payload; sink.grants = grants; } + return payload; + }; +} + +/** The same session, read without resolving anything — the pre-memo step 2's only input. */ +function sessionReadOnly(fixture: SessionFixture | null) { + return async () => (fixture ? { user: { ...fixture.user }, session: fixture.session } : null); +} + +const callKey = (c: RecordedCall) => JSON.stringify([c.object, c.where, c.limit]); +const multiset = (calls: RecordedCall[]) => calls.map(callKey).sort(); + +function deferred() { + let resolve!: (v: T) => void; + const promise = new Promise((r) => { resolve = r; }); + return { promise, resolve }; +} + +const withoutMembersAuditor = (rows: any[]) => + rows.filter((r) => !(r.user_id === 'u_member' && r.position === 'auditor')); + +interface CallerClass { + name: string; + fixture: SessionFixture | null; + headers?: Record; + env?: Record; + tenancyPosture?: 'isolated' | 'single'; + /** What the class must resolve to, so the matrix cannot pass on two equally wrong envelopes. */ + expect: (ctx: ResolvedAuthzContext) => void; +} + +const CALLER_CLASSES: CallerClass[] = [ + { + name: 'platform administrator — unscoped admin_full_access grant (single posture)', + fixture: sessionOf('u_padmin', 'padmin@x.com', null), + expect: (ctx) => { + expect(ctx.posture).toBe('PLATFORM_ADMIN'); + expect(ctx.positions[0]).toBe('platform_admin'); + }, + }, + { + name: 'platform administrator — declared administrator email (isolated posture)', + fixture: sessionOf('u_root', 'root@x.com', null), + env: { OS_TENANCY_POSTURE: 'isolated', OS_PLATFORM_OWNER_EMAIL: 'root@x.com' }, + tenancyPosture: 'isolated', + expect: (ctx) => { + expect(ctx.posture).toBe('PLATFORM_ADMIN'); + expect(ctx.permissions).toContain('admin_full_access'); + }, + }, + { + name: 'organization owner', + fixture: sessionOf('u_owner', 'owner@x.com', 'org_a'), + tenancyPosture: 'isolated', + expect: (ctx) => { + expect(ctx.tenantId).toBe('org_a'); + expect(ctx.positions).toContain('org_owner'); + expect(ctx.org_user_ids).toEqual(expect.arrayContaining(['u_owner', 'u_admin', 'u_member'])); + }, + }, + { + name: 'organization admin', + fixture: sessionOf('u_admin', 'admin@x.com', 'org_a'), + tenancyPosture: 'isolated', + expect: (ctx) => { + expect(ctx.positions).toContain('org_admin'); + expect(ctx.posture).toBe('TENANT_ADMIN'); + }, + }, + { + name: 'organization member', + fixture: sessionOf('u_member', 'member@x.com', 'org_a'), + tenancyPosture: 'isolated', + expect: (ctx) => { + expect(ctx.posture).toBe('MEMBER'); + expect(ctx.positions).toEqual(expect.arrayContaining(['org_member', 'auditor', 'temp_role', 'everyone'])); + expect(ctx.permissions).toEqual(expect.arrayContaining(['org_tools', 'read_all', 'temp_tools', 'ai_seat'])); + expect(ctx.accessible_org_ids.sort()).toEqual(['org_a', 'org_b']); + }, + }, + { + name: 'non-member claiming an organization — walled posture drops the claim', + fixture: sessionOf('u_outsider', 'outsider@x.com', 'org_a'), + tenancyPosture: 'isolated', + expect: (ctx) => { + expect(ctx.tenantId).toBeUndefined(); + expect(ctx.positions).not.toContain('auditor'); + expect(ctx.org_user_ids).toEqual(['u_outsider']); + }, + }, + { + name: 'non-member claiming an organization — single posture keeps the claim', + fixture: sessionOf('u_outsider', 'outsider@x.com', 'org_a'), + expect: (ctx) => { + expect(ctx.tenantId).toBe('org_a'); + expect(ctx.positions).not.toContain('org_member'); + }, + }, + { + // The session read is never called for an admitted key: step 2 is the + // request's only resolution, with the key's scopes as seeds. + name: 'API-key principal with scopes and a stamped organization (isolated posture)', + fixture: sessionOf('u_member', 'member@x.com', 'org_a'), + headers: { 'x-api-key': API_KEY }, + tenancyPosture: 'isolated', + expect: (ctx) => { + expect(ctx.userId).toBe('u_member'); + expect(ctx.tenantId).toBe('org_a'); + expect(ctx.permissions.slice(0, 2)).toEqual(['data:read', 'reports:export']); + expect(ctx.positions).toContain('org_member'); + }, + }, + { + name: 'anonymous request', + fixture: null, + expect: (ctx) => { + expect(ctx.userId).toBeUndefined(); + expect(ctx.positions).toEqual([]); + }, + }, +]; + +async function resolveBoth(c: CallerClass) { + for (const [k, v] of Object.entries(c.env ?? {})) process.env[k] = v; + resetPlatformAdminEmailMemo(); + + const memoQl = await armedQl(makeTables()); + const withMemo = await resolveAuthzContext({ + ql: memoQl, + headers: c.headers ?? {}, + getSession: sessionReadThatResolves(memoQl, c.fixture), + nowMs: T0, + tenancyPosture: c.tenancyPosture, + }); + + const baselineQl = makeQl(makeTables()); + const baseline = await resolveAuthzContext({ + ql: baselineQl, + headers: c.headers ?? {}, + getSession: sessionReadOnly(c.fixture), + nowMs: T0, + tenancyPosture: c.tenancyPosture, + }); + return { withMemo, baseline, memoQl, baselineQl }; +} + +describe('request-scoped grants memo — the same decision for every caller class', () => { + for (const c of CALLER_CLASSES) { + it(`${c.name}: same envelope as step 2 resolved on its own, one resolution's reads`, async () => { + const { withMemo, baseline, memoQl, baselineQl } = await resolveBoth(c); + + c.expect(baseline); + expect(withMemo).toEqual(baseline); + // The request issued exactly the reads step 2 issues on its own — the + // session read's resolution WAS step 2's — never those plus a second set. + expect(multiset(memoQl.calls)).toEqual(multiset(baselineQl.calls)); + }); + } +}); + +describe('request-scoped grants memo — the round-trip count', () => { + const member = sessionOf('u_member', 'member@x.com', 'org_a'); + + it('one request issues ONE resolution of grant reads; the pre-memo path (no epoch seam) issues two', async () => { + const standaloneQl = makeQl(makeTables()); + await resolveUserAuthzGrants(standaloneQl, 'u_member', { tenantId: 'org_a', seedEmail: 'member@x.com', nowMs: T0 }); + const oneResolution = standaloneQl.calls.length; + expect(oneResolution).toBe(8); + + const memoQl = await armedQl(makeTables()); + const withMemo = await resolveAuthzContext({ ql: memoQl, headers: {}, getSession: sessionReadThatResolves(memoQl, member), nowMs: T0 }); + expect(memoQl.calls.length).toBe(oneResolution); + expect(multiset(memoQl.calls)).toEqual(multiset(standaloneQl.calls)); + + // An engine without the write-epoch seam: the memo declines, and the + // request makes the two resolutions it made before the memo existed. + const noSeamQl = makeQl(makeTables(), { epoch: false }); + const noSeam = await resolveAuthzContext({ ql: noSeamQl, headers: {}, getSession: sessionReadThatResolves(noSeamQl, member), nowMs: T0 }); + expect(noSeamQl.calls.length).toBe(2 * oneResolution); + expect(noSeam).toEqual(withMemo); + + // …and one that cannot register a middleware (no write observer) declines too. + const noMiddlewareQl = makeQl(makeTables(), { middleware: false }); + const noMiddleware = await resolveAuthzContext({ ql: noMiddlewareQl, headers: {}, getSession: sessionReadThatResolves(noMiddlewareQl, member), nowMs: T0 }); + expect(noMiddlewareQl.calls.length).toBe(2 * oneResolution); + expect(noMiddleware).toEqual(withMemo); + }); + + it('the request that registers the engine\'s write observer stores nothing for it; the next one is served', async () => { + const ql = makeQl(makeTables()); + expect(ql.middlewareCount()).toBe(0); + const first = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0 }); + expect(ql.middlewareCount()).toBe(1); + expect(ql.calls.length).toBe(16); + const before = ql.calls.length; + const second = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0 }); + expect(ql.calls.length - before).toBe(8); + expect(ql.middlewareCount()).toBe(1); + expect(second).toEqual(first); + }); +}); + +describe('request-scoped grants memo — isolation', () => { + it('two interleaved requests from different callers keep their own grants', async () => { + const ql = await armedQl(makeTables()); + const owner = sessionOf('u_owner', 'owner@x.com', 'org_a'); + const member = sessionOf('u_member', 'member@x.com', 'org_b'); + + // Both session reads resolve and commit before EITHER step 2 runs, so + // each step 2 looks up its entry while the other caller's exists too. + const ownerRead = deferred(); + const memberRead = deferred(); + const release = deferred(); + const gated = (read: () => Promise, done: { resolve: () => void }) => async () => { + const payload = await read(); + done.resolve(); + await release.promise; + return payload; + }; + + const pOwner = resolveAuthzContext({ ql, headers: {}, getSession: gated(sessionReadThatResolves(ql, owner), ownerRead), nowMs: T0, tenancyPosture: 'isolated' }); + const pMember = resolveAuthzContext({ ql, headers: {}, getSession: gated(sessionReadThatResolves(ql, member), memberRead), nowMs: T0, tenancyPosture: 'isolated' }); + await Promise.all([ownerRead.promise, memberRead.promise]); + release.resolve(); + const [ownerCtx, memberCtx] = await Promise.all([pOwner, pMember]); + + const baselineOwner = await resolveAuthzContext({ ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(owner), nowMs: T0, tenancyPosture: 'isolated' }); + const baselineMember = await resolveAuthzContext({ ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0, tenancyPosture: 'isolated' }); + expect(ownerCtx).toEqual(baselineOwner); + expect(memberCtx).toEqual(baselineMember); + expect(ownerCtx.userId).toBe('u_owner'); + expect(memberCtx.positions).toContain('org_owner'); // the member OWNS org_b + expect(ownerCtx.org_user_ids).not.toContain('u_outsider'); + expect(memberCtx.org_user_ids).toEqual(expect.arrayContaining(['u_member', 'u_outsider'])); + // Two requests, one resolution each. + expect(ql.calls.length).toBe(16); + }); + + it('nothing outlives the request: the next request reads the grants as they are now', async () => { + const tables = makeTables(); + const ql = await armedQl(tables); + const member = sessionOf('u_member', 'member@x.com', 'org_a'); + + const first = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0, tenancyPosture: 'isolated' }); + expect(first.positions).toContain('auditor'); + + // Revoked with NO epoch bump and outside the chain: what retires the first + // request's resolution is the end of that request, and nothing else. + tables.sys_user_position = withoutMembersAuditor(tables.sys_user_position); + const before = ql.calls.length; + const second = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0, tenancyPosture: 'isolated' }); + expect(second.positions).not.toContain('auditor'); + expect(second.permissions).not.toContain('read_all'); + expect(ql.calls.length - before).toBe(8); + }); + + it('a continuation the request started reads afresh once the request settled', async () => { + const ql = await armedQl(makeTables()); + const member = sessionOf('u_member', 'member@x.com', 'org_a'); + const settled = deferred(); + let late: Promise | undefined; + + const read = sessionReadThatResolves(ql, member); + await resolveAuthzContext({ + ql, + headers: {}, + getSession: async () => { + const payload = await read(); + // Started INSIDE the request's async context, run after it settled. + late = settled.promise.then(() => resolveUserAuthzGrants(ql, 'u_member', { tenantId: 'org_a', seedEmail: 'member@x.com', nowMs: T0 })); + return payload; + }, + nowMs: T0, + tenancyPosture: 'isolated', + }); + const before = ql.calls.length; + settled.resolve(); + await late; + expect(ql.calls.length - before).toBe(8); + }); + + it('a session read resolved against a SECOND engine serves nothing to the first engine\'s step 2', async () => { + const qlA = await armedQl(makeTables()); + const qlB = await armedQl(makeTables()); + const member = sessionOf('u_member', 'member@x.com', 'org_a'); + const ctx = await resolveAuthzContext({ + ql: qlA, + headers: {}, + getSession: sessionReadThatResolves(qlB, member), + nowMs: T0, + tenancyPosture: 'isolated', + }); + expect(qlB.calls.length).toBe(8); + expect(qlA.calls.length).toBe(8); + const baseline = await resolveAuthzContext({ ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0, tenancyPosture: 'isolated' }); + expect(ctx).toEqual(baseline); + }); + + it('a nested resolveAuthzContext inside the session read serves nothing to the outer step 2', async () => { + const ql = await armedQl(makeTables()); + const member = sessionOf('u_member', 'member@x.com', 'org_a'); + const ctx = await resolveAuthzContext({ + ql, + headers: {}, + getSession: async () => { + // The inner call resolves twice-over-once in ITS OWN scope: its session + // read's resolution serves its step 2 (8 reads), and that scope closes. + const inner = await resolveAuthzContext({ + ql, + headers: {}, + getSession: sessionReadThatResolves(ql, member), + nowMs: T0, + tenancyPosture: 'isolated', + }); + expect(inner.userId).toBe('u_member'); + return sessionReadOnly(member)(); + }, + nowMs: T0, + tenancyPosture: 'isolated', + }); + // Inner 8, outer step 2 afresh 8. + expect(ql.calls.length).toBe(16); + const baseline = await resolveAuthzContext({ ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0, tenancyPosture: 'isolated' }); + expect(ctx).toEqual(baseline); + }); +}); + +describe('request-scoped grants memo — step 2 reads afresh whenever a fresh read could differ', () => { + const member = sessionOf('u_member', 'member@x.com', 'org_a'); + const postWriteBaseline = async (nowMs = T0) => { + const after = makeTables(); + after.sys_user_position = withoutMembersAuditor(after.sys_user_position); + return resolveAuthzContext({ ql: makeQl(after), headers: {}, getSession: sessionReadOnly(member), nowMs, tenancyPosture: 'isolated' }); + }; + + it('a revocation that bumped the epoch BEFORE the first resolution opened and lands before step 2', async () => { + const tables = makeTables(); + const ql = await armedQl(tables); + const landing = deferred(); + // W: the engine bumps the epoch and enters the chain NOW; its driver step + // waits for `landing`, then the row is gone. + const w = ql.write('sys_user_position', 'delete', async () => { + await landing.promise; + tables.sys_user_position = withoutMembersAuditor(tables.sys_user_position); + }); + const read = sessionReadThatResolves(ql, member); + const ctx = await resolveAuthzContext({ + ql, + headers: {}, + getSession: async () => { + // The hook's resolution opens AFTER W's bump and reads the pre-W rows… + const payload = await read(); + expect(payload?.user.positions).toContain('auditor'); + // …then W lands, before step 2. + landing.resolve(); + await w; + return payload; + }, + nowMs: T0, + tenancyPosture: 'isolated', + }); + // The decision first: the request is authorised WITHOUT the revoked row… + expect(ctx.positions).not.toContain('auditor'); + expect(ctx).toEqual(await postWriteBaseline()); + // …because step 2 read afresh after the landing. + expect(ql.calls.length).toBe(16); + }); + + it('a write that bumped before the first resolution and is still in flight at step 2', async () => { + const tables = makeTables(); + const ql = await armedQl(tables); + const landing = deferred(); + const w = ql.write('sys_user_position', 'delete', async () => { + await landing.promise; + tables.sys_user_position = withoutMembersAuditor(tables.sys_user_position); + }); + const ctx = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0, tenancyPosture: 'isolated' }); + expect(ql.calls.length).toBe(16); + // Not landed yet: a fresh read still sees the row, and so did step 2. + expect(ctx.positions).toContain('auditor'); + landing.resolve(); + await w; + }); + + it('a write that starts and lands between the two resolutions', async () => { + const tables = makeTables(); + const ql = await armedQl(tables); + const read = sessionReadThatResolves(ql, member); + const ctx = await resolveAuthzContext({ + ql, + headers: {}, + getSession: async () => { + const payload = await read(); + await ql.write('sys_user_position', 'delete', async () => { + tables.sys_user_position = withoutMembersAuditor(tables.sys_user_position); + }); + return payload; + }, + nowMs: T0, + tenancyPosture: 'isolated', + }); + expect(ctx.positions).not.toContain('auditor'); + expect(ql.calls.length).toBe(16); + expect(ctx).toEqual(await postWriteBaseline()); + }); + + it('a non-write epoch bump between the two resolutions (a peer\'s hint, a declared permission set)', async () => { + const ql = await armedQl(makeTables()); + const read = sessionReadThatResolves(ql, member); + await resolveAuthzContext({ + ql, + headers: {}, + getSession: async () => { + const payload = await read(); + ql.writeEpoch!.bump('remote'); + return payload; + }, + nowMs: T0, + tenancyPosture: 'isolated', + }); + expect(ql.calls.length).toBe(16); + }); + + it('a write that starts while the first resolution is still reading', async () => { + const ql = await armedQl(makeTables()); + const find = ql.find.bind(ql); + let started = false; + let w: Promise | undefined; + ql.find = async (object: string, opts: any) => { + if (!started && object === 'sys_position') { started = true; w = ql.write('sys_audit_log', 'insert', async () => {}); } + return find(object, opts); + }; + await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0, tenancyPosture: 'isolated' }); + await w; + expect(ql.calls.length).toBe(16); + }); + + it('a step-2 clock later than the first resolution\'s, inside its validity window, is served', async () => { + // temp_role is valid until T0 + 1 day: T0 + 1 hour is inside the window. + const ql = await armedQl(makeTables()); + const ctx = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member, T0), nowMs: T0 + HOUR, tenancyPosture: 'isolated' }); + expect(ql.calls.length).toBe(8); + expect(ctx.positions).toContain('temp_role'); + const baseline = await resolveAuthzContext({ ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0 + HOUR, tenancyPosture: 'isolated' }); + expect(ctx).toEqual(baseline); + }); + + it('a validity boundary between the two clocks, and a clock before the first resolution', async () => { + // The session read resolves at T0 (temp_role active until T0 + 1 day); + // step 2 asks at T0 + 2 days, past that boundary. + const ql = await armedQl(makeTables()); + const ctx = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member, T0), nowMs: T0 + 2 * DAY, tenancyPosture: 'isolated' }); + expect(ctx.positions).not.toContain('temp_role'); + expect(ctx.permissions).not.toContain('temp_tools'); + expect(ql.calls.length).toBe(16); + const baseline = await resolveAuthzContext({ ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0 + 2 * DAY, tenancyPosture: 'isolated' }); + expect(ctx).toEqual(baseline); + + // A step-2 clock EARLIER than the resolution it would be served. + const ql2 = await armedQl(makeTables()); + await resolveAuthzContext({ ql: ql2, headers: {}, getSession: sessionReadThatResolves(ql2, member, T0), nowMs: T0 - DAY, tenancyPosture: 'isolated' }); + expect(ql2.calls.length).toBe(16); + }); + + it('a bypassGrantsCache caller is never served from the memo', async () => { + const ql = await armedQl(makeTables()); + const read = sessionReadThatResolves(ql, member); + await resolveAuthzContext({ + ql, + headers: {}, + getSession: async () => { + const payload = await read(); + await resolveUserAuthzGrants(ql, 'u_member', { tenantId: 'org_a', seedEmail: 'member@x.com', nowMs: T0, bypassGrantsCache: true }); + return payload; + }, + nowMs: T0, + tenancyPosture: 'isolated', + }); + // session read 8 + the bypass caller's own 8 + step 2 served (0). + expect(ql.calls.length).toBe(16); + }); + + it('a first resolution that failed is not remembered', async () => { + const ql = await armedQl(makeTables()); + const find = ql.find.bind(ql); + let failed = false; + ql.find = async (object: string, opts: any) => { + if (!failed && object === 'sys_user_permission_set') { failed = true; throw new Error('connection reset'); } + return find(object, opts); + }; + const sink: { payload?: any; grants?: UserAuthzGrants } = {}; + const ctx = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member, T0, sink), nowMs: T0, tenancyPosture: 'isolated' }); + expect(sink.grants).toBeUndefined(); + const baseline = await resolveAuthzContext({ ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0, tenancyPosture: 'isolated' }); + expect(ctx).toEqual(baseline); + }); +}); + +describe('request-scoped grants memo — served values are clones', () => { + it('the session payload and the request envelope never share an array', async () => { + const ql = await armedQl(makeTables()); + const sink: { payload?: any; grants?: UserAuthzGrants } = {}; + const member = sessionOf('u_member', 'member@x.com', 'org_a'); + const ctx = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member, T0, sink), nowMs: T0, tenancyPosture: 'isolated' }); + expect(ql.calls.length).toBe(8); + expect(ctx.positions).toEqual(sink.payload.user.positions); + expect(ctx.positions).not.toBe(sink.payload.user.positions); + ctx.positions.push('mutated_downstream'); + ctx.permissions.push('mutated_downstream'); + expect(sink.payload.user.positions).not.toContain('mutated_downstream'); + expect(sink.grants!.permissions).not.toContain('mutated_downstream'); + }); +}); diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 30f90006020..fed2632781c 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -66,6 +66,7 @@ import { resolveApiKeyAdmission } from './api-key.js'; import type { ApiKeyRefusalReason } from './api-key.js'; import { isGrantActive, nextGrantValidityBoundary } from './grant-validity.js'; import { openUserGrantsCache } from './resolve-user-grants-cache.js'; +import { openRequestGrantsMemo, withRequestGrantsMemo } from './request-grants-memo.js'; import { matchesConfiguredPlatformAdmin, resolvePlatformAdminEmails } from './platform-admin.js'; import { derivePosture } from './posture-ladder.js'; import { isRowActive } from './row-active.js'; @@ -348,8 +349,23 @@ async function tryFind( * never-provisioned one. A transport that fails closed on unexpected throws should re-raise this * one ({@link isAuthzStoreUnavailableError}) rather than degrade it to a * refusal — degrading it restores the disguise the ruling removed. + * + * The whole resolution — the `getSession` call included — runs inside one + * request-scoped grants memo (`request-grants-memo.ts`): when the session read + * itself resolved this principal's grants (plugin-auth's `customSession` hook + * does, for the payload's `positions[]`) with the arguments step 2 below uses, + * step 2 is served that resolution instead of issuing every grant read again. + * The scope closes when this call settles; nothing outlives the request. */ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise { + // The engine is handed to the scope only when it carries the write-epoch seam: + // without it the memo can never serve, so its write observer is never + // registered there — no footprint on such an engine at all. + const memoEngine = readWriteEpoch(input.ql) === undefined ? undefined : input.ql; + return withRequestGrantsMemo(memoEngine, () => resolveAuthzContextInScope(input)); +} + +async function resolveAuthzContextInScope(input: ResolveAuthzInput): Promise { const { ql, headers } = input; const ctx: ResolvedAuthzContext = { positions: [], @@ -731,6 +747,18 @@ export async function resolveUserAuthzGrants( // ADR-0091 validity boundary, or the TTL — whichever comes first. The // attempt snapshots its generation and clock HERE, before any read is // issued, so a write landing mid-resolution kills the entry on arrival. + // + // Consulted first, the request-scoped memo (`request-grants-memo.ts`): inside + // one `resolveAuthzContext` call, a resolution with these exact arguments that + // already completed — with no write started, landed or in flight on this + // engine since it opened, and no validity boundary since its clock — is + // served instead of re-read. The epoch and the engine's write counters are + // read HERE, before any read of this resolution is issued. Outside that + // scope (every direct caller of this function) it is `undefined` and nothing + // changes. + const requestMemo = openRequestGrantsMemo(ql, userId, opts, readWriteEpoch(ql)); + if (requestMemo?.hit) return requestMemo.hit; + const grantsCache = openUserGrantsCache(ql, userId, opts); if (grantsCache?.hit) return grantsCache.hit; @@ -1156,11 +1184,14 @@ export async function resolveUserAuthzGrants( // position assignments, user-bound set grants — INCLUDING currently-inactive // rows, because a future `valid_from` is a flip the timer must catch too. // Peer rows (`orgMembersLeg`) feed `org_user_ids` with no validity check, so - // they contribute no boundary. - grantsCache?.commit( - grants, - nextGrantValidityBoundary([...members, ...userPositionRows, ...upsRowsAll], nowMs), - ); + // they contribute no boundary. The request-scoped memo stores the same + // envelope under the same boundary, stamped with the clock the verdicts above + // were taken at; the scan runs only when at least one of the two is open. + if (grantsCache || requestMemo) { + const nextBoundaryMs = nextGrantValidityBoundary([...members, ...userPositionRows, ...upsRowsAll], nowMs); + grantsCache?.commit(grants, nextBoundaryMs); + requestMemo?.commit(grants, nowMs, nextBoundaryMs); + } return grants; } diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index d73e337307e..5b5d42a0363 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -4073,11 +4073,22 @@ export class AuthManager { // on any lookup error; `activeOrgRoles` caught to `[]`). Warned rather // than swallowed — an empty `positions[]` hides UI, and this card is // about exactly that going unannounced. + // + // The seed email is the session's own, spelled as `resolveAuthzContext` + // spells it for this session (`String(user.email)` when present), so an + // in-process session read that `resolveAuthzContext` makes asks for + // EXACTLY the resolution its own step 2 asks for — and core's + // request-scoped memo serves that step this resolution instead of + // issuing every grant read twice per request. It shapes nothing read + // here: `grants.email` is the only output it reaches, and the + // platform-admin config anchor compares the STORED `sys_user.email`, + // never a seed (core §6b-config). let positions: string[] = []; let platformAdmin = false; try { const grants = await resolveUserAuthzGrants(dataEngine as any, user.id, { tenantId: (session as any)?.activeOrganizationId ?? undefined, + seedEmail: user.email ? String(user.email) : undefined, }); positions = grants.positions; platformAdmin = grants.posture === 'PLATFORM_ADMIN'; diff --git a/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts b/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts new file mode 100644 index 00000000000..b1693d1b072 --- /dev/null +++ b/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts @@ -0,0 +1,352 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// One request, one resolution of the caller's grants — through the REAL hook. +// +// `resolveAuthzContext` (core) learns who the caller is from the transport's +// `getSession`. Against this plugin that is better-auth's `getSession`, whose +// `customSession` hook (`auth-manager.ts`) resolves the principal's grants for +// the payload's `positions[]`; `resolveAuthzContext` then resolves them again +// for the request envelope. Core's request-scoped memo serves the second from +// the first — but only when both ask for the SAME resolution, so the hook must +// pass exactly the arguments `resolveAuthzContext` passes for that session. +// Core's own suite pins the memo against a stand-in hook; only this suite can +// show that the real hook and the real resolver agree on those arguments. +// +// The measurement is a read count on the engine double, taken around one +// `resolveAuthzContext` call whose `getSession` is the real better-auth one: +// +// - with the engine's write-epoch and middleware seams present (the double +// runs them the way the engine does), on a warm engine, the grant tables +// are read by ONE resolution; +// - with the seam removed, core's memo declines and the same request reads +// them twice — the count before the memo existed, measured on the same +// engine and the same session; +// - the two envelopes are deep-equal, per caller class. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +// [#10126] A static import, so this dist-resolved workspace dep's first +// transform is paid at module load rather than inside a clocked test body. +import { resolveAuthzContext } from '@objectstack/core'; +import { AuthManager } from './auth-manager'; +// The SAME double the security-axis suite drives (a second engine double would +// be a second looseness risk and a new `check:engine-double-contract` row). +import { createMemoryEngine } from './impersonation-bearer-rotation.test'; +import { inviteForAudienceGate } from './audience-gate-test-support'; + +const SECRET = 'test-secret-at-least-32-chars-long!!'; +const PASSWORD = 'S3cure!Passw0rd-grants-once'; +const BASE = 'http://localhost:3000/api/v1/auth'; +const ORG = 'org_once'; +const OTHER_ORG = 'org_other'; + +/** The tables only the grant resolver reads — better-auth's own session read never touches them. */ +const GRANT_ONLY_TABLES = new Set([ + 'sys_user_position', + 'sys_user_permission_set', + 'sys_position', + 'sys_position_permission_set', + 'sys_permission_set', +]); + +const signUp = (manager: AuthManager, email: string, name: string) => { + inviteForAudienceGate(manager, email); + return 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 userIdFor = (engine: any, email: string): string => { + const row = ((engine.tables.get('sys_user') ?? []) as any[]).find((r) => r.email === email); + if (!row) throw new Error(`no sys_user row for ${email}`); + return String(row.id); +}; + +/** + * Give the double the two engine seams core's memo requires, run the way + * `ObjectQL.executeWithMiddleware` runs them: a write bumps the epoch FIRST, + * then the middleware chain runs with the driver step innermost; reads go + * through the same chain. Plus a read/write recorder switched on only around + * the request being measured. Wraps the instance's own methods; the double + * itself is unchanged. + */ +function instrument(engine: any) { + const epoch = { + current: 0, + bump(_reason: string) { this.current += 1; }, + subscribe(_listener: (epoch: number, reason: string) => void) { return () => {}; }, + }; + engine.writeEpoch = epoch; + type Mw = (ctx: { object: string; operation: string }, next: () => Promise) => Promise; + const middlewares: Mw[] = []; + engine.registerMiddleware = (fn: Mw) => { middlewares.push(fn); }; + const chain = async (ctx: { object: string; operation: string }, innermost: () => Promise) => { + const applicable = [...middlewares]; + let index = 0; + let result: unknown; + const next = async (): Promise => { + if (index < applicable.length) await applicable[index++](ctx, next); + else result = await innermost(); + }; + await next(); + return result; + }; + const reads: string[] = []; + const writes: string[] = []; + let recording = false; + for (const verb of ['insert', 'update', 'delete'] as const) { + const original = engine[verb].bind(engine); + engine[verb] = async (...args: any[]) => { + epoch.bump('write'); + if (recording) writes.push(`${verb} ${String(args[0])}`); + return chain({ object: String(args[0]), operation: verb }, () => original(...args)); + }; + } + for (const verb of ['findOne', 'count'] as const) { + const original = engine[verb].bind(engine); + engine[verb] = async (...args: any[]) => chain({ object: String(args[0]), operation: verb }, () => original(...args)); + } + const originalFind = engine.find.bind(engine); + engine.find = async (name: string, q?: any) => { + if (recording) reads.push(name); + return chain({ object: name, operation: 'find' }, () => originalFind(name, q)); + }; + return { + reads, + middlewareCount: () => middlewares.length, + async measure(fn: () => Promise): Promise<{ value: T; reads: string[]; writes: string[] }> { + reads.length = 0; + writes.length = 0; + recording = true; + try { + const value = await fn(); + return { value, reads: [...reads], writes: [...writes] }; + } finally { + recording = false; + } + }, + }; +} + +const arrange = async () => { + const engine: any = createMemoryEngine(); + const probe = instrument(engine); + const manager = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', dataEngine: engine } as any); + + await signUp(manager, 'owner@example.com', 'Org Owner'); + await signUp(manager, 'member@example.com', 'Org Member'); + await signUp(manager, 'leaver@example.com', 'Removed Member'); + await signUp(manager, 'operator@example.com', 'Platform Operator'); + const ownerId = userIdFor(engine, 'owner@example.com'); + const memberId = userIdFor(engine, 'member@example.com'); + const leaverId = userIdFor(engine, 'leaver@example.com'); + const operatorId = userIdFor(engine, 'operator@example.com'); + + await engine.insert('sys_organization', { id: ORG, name: 'Once Org', slug: 'once-org' }); + await engine.insert('sys_organization', { id: OTHER_ORG, name: 'Other Org', slug: 'other-org' }); + await engine.insert('sys_member', { organization_id: ORG, user_id: ownerId, role: 'owner' }); + await engine.insert('sys_member', { organization_id: ORG, user_id: memberId, role: 'member' }); + // The leaver's only membership at sign-in is ORG, so sign-in stamps ORG as + // the session's active organization (the test removes it afterwards). + await engine.insert('sys_member', { id: 'mem_leaver', organization_id: ORG, user_id: leaverId, role: 'member' }); + + // A position held in the active organization, carrying a permission set. + await engine.insert('sys_position', { id: 'pos_reviewer', name: 'reviewer', organization_id: ORG }); + await engine.insert('sys_permission_set', { id: 'ps_review', name: 'review_tools' }); + await engine.insert('sys_position_permission_set', { position_id: 'pos_reviewer', permission_set_id: 'ps_review' }); + await engine.insert('sys_user_position', { user_id: memberId, position: 'reviewer', organization_id: ORG }); + await engine.insert('sys_user_position', { user_id: leaverId, position: 'reviewer', organization_id: ORG }); + + // The platform operator: an UNSCOPED admin_full_access user grant (the + // single-posture anchor; no tenancy posture is configured here). + await engine.insert('sys_permission_set', { id: 'ps_admin', name: 'admin_full_access' }); + await engine.insert('sys_user_permission_set', { user_id: operatorId, permission_set_id: 'ps_admin', organization_id: null }); + + const bearers = { + owner: bearerFrom(await signIn(manager, 'owner@example.com')), + member: bearerFrom(await signIn(manager, 'member@example.com')), + leaver: bearerFrom(await signIn(manager, 'leaver@example.com')), + operator: bearerFrom(await signIn(manager, 'operator@example.com')), + }; + return { engine, probe, manager, bearers, ids: { ownerId, memberId, leaverId, operatorId } }; +}; + +/** + * A WARM kernel: the first session read on a fresh auth instance generates the + * JWT signing key (`sys_jwks` insert) — a write inside the request, which the + * memo must and does decline across (pinned below). Every later request finds + * the key, writes nothing, and is the case the count is about. + */ +const arrangeWarm = async () => { + const a = await arrange(); + // One request through the resolver: it generates the signing key AND + // registers core's write observer on the engine (that request stores nothing + // for the engine, and reads twice). Every later request is the warm one. + const warm = await resolveRequest(a, a.bearers.owner); + expect(warm.writes).toContain('insert sys_jwks'); + expect(a.probe.middlewareCount()).toBe(1); + return a; +}; + +type Arranged = Awaited>; + +/** One request through the real resolver with the real better-auth session read. */ +async function resolveRequest(a: Arranged, bearer: string | null, tenancyPosture?: 'isolated') { + const auth: any = await a.manager.getAuthInstance(); + const headers = new Headers(bearer ? { authorization: `Bearer ${bearer}` } : {}); + return a.probe.measure(() => + resolveAuthzContext({ + ql: a.engine, + headers, + getSession: (h: any) => auth.api.getSession({ headers: h }), + tenancyPosture, + }), + ); +} + +/** The same request with the epoch seam removed: core's memo declines, as before it existed. */ +async function resolveRequestWithoutMemo(a: Arranged, bearer: string | null, tenancyPosture?: 'isolated') { + const seam = a.engine.writeEpoch; + delete a.engine.writeEpoch; + try { + return await resolveRequest(a, bearer, tenancyPosture); + } finally { + a.engine.writeEpoch = seam; + } +} + +const grantOnlyReads = (reads: string[]) => reads.filter((n) => GRANT_ONLY_TABLES.has(n)); +const countOf = (reads: string[], name: string) => reads.filter((n) => n === name).length; + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => vi.restoreAllMocks()); + +describe('the real customSession hook and resolveAuthzContext resolve the caller\'s grants once per request', () => { + it('organization member: one resolution of grant reads, the same envelope as the two-resolution path', async () => { + const a = await arrangeWarm(); + const once = await resolveRequest(a, a.bearers.member); + const twice = await resolveRequestWithoutMemo(a, a.bearers.member); + + // The population, stated: a warm request (nothing written inside it), a + // session that claims the organization, and a position that resolves — so + // the count below is over a real resolution. + expect(once.writes).toEqual([]); + expect(once.value.tenantId).toBe(ORG); + expect(once.value.positions).toEqual(expect.arrayContaining(['org_member', 'reviewer', 'everyone'])); + expect(once.value.permissions).toContain('review_tools'); + + expect(once.value).toEqual(twice.value); + // Leg 1 reads each user-keyed grant table exactly once per resolution. + expect(countOf(once.reads, 'sys_user_position')).toBe(1); + expect(countOf(once.reads, 'sys_user_permission_set')).toBe(1); + expect(countOf(twice.reads, 'sys_user_position')).toBe(2); + expect(countOf(twice.reads, 'sys_user_permission_set')).toBe(2); + // Every grant-only read the memo saved, and nothing else. + expect(grantOnlyReads(twice.reads).length).toBe(2 * grantOnlyReads(once.reads).length); + }); + + it('the request saves exactly one resolution\'s reads — eight for a caller in an organization', async () => { + const a = await arrangeWarm(); + const once = await resolveRequest(a, a.bearers.member); + const twice = await resolveRequestWithoutMemo(a, a.bearers.member); + // sys_user, sys_member (own), sys_user_position, sys_member (peers), + // sys_user_permission_set, sys_position, sys_position_permission_set, + // sys_permission_set. + expect(twice.reads.length - once.reads.length).toBe(8); + }); + + it('organization owner and platform operator: same envelope, one resolution', async () => { + const a = await arrangeWarm(); + for (const bearer of [a.bearers.owner, a.bearers.operator]) { + const once = await resolveRequest(a, bearer); + const twice = await resolveRequestWithoutMemo(a, bearer); + expect(once.value).toEqual(twice.value); + expect(countOf(once.reads, 'sys_user_position')).toBe(1); + expect(countOf(twice.reads, 'sys_user_position')).toBe(2); + } + const operator = await resolveRequest(a, a.bearers.operator); + expect(operator.value.posture).toBe('PLATFORM_ADMIN'); + const owner = await resolveRequest(a, a.bearers.owner); + expect(owner.value.positions).toContain('org_owner'); + }); + + it('a removed member whose session still claims the organization: the claim is dropped exactly as before', async () => { + const a = await arrangeWarm(); + // Offboarded after sign-in — moved to another organization — while the + // live session keeps naming ORG. + await a.engine.delete('sys_member', { where: { id: 'mem_leaver' } }); + await a.engine.insert('sys_member', { organization_id: OTHER_ORG, user_id: a.ids.leaverId, role: 'member' }); + const auth: any = await a.manager.getAuthInstance(); + const claimed = await auth.api.getSession({ headers: new Headers({ authorization: `Bearer ${a.bearers.leaver}` }) }); + expect(claimed?.session?.activeOrganizationId).toBe(ORG); + + const once = await resolveRequest(a, a.bearers.leaver, 'isolated'); + const twice = await resolveRequestWithoutMemo(a, a.bearers.leaver, 'isolated'); + expect(once.value).toEqual(twice.value); + expect(once.value.tenantId).toBeUndefined(); + expect(once.value.positions).not.toContain('reviewer'); + expect(once.value.org_user_ids).toEqual([a.ids.leaverId]); + expect(once.value.accessible_org_ids).toEqual([OTHER_ORG]); + // The hook's resolution (claimed org) serves step 2; the re-resolution with + // NO organization is its own and is read afresh: two, against three. + expect(countOf(once.reads, 'sys_user_position')).toBe(2); + expect(countOf(twice.reads, 'sys_user_position')).toBe(3); + }); + + it('anonymous request: nothing is resolved either way', async () => { + const a = await arrangeWarm(); + const once = await resolveRequest(a, null); + const twice = await resolveRequestWithoutMemo(a, null); + expect(once.value).toEqual(twice.value); + expect(once.value.userId).toBeUndefined(); + expect(grantOnlyReads(once.reads)).toEqual([]); + }); + + it('the session read outside resolveAuthzContext is untouched: the payload still resolves its own positions', async () => { + const a = await arrangeWarm(); + const auth: any = await a.manager.getAuthInstance(); + const { value: payload, reads } = await a.probe.measure(() => + auth.api.getSession({ headers: new Headers({ authorization: `Bearer ${a.bearers.member}` }) }), + ); + expect((payload as any)?.user?.positions).toEqual(expect.arrayContaining(['org_member', 'reviewer'])); + expect(countOf(reads, 'sys_user_position')).toBe(1); + }); + + it('the kernel\'s first request writes its signing key inside the request: the memo declines, the envelope is unchanged', async () => { + const a = await arrange(); + // Register core's write observer with an anonymous request first (no + // session, no key generated), so the ONLY reason left for the member's + // first request to read twice is the write inside it. + const anonymous = await resolveRequest(a, null); + expect(anonymous.writes).toEqual([]); + expect(a.probe.middlewareCount()).toBe(1); + const first = await resolveRequest(a, a.bearers.member); + // The write happened between the hook's resolution and step 2… + expect(first.writes).toContain('insert sys_jwks'); + // …so step 2 read afresh: two resolutions, the pre-memo count. + expect(countOf(first.reads, 'sys_user_position')).toBe(2); + const warm = await resolveRequestWithoutMemo(a, a.bearers.member); + expect(first.value).toEqual(warm.value); + }); +});