From ce8ee025a9daac6990a20ccfbb885863e2a4ea0a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:11:29 +0000 Subject: [PATCH 1/9] perf(core,plugin-auth): one resolveAuthzContext call resolves the session principal's grants once The session read inside resolveAuthzContext runs plugin-auth's customSession hook, which resolves the principal's grants for the payload's positions[]; the resolver then resolved the same grants again with the same arguments. A request-scoped memo (AsyncLocalStorage, closed when the call settles) now serves the second resolution the first one's envelope, keyed by user, tenant and seeds, guarded by the engine write epoch and the next validity boundary. The hook passes the session email as its seed so both calls ask for the same resolution. Claude-Session: https://claude.ai/code/session_01WVbr5J6u8BHh8EyFtcWciH Co-authored-by: Claude --- .../core/src/security/request-grants-memo.ts | 194 ++++++++++++++++++ .../src/security/resolve-authz-context.ts | 36 +++- .../plugins/plugin-auth/src/auth-manager.ts | 11 + 3 files changed, 236 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/security/request-grants-memo.ts 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..b923cbd8b2a --- /dev/null +++ b/packages/core/src/security/request-grants-memo.ts @@ -0,0 +1,194 @@ +// 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. There is no module-level state and nothing + * 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. + * + * 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 in between.** The engine's write epoch is read when the + * resolution OPENS, before its first read, and an entry is served only while + * that epoch has not moved — so a write through this engine that starts + * while the first resolution is reading, or between the two, makes the + * second one read afresh. A `ql` without the epoch seam 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 remains different from issuing every read twice, by design: the + * request's grants are read at the FIRST resolution, a few milliseconds + * earlier than the second used to read them. A write from another process — + * invisible to this engine's epoch — that commits inside those milliseconds is + * seen by the next request instead of this one, the same answer a write + * committing just after the second read always got. + */ + +import { AsyncLocalStorage } from 'node:async_hooks'; + +import type { ResolveUserAuthzGrantsOptions, UserAuthzGrants } from './resolve-authz-context.js'; + +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 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>; +} + +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. + */ +export async function withRequestGrantsMemo(fn: () => Promise): Promise { + const scope: RequestGrantsMemoScope = { open: true, entries: new WeakMap() }; + 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 — outside a `resolveAuthzContext` scope or after it + * closed, for a `bypassGrantsCache` caller, and for a `ql` that is not an + * object or carries no write epoch (`epochNow` undefined). + * + * `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 key = memoKey(userId, opts); + const now = opts.nowMs ?? Date.now(); + const existing = scope.entries.get(ql)?.get(key); + if ( + existing + && existing.epochAtOpen === epochNow + && existing.resolvedAtMs <= now + && (existing.nextBoundaryMs === undefined || now < existing.nextBoundaryMs) + ) { + return { hit: structuredClone(existing.value), commit: () => {} }; + } + + return { + commit(grants, resolvedAtMs, nextBoundaryMs) { + if (!scope.open) 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, + resolvedAtMs, + nextBoundaryMs, + }); + }, + }; +} diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 30f90006020..e901ce16653 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,19 @@ 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 { + return withRequestGrantsMemo(() => resolveAuthzContextInScope(input)); +} + +async function resolveAuthzContextInScope(input: ResolveAuthzInput): Promise { const { ql, headers } = input; const ctx: ResolvedAuthzContext = { positions: [], @@ -731,6 +743,17 @@ 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 through this engine since it opened and + // no validity boundary since its clock — is served instead of re-read. The + // epoch is 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 +1179,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..a3ec2ec9ca3 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 as any)?.email ? String((user as any).email) : undefined, }); positions = grants.positions; platformAdmin = grants.posture === 'PLATFORM_ADMIN'; From 1bb907b87a416ad69e43c6f0ecec867a53cf7670 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:17:02 +0000 Subject: [PATCH 2/9] test(core): pin the request-scoped grants memo per caller class, its count, isolation and freshness Claude-Session: https://claude.ai/code/session_01WVbr5J6u8BHh8EyFtcWciH Co-authored-by: Claude --- ...-authz-context.request-grants-memo.test.ts | 523 ++++++++++++++++++ 1 file changed, 523 insertions(+) create mode 100644 packages/core/src/security/resolve-authz-context.request-grants-memo.test.ts 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..a09f878ff90 --- /dev/null +++ b/packages/core/src/security/resolve-authz-context.request-grants-memo.test.ts @@ -0,0 +1,523 @@ +// 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), 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. + * 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. + * 4. FRESHNESS. A write through the engine between (or during) the two + * resolutions, a validity boundary between their clocks, a `bypass` + * caller and a failed first read all make step 2 read afresh. + * 5. NO ALIASING. The session payload's arrays and the envelope's are + * distinct objects. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +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 DAY = 86_400_000; +const iso = (ms: number) => new Date(ms).toISOString(); + +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"]' }, + ], + }; +} + +/** 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 Ql = ReturnType & { writeEpoch?: ReturnType }; + +/** The recording double, with the engine's write-epoch seam the memo requires. */ +function makeQl(tables: Record, opts: { epoch?: boolean } = {}): Ql { + const ql: Ql = makeRecordingQl(tables); + if (opts.epoch !== false) ql.writeEpoch = makeEpoch(); + 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(); + +interface CallerClass { + name: string; + fixture: SessionFixture | null; + 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'); + }, + }, + { + 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 = makeQl(makeTables()); + const withMemo = await resolveAuthzContext({ + ql: memoQl, + headers: {}, + getSession: sessionReadThatResolves(memoQl, c.fixture), + nowMs: T0, + tenancyPosture: c.tenancyPosture, + }); + + const baselineQl = makeQl(makeTables()); + const baseline = await resolveAuthzContext({ + ql: baselineQl, + 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 = makeQl(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); + }); +}); + +describe('request-scoped grants memo — isolation', () => { + function deferred() { + let resolve!: (v: T) => void; + const promise = new Promise((r) => { resolve = r; }); + return { promise, resolve }; + } + + it('two interleaved requests from different callers keep their own grants', async () => { + const tables = makeTables(); + const ql = makeQl(tables); + 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'])); + }); + + it('nothing outlives the request: the next request reads the grants as they are now', async () => { + const tables = makeTables(); + const ql = makeQl(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: what retires the first request's + // resolution is the end of that request, and nothing else. + tables.sys_user_position = tables.sys_user_position.filter((r) => !(r.user_id === 'u_member' && r.position === 'auditor')); + 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 = makeQl(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); + }); +}); + +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'); + + it('a write through the engine between the two resolutions', async () => { + const tables = makeTables(); + const ql = makeQl(tables); + const read = sessionReadThatResolves(ql, member); + const ctx = await resolveAuthzContext({ + ql, + headers: {}, + getSession: async () => { + const payload = await read(); + tables.sys_user_position = tables.sys_user_position.filter((r) => !(r.user_id === 'u_member' && r.position === 'auditor')); + ql.writeEpoch!.bump('write'); + return payload; + }, + nowMs: T0, + tenancyPosture: 'isolated', + }); + expect(ctx.positions).not.toContain('auditor'); + expect(ql.calls.length).toBe(16); + + const after = makeTables(); + after.sys_user_position = after.sys_user_position.filter((r) => !(r.user_id === 'u_member' && r.position === 'auditor')); + const baseline = await resolveAuthzContext({ ql: makeQl(after), headers: {}, getSession: sessionReadOnly(member), nowMs: T0, tenancyPosture: 'isolated' }); + expect(ctx).toEqual(baseline); + }); + + it('a write that starts while the first resolution is still reading', async () => { + const ql = makeQl(makeTables()); + const find = ql.find.bind(ql); + let bumped = false; + ql.find = async (object: string, opts: any) => { + if (!bumped && object === 'sys_position') { bumped = true; ql.writeEpoch!.bump('write'); } + return find(object, opts); + }; + await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0, tenancyPosture: 'isolated' }); + expect(ql.calls.length).toBe(16); + }); + + 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 = makeQl(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 = makeQl(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 = makeQl(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 = makeQl(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 = makeQl(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'); + }); +}); From 71d418cde8adf76db5a17079b231c21267e4ada9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:25:20 +0000 Subject: [PATCH 3/9] test(plugin-auth): the real customSession hook and resolveAuthzContext resolve grants once per warm request Claude-Session: https://claude.ai/code/session_01WVbr5J6u8BHh8EyFtcWciH Co-authored-by: Claude --- .../src/session-grants-resolved-once.test.ts | 320 ++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts 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..be5d8aac878 --- /dev/null +++ b/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts @@ -0,0 +1,320 @@ +// 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 seam present, 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 write-epoch seam a real ObjectQL engine carries, bumped + * on every write verb, plus a read 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; + 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 original(...args); + }; + } + const originalFind = engine.find.bind(engine); + engine.find = async (name: string, q?: any) => { + if (recording) reads.push(name); + return originalFind(name, q); + }; + return { + reads, + 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(); + const auth: any = await a.manager.getAuthInstance(); + await auth.api.getSession({ headers: new Headers({ authorization: `Bearer ${a.bearers.owner}` }) }); + 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(); + 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); + }); +}); From fa9bc38a98bc141caca174ffcd51baf559019228 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:26:16 +0000 Subject: [PATCH 4/9] chore(changeset): core and plugin-auth patch for the request-scoped grants memo Claude-Session: https://claude.ai/code/session_01WVbr5J6u8BHh8EyFtcWciH Co-authored-by: Claude --- .changeset/request-scoped-grants-memo.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .changeset/request-scoped-grants-memo.md diff --git a/.changeset/request-scoped-grants-memo.md b/.changeset/request-scoped-grants-memo.md new file mode 100644 index 00000000000..2819ac2a9ea --- /dev/null +++ b/.changeset/request-scoped-grants-memo.md @@ -0,0 +1,18 @@ +--- +'@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 through the engine since the first resolution began (the engine's write epoch), no grant validity boundary between the two clocks, and the same organization (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 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. + +A request whose session read writes (the first request on a fresh auth instance generates its signing key) reads the grants twice, as before. From 0b07e024d3483e630611d387f82438b1ed520305 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:55:48 +0000 Subject: [PATCH 5/9] refactor(plugin-auth): read the hook's seed email off better-auth's typed user Claude-Session: https://claude.ai/code/session_01WVbr5J6u8BHh8EyFtcWciH Co-authored-by: Claude --- packages/plugins/plugin-auth/src/auth-manager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index a3ec2ec9ca3..5b5d42a0363 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -4088,7 +4088,7 @@ export class AuthManager { try { const grants = await resolveUserAuthzGrants(dataEngine as any, user.id, { tenantId: (session as any)?.activeOrganizationId ?? undefined, - seedEmail: (user as any)?.email ? String((user as any).email) : undefined, + seedEmail: user.email ? String(user.email) : undefined, }); positions = grants.positions; platformAdmin = grants.posture === 'PLATFORM_ADMIN'; From 1f61b8ab2f69e813935b8bfd4135695f3d07c89f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 08:46:46 +0000 Subject: [PATCH 6/9] fix(core): the grants memo serves only when no write landed or is in flight since the first resolution opened The engine bumps its write epoch when a write STARTS, so an epoch-only guard served an envelope read while an already-bumped revocation was in flight, after that revocation landed. The memo now also registers a write observer on the engine (a middleware counting writes into the chain and, in a finally around next(), out of it) and serves only when the epoch and both counters read what they read at the open with nothing in flight. The scope that registers an engine's observer stores nothing for it. Pins: bump-then-deferred-landing, in flight at step 2, two engines, a nested resolveAuthzContext, an API-key caller class, the positive clock window. Claude-Session: https://claude.ai/code/session_01WVbr5J6u8BHh8EyFtcWciH Co-authored-by: Claude --- .changeset/request-scoped-grants-memo.md | 8 +- .../core/src/security/request-grants-memo.ts | 170 +++++++-- ...-authz-context.request-grants-memo.test.ts | 337 +++++++++++++++--- .../src/security/resolve-authz-context.ts | 13 +- .../src/session-grants-resolved-once.test.ts | 52 ++- 5 files changed, 491 insertions(+), 89 deletions(-) diff --git a/.changeset/request-scoped-grants-memo.md b/.changeset/request-scoped-grants-memo.md index 2819ac2a9ea..bb40ad72142 100644 --- a/.changeset/request-scoped-grants-memo.md +++ b/.changeset/request-scoped-grants-memo.md @@ -12,7 +12,11 @@ Clause-②: no `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 through the engine since the first resolution began (the engine's write epoch), no grant validity boundary between the two clocks, and the same organization (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 declines entirely. +- An entry is served only when a fresh read would agree with it. No write may have started, landed 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. 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. -A request whose session read writes (the first request on a fresh auth instance generates its signing key) reads the grants twice, as before. +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 index b923cbd8b2a..f25c2a105e0 100644 --- a/packages/core/src/security/request-grants-memo.ts +++ b/packages/core/src/security/request-grants-memo.ts @@ -29,10 +29,11 @@ * 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. There is no module-level state and nothing - * 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. + * 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 — @@ -47,13 +48,27 @@ * 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 in between.** The engine's write epoch is read when the - * resolution OPENS, before its first read, and an entry is served only while - * that epoch has not moved — so a write through this engine that starts - * while the first resolution is reading, or between the two, makes the - * second one read afresh. A `ql` without the epoch seam declines entirely: - * an answer whose staleness cannot be observed is not served, not even for - * milliseconds. + * - **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 + * land without first moving `started` and cannot finish without moving + * `completed`. + * 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` @@ -68,23 +83,111 @@ * session payload and the resolver puts its own into the request context, and * downstream code may mutate either; the two must never alias. * - * ⚠️ What remains different from issuing every read twice, by design: the - * request's grants are read at the FIRST resolution, a few milliseconds - * earlier than the second used to read them. A write from another process — - * invisible to this engine's epoch — that commits inside those milliseconds is - * seen by the next request instead of this one, the same answer a write - * committing just after the second read always got. + * ⚠️ 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 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. */ @@ -96,6 +199,12 @@ interface RequestGrantsMemoScope { 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(); @@ -103,9 +212,12 @@ 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; its write observer is + * registered here, before any read, when this is the first call to name it. */ -export async function withRequestGrantsMemo(fn: () => Promise): Promise { - const scope: RequestGrantsMemoScope = { open: true, entries: new WeakMap() }; +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 { @@ -144,9 +256,10 @@ export interface RequestGrantsMemoAttempt { /** * Open the memo for one resolution. Returns `undefined` — the plain fresh - * path, no side effects — outside a `resolveAuthzContext` scope or after it - * closed, for a `bypassGrantsCache` caller, and for a `ql` that is not an - * object or carries no write epoch (`epochNow` undefined). + * 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 @@ -162,6 +275,10 @@ export function openRequestGrantsMemo( 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(); @@ -169,15 +286,20 @@ export function openRequestGrantsMemo( 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) return; + if (!scope.open || scope.attachedHere.has(ql)) return; let perEngine = scope.entries.get(ql); if (!perEngine) { perEngine = new Map(); @@ -186,6 +308,8 @@ export function openRequestGrantsMemo( 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 index a09f878ff90..f1b0e4569b3 100644 --- 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 @@ -12,28 +12,36 @@ * 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), 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. + * 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. + * 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. - * 4. FRESHNESS. A write through the engine between (or during) the two - * resolutions, a validity boundary between their clocks, a `bypass` - * caller and a failed first read all make step 2 read afresh. + * 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, @@ -44,8 +52,10 @@ import { 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 = {}; @@ -111,6 +121,17 @@ function makeTables(): Record { { 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"]', + }, + ], }; } @@ -123,12 +144,71 @@ function makeEpoch() { }; } -type Ql = ReturnType & { writeEpoch?: ReturnType }; +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 engine's write-epoch seam the memo requires. */ -function makeQl(tables: Record, opts: { epoch?: boolean } = {}): Ql { - const ql: Ql = makeRecordingQl(tables); +/** + * 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; } @@ -183,9 +263,19 @@ function sessionReadOnly(fixture: SessionFixture | 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. */ @@ -259,6 +349,20 @@ const CALLER_CLASSES: CallerClass[] = [ 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, @@ -273,10 +377,10 @@ async function resolveBoth(c: CallerClass) { for (const [k, v] of Object.entries(c.env ?? {})) process.env[k] = v; resetPlatformAdminEmailMemo(); - const memoQl = makeQl(makeTables()); + const memoQl = await armedQl(makeTables()); const withMemo = await resolveAuthzContext({ ql: memoQl, - headers: {}, + headers: c.headers ?? {}, getSession: sessionReadThatResolves(memoQl, c.fixture), nowMs: T0, tenancyPosture: c.tenancyPosture, @@ -285,7 +389,7 @@ async function resolveBoth(c: CallerClass) { const baselineQl = makeQl(makeTables()); const baseline = await resolveAuthzContext({ ql: baselineQl, - headers: {}, + headers: c.headers ?? {}, getSession: sessionReadOnly(c.fixture), nowMs: T0, tenancyPosture: c.tenancyPosture, @@ -316,7 +420,7 @@ describe('request-scoped grants memo — the round-trip count', () => { const oneResolution = standaloneQl.calls.length; expect(oneResolution).toBe(8); - const memoQl = makeQl(makeTables()); + 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)); @@ -327,19 +431,31 @@ describe('request-scoped grants memo — the round-trip count', () => { 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', () => { - function deferred() { - let resolve!: (v: T) => void; - const promise = new Promise((r) => { resolve = r; }); - return { promise, resolve }; - } - it('two interleaved requests from different callers keep their own grants', async () => { - const tables = makeTables(); - const ql = makeQl(tables); + 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'); @@ -369,19 +485,21 @@ describe('request-scoped grants memo — isolation', () => { 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 = makeQl(tables); + 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: what retires the first request's - // resolution is the end of that request, and nothing else. - tables.sys_user_position = tables.sys_user_position.filter((r) => !(r.user_id === 'u_member' && r.position === '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'); @@ -390,7 +508,7 @@ describe('request-scoped grants memo — isolation', () => { }); it('a continuation the request started reads afresh once the request settled', async () => { - const ql = makeQl(makeTables()); + const ql = await armedQl(makeTables()); const member = sessionOf('u_member', 'member@x.com', 'org_a'); const settled = deferred(); let late: Promise | undefined; @@ -413,52 +531,175 @@ describe('request-scoped grants memo — isolation', () => { 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 write through the engine between the two resolutions', async () => { + it('a revocation that bumped the epoch BEFORE the first resolution opened and lands before step 2', async () => { const tables = makeTables(); - const ql = makeQl(tables); + 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(); - tables.sys_user_position = tables.sys_user_position.filter((r) => !(r.user_id === 'u_member' && r.position === 'auditor')); - ql.writeEpoch!.bump('write'); + expect(payload?.user.positions).toContain('auditor'); + // …then W lands, before step 2. + landing.resolve(); + await w; return payload; }, nowMs: T0, tenancyPosture: 'isolated', }); + expect(ql.calls.length).toBe(16); expect(ctx.positions).not.toContain('auditor'); + expect(ctx).toEqual(await postWriteBaseline()); + }); + + 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; + }); - const after = makeTables(); - after.sys_user_position = after.sys_user_position.filter((r) => !(r.user_id === 'u_member' && r.position === 'auditor')); - const baseline = await resolveAuthzContext({ ql: makeQl(after), headers: {}, getSession: sessionReadOnly(member), nowMs: T0, tenancyPosture: 'isolated' }); - expect(ctx).toEqual(baseline); + 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 = makeQl(makeTables()); + const ql = await armedQl(makeTables()); const find = ql.find.bind(ql); - let bumped = false; + let started = false; + let w: Promise | undefined; ql.find = async (object: string, opts: any) => { - if (!bumped && object === 'sys_position') { bumped = true; ql.writeEpoch!.bump('write'); } + 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 = makeQl(makeTables()); + 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'); @@ -467,13 +708,13 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re expect(ctx).toEqual(baseline); // A step-2 clock EARLIER than the resolution it would be served. - const ql2 = makeQl(makeTables()); + 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 = makeQl(makeTables()); + const ql = await armedQl(makeTables()); const read = sessionReadThatResolves(ql, member); await resolveAuthzContext({ ql, @@ -491,7 +732,7 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re }); it('a first resolution that failed is not remembered', async () => { - const ql = makeQl(makeTables()); + const ql = await armedQl(makeTables()); const find = ql.find.bind(ql); let failed = false; ql.find = async (object: string, opts: any) => { @@ -508,7 +749,7 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re describe('request-scoped grants memo — served values are clones', () => { it('the session payload and the request envelope never share an array', async () => { - const ql = makeQl(makeTables()); + 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' }); diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index e901ce16653..d362a78fea6 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -358,7 +358,7 @@ async function tryFind( * The scope closes when this call settles; nothing outlives the request. */ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise { - return withRequestGrantsMemo(() => resolveAuthzContextInScope(input)); + return withRequestGrantsMemo(input.ql, () => resolveAuthzContextInScope(input)); } async function resolveAuthzContextInScope(input: ResolveAuthzInput): Promise { @@ -746,11 +746,12 @@ export async function resolveUserAuthzGrants( // // 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 through this engine since it opened and - // no validity boundary since its clock — is served instead of re-read. The - // epoch is 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. + // 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; 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 index be5d8aac878..b1693d1b072 100644 --- a/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts +++ b/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts @@ -15,8 +15,9 @@ // 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 seam present, the grant tables are read by -// ONE resolution; +// - 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; @@ -80,10 +81,12 @@ const userIdFor = (engine: any, email: string): string => { }; /** - * Give the double the write-epoch seam a real ObjectQL engine carries, bumped - * on every write verb, plus a read recorder switched on only around the - * request being measured. Wraps the instance's own methods; the double itself - * is unchanged. + * 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 = { @@ -92,6 +95,20 @@ function instrument(engine: any) { 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; @@ -100,16 +117,21 @@ function instrument(engine: any) { engine[verb] = async (...args: any[]) => { epoch.bump('write'); if (recording) writes.push(`${verb} ${String(args[0])}`); - return original(...args); + 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 originalFind(name, q); + 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; @@ -175,8 +197,12 @@ const arrange = async () => { */ const arrangeWarm = async () => { const a = await arrange(); - const auth: any = await a.manager.getAuthInstance(); - await auth.api.getSession({ headers: new Headers({ authorization: `Bearer ${a.bearers.owner}` }) }); + // 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; }; @@ -309,6 +335,12 @@ describe('the real customSession hook and resolveAuthzContext resolve the caller 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'); From 606c3340ad2efc778fbd5235033c10a15afc99a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 08:47:56 +0000 Subject: [PATCH 7/9] fix(core): register the grants memo's write observer only on an engine that carries the write epoch Claude-Session: https://claude.ai/code/session_01WVbr5J6u8BHh8EyFtcWciH Co-authored-by: Claude --- packages/core/src/security/request-grants-memo.ts | 3 ++- packages/core/src/security/resolve-authz-context.ts | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/core/src/security/request-grants-memo.ts b/packages/core/src/security/request-grants-memo.ts index f25c2a105e0..9e08f71d1d1 100644 --- a/packages/core/src/security/request-grants-memo.ts +++ b/packages/core/src/security/request-grants-memo.ts @@ -212,7 +212,8 @@ 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; its write observer is + * `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 { diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index d362a78fea6..fed2632781c 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -358,7 +358,11 @@ async function tryFind( * The scope closes when this call settles; nothing outlives the request. */ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise { - return withRequestGrantsMemo(input.ql, () => resolveAuthzContextInScope(input)); + // 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 { From 870b42979c885b2a2852add8071abdb8e49fbd39 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 09:01:07 +0000 Subject: [PATCH 8/9] test(core): the landing pin asserts the authorization decision before the read count Claude-Session: https://claude.ai/code/session_01WVbr5J6u8BHh8EyFtcWciH Co-authored-by: Claude --- .../resolve-authz-context.request-grants-memo.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index f1b0e4569b3..2e76142b67e 100644 --- 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 @@ -612,9 +612,11 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re nowMs: T0, tenancyPosture: 'isolated', }); - expect(ql.calls.length).toBe(16); + // 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 () => { From da29c616426e02fb511a44573acc46dffe5987b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 09:40:20 +0000 Subject: [PATCH 9/9] docs(core): the grants memo's observer sees statement execution, not commit visibility; state the transactional residual Comment and changeset text only. A write inside engine.transaction() is executed through the observer but becomes visible at the driver COMMIT, outside every middleware chain: on driver-sql, a request whose step 2 falls in that one commit round trip is authorised as of its first resolution and the next request reads fresh, the same answer as an out-of-process write. Claude-Session: https://claude.ai/code/session_01WVbr5J6u8BHh8EyFtcWciH Co-authored-by: Claude --- .changeset/request-scoped-grants-memo.md | 2 +- .../core/src/security/request-grants-memo.ts | 19 +++++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/.changeset/request-scoped-grants-memo.md b/.changeset/request-scoped-grants-memo.md index bb40ad72142..434b3cbd634 100644 --- a/.changeset/request-scoped-grants-memo.md +++ b/.changeset/request-scoped-grants-memo.md @@ -12,7 +12,7 @@ Clause-②: no `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, landed 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. 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. +- 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: diff --git a/packages/core/src/security/request-grants-memo.ts b/packages/core/src/security/request-grants-memo.ts index 9e08f71d1d1..dd88ea4ee3c 100644 --- a/packages/core/src/security/request-grants-memo.ts +++ b/packages/core/src/security/request-grants-memo.ts @@ -60,8 +60,11 @@ * 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 - * land without first moving `started` and cannot finish without moving - * `completed`. + * 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 @@ -98,6 +101,18 @@ * 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`