From 1705a1c432aee94a1058fe743b241a00cfdaf3e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 10:05:03 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(core):=20cross-request=20grants=20cach?= =?UTF-8?q?e=20=E2=80=94=20#11633=20leg=20B,=20coarse=20invalidation,=20TT?= =?UTF-8?q?L=3D0=20default,=20expiry-boundary=20rule,=20ruled=20bypass=20l?= =?UTF-8?q?ist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UserAuthzGrants envelope resolveUserAuthzGrants produces can now be cached across requests, governed by OS_AUTHZ_GRANTS_CACHE_TTL_MS (default 0 = off, a real path with zero engine footprint). When enabled: coarse invalidation on any engine write to a watched authorization object (sys_session deliberately excluded — the measured keying trap), wholesale retirement on non-write epoch reasons (metadata/remote/manual), entry expiry at min(ttl, nextValidityBoundary) because ADR-0091 windows flip with no write anywhere, seeds in the cache key (measured: seeding is NOT a pure prepend — posture and the suppressed sys_user read both depend on seeds), and clones served so callers cannot poison entries. The explain engine and runAs:'user' automation runs take the ruled force-fresh path. Pins: identity over the 11-fixture matrix (zero reads on a hit), read-after-write revocation/grant with no clock advance, the sys_session trap, validity boundaries in both directions, peer-membership coarse retirement, seed isolation, TTL=0 bit-identity, and the two-node bus/TTL convergence pair. The batch-equivalence harness moved to a .testkit.ts so the identity pins reuse the fixtures without re-registering that suite. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012WkdHQwHr2KQmaX7P1BHzi --- .changeset/authz-grants-cache-leg-b.md | 36 ++ .../docs/deployment/environment-variables.mdx | 2 +- packages/core/src/security/grant-validity.ts | 41 ++ ...ve-authz-context.batch-equivalence.test.ts | 326 +---------- ...authz-context.batch-equivalence.testkit.ts | 334 +++++++++++ .../src/security/resolve-authz-context.ts | 46 +- .../resolve-user-grants-cache.test.ts | 530 ++++++++++++++++++ .../src/security/resolve-user-grants-cache.ts | 329 +++++++++++ .../src/explain-engine.test.ts | 77 +++ .../plugin-security/src/explain-engine.ts | 8 +- .../services/service-automation/src/plugin.ts | 7 +- 11 files changed, 1415 insertions(+), 321 deletions(-) create mode 100644 .changeset/authz-grants-cache-leg-b.md create mode 100644 packages/core/src/security/resolve-authz-context.batch-equivalence.testkit.ts create mode 100644 packages/core/src/security/resolve-user-grants-cache.test.ts create mode 100644 packages/core/src/security/resolve-user-grants-cache.ts diff --git a/.changeset/authz-grants-cache-leg-b.md b/.changeset/authz-grants-cache-leg-b.md new file mode 100644 index 0000000000..2f423e86a2 --- /dev/null +++ b/.changeset/authz-grants-cache-leg-b.md @@ -0,0 +1,36 @@ +--- +"@objectstack/core": minor +"@objectstack/plugin-security": patch +"@objectstack/service-automation": patch +--- + +feat(core): cross-request authorization grants cache — leg B of #11633 (#11971) + +`resolveUserAuthzGrants` can now cache its resolved envelope across requests, +governed by `OS_AUTHZ_GRANTS_CACHE_TTL_MS`. **The default is `0` — the cache is +OFF and the shipped behaviour is unchanged** (Fork 4 of the accepted #11633 +design): a deployment that enables it accepts the configured staleness window +explicitly, and the boot-time posture statement says so out loud when no +cross-node invalidation bus is attached. + +With the cache on: + +- **Coarse write-invalidation (Fork 1A).** Any engine write to a watched + authorization object (`sys_member`, `sys_user_position`, + `sys_user_permission_set`, `sys_position`, `sys_position_permission_set`, + `sys_permission_set`, `sys_user`) retires every entry on the writing node — + a grant/revoke/role change is observed by the very next request there, by + invalidation and not by TTL. `metadata.changed` and peer-node + `authz.invalidated` hints retire wholesale via the engine write epoch. + `sys_session` is deliberately not watched (its once-a-minute + `last_activity_at` cadence would turn the cache into a non-cache). +- **Expiry-boundary rule.** Entries expire at `min(ttl, nextBoundary)`, where + `nextBoundary` is the earliest upcoming ADR-0091 `valid_from`/`valid_until` + among the rows consulted — a validity window flipping is a permission change + with no write anywhere, so the timer is the only mechanism for that class. +- **Ruled bypass list.** The permission explainer + (`plugin-security` `buildContextForUser`) and `runAs:'user'` automation runs + (`service-automation`) always resolve fresh, and never populate the cache. +- The TTL remains the correctness contract; the `authz.invalidated` bus only + narrows the typical cross-node window (no shipped driver exceeds + at-most-once delivery). diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index a3e543da4b..707d3e317c 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -65,7 +65,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false | `OS_DEV_CRYPTO_KEY` | string | — | Development convenience crypto key, consulted after `OS_SECRET_KEY`. Do not use in production. | | `OS_CLUSTER_DRIVER` | string | `memory` | Cluster coordination driver id. When set to anything other than `memory`, the runtime treats the deployment as multi-node (and requires `OS_SECRET_KEY`). Non-memory drivers are opt-in sibling packages (e.g. `redis` via `@objectstack/service-cluster-redis`) — see [Cluster](/docs/kernel/cluster). | | `OS_REDIS_URL` | url | — | Connection URL passed to a non-memory cluster driver (e.g. `OS_CLUSTER_DRIVER=redis`). | -| `OS_AUTHZ_GRANTS_CACHE_TTL_MS` | number | `0` | Staleness bound, in milliseconds, for the cross-request authorization grants cache (#11633). `0` (the default) means **off** — a real path, not a degenerate TTL. ⚠️ **No cache reads this value yet**: the invalidation substrate is landed, its first consumer is not, so today the only thing a non-zero value does is make the boot state its posture. When a value is set with no cross-node invalidation bus — no cluster service, or the in-process `memory` driver, which fans out to nobody — the boot says so loudly, every time: the TTL is then the whole bound on how long this replica may honour a grant another replica revoked. A malformed value is treated as `0` and warned about rather than silently read as "disabled". Deployment config only; it is deliberately not a settings row, because a cached path must not serve the knob that bounds the cache. | +| `OS_AUTHZ_GRANTS_CACHE_TTL_MS` | number | `0` | Staleness bound, in milliseconds, for the cross-request authorization grants cache (#11633). `0` (the default) means **off** — a real path, not a degenerate TTL. A non-zero value caches the resolved authorization grants envelope (`resolveUserAuthzGrants` — positions, permission sets, posture, RLS peer ids) per `(user, organization, seed)` on each node. On the writing node the cache is retired **immediately** by any write to a watched authorization object (`sys_member`, `sys_user_position`, `sys_user_permission_set`, `sys_position`, `sys_position_permission_set`, `sys_permission_set`, `sys_user`) and by any `metadata.changed` (a permission set can be declared in metadata); `sys_session` is deliberately not watched, so session-activity writes cannot flush it. Entries additionally expire at the earliest upcoming ADR-0091 validity boundary — `min(ttl, nextBoundary)` — because a `valid_until` passing is a permission change with **no write anywhere**. The permission explainer and `runAs:'user'` automation runs always read uncached. When a value is set with no cross-node invalidation bus — no cluster service, or the in-process `memory` driver, which fans out to nobody — the boot says so loudly, every time: the TTL is then the whole bound on how long this replica may honour a grant another replica revoked. A malformed value is treated as `0` and warned about rather than silently read as "disabled". Deployment config only; it is deliberately not a settings row, because a cached path must not serve the knob that bounds the cache. | --- diff --git a/packages/core/src/security/grant-validity.ts b/packages/core/src/security/grant-validity.ts index 288124fabe..b47704e9cb 100644 --- a/packages/core/src/security/grant-validity.ts +++ b/packages/core/src/security/grant-validity.ts @@ -58,6 +58,47 @@ export function isGrantActive(row: GrantValidityWindow | null | undefined, nowMs return true; } +/** + * The earliest FUTURE validity boundary among `rows`, in epoch ms — or + * `undefined` when no consulted row can change its `isGrantActive` verdict by + * clock movement alone. + * + * [#11971, #11633 §4 leg B constraint B.1] A cached grants envelope freezes a + * validity decision: a grant whose `valid_until` passes DURING the cache TTL + * keeps resolving until the entry expires, and a `valid_from` that arrives + * during it stays unresolved. **No write occurs at either boundary**, so + * write-invalidation is structurally blind to this class and the entry's + * expiry timer is the only mechanism that can catch it. The grants cache + * therefore expires an entry at `min(ttl, nextBoundary)` — this function is + * the `nextBoundary` half, computed with the same parser `isGrantActive` + * reads bounds with, so the two can never disagree about when a row flips. + * + * Both directions of flip count: a future `valid_until` (active → expired) + * AND a future `valid_from` (not-yet-active → active). Bounds that are + * absent, already past, or present-but-unparseable contribute nothing — an + * unparseable bound fails the row closed *permanently* (see `isGrantActive`), + * so there is no future instant at which its verdict changes. + */ +export function nextGrantValidityBoundary( + rows: Iterable, + nowMs: number, +): number | undefined { + let next: number | undefined; + for (const row of rows) { + if (!row) continue; + const from = toEpochMs((row as any).valid_from ?? (row as any).validFrom); + const until = toEpochMs((row as any).valid_until ?? (row as any).validUntil); + for (const bound of [from, until]) { + // NaN (unparseable) fails both comparisons and is skipped — fail-closed + // rows never flip, so they never set a boundary. + if (bound !== undefined && bound > nowMs && (next === undefined || bound < next)) { + next = bound; + } + } + } + return next; +} + /** * True when a grant row carries a `valid_until` that has already passed — * i.e. it WAS active and expired (not merely not-yet-active). The explain diff --git a/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts b/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts index 5e353a21fb..47e191e947 100644 --- a/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts +++ b/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts @@ -41,323 +41,15 @@ import { readFileSync } from 'node:fs'; import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { resetPlatformAdminEmailMemo } from './platform-admin.js'; import { resolveUserAuthzGrants } from './resolve-authz-context.js'; -import type { ResolveUserAuthzGrantsOptions } from './resolve-authz-context.js'; - -// ── Recording ObjectQL double ─────────────────────────────────────────────── - -interface RecordedCall { object: string; where: unknown; limit: unknown; isSystem: boolean } - -/** - * An in-memory ObjectQL double that (a) records every read, (b) ENFORCES the - * `limit` the caller passed — a real driver does, and a batch that quietly - * changed a limit would otherwise be invisible — and (c) counts LEGS. - * - * Leg counting: every read yields on a real macrotask boundary before it - * answers, so reads issued together are genuinely in flight together. A read - * that starts while nothing else is in flight OPENS a leg; one that starts - * while another is in flight JOINS the open leg. Sequential awaits therefore - * count one leg each, and a `Promise.all` of any width counts one — which is - * exactly the definition cloud#1539 measured latency against. - */ -export function makeRecordingQl(tables: Record) { - const calls: RecordedCall[] = []; - const legOf: number[] = []; - let inFlight = 0; - let legs = 0; - const matches = (row: any, where: any): boolean => - Object.entries(where ?? {}).every(([k, v]) => { - if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); - if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]); - return row[k] === v; - }); - return { - calls, - legOf, - get legs() { return legs; }, - async find(object: string, opts: any) { - if (inFlight === 0) legs += 1; - inFlight += 1; - legOf.push(legs); - calls.push({ - object, - where: opts?.where, - limit: opts?.limit, - isSystem: opts?.context?.isSystem === true, - }); - try { - await new Promise((r) => setTimeout(r, 0)); - const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); - return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; - } finally { - inFlight -= 1; - } - }, - }; -} - -// ── Fixture matrix — one entry per shape the eight reads discriminate on ──── - -const T0 = Date.UTC(2026, 0, 1); // fixed clock; every validity window is relative to it -const past = new Date(T0 - 86_400_000).toISOString(); -const future = new Date(T0 + 86_400_000).toISOString(); - -export interface Fixture { - name: string; - userId: string; - opts: ResolveUserAuthzGrantsOptions; - tables: Record; -} - -/** 205 memberships for one user — the `sys_member {user_id}` limit is 200. */ -const manyOwnMemberships = Array.from({ length: 205 }, (_, i) => ({ - user_id: 'u_lim', - organization_id: `org_${String(i).padStart(4, '0')}`, - role: 'member', -})); -/** 1005 peers in the active org — the fellow-org `sys_member` limit is 1000. */ -const manyPeers = Array.from({ length: 1005 }, (_, i) => ({ - user_id: `peer_${String(i).padStart(4, '0')}`, - organization_id: 'org_0000', - role: 'member', -})); - -export const FIXTURES: Fixture[] = [ - { - // An authenticated principal that holds nothing at all. Fails closed to the - // `everyone` anchor and an empty everything-else — never null. - name: 'empty-principal', - userId: 'u_empty', - opts: { seedEmail: 'empty@x.com', nowMs: T0 }, - tables: { sys_user: [{ id: 'u_empty' }], sys_member: [], sys_user_position: [], sys_user_permission_set: [] }, - }, - { - // Multi-org membership: positions come from the ACTIVE org only, while - // `accessible_org_ids` spans every org — the two facts the ONE sys_member - // read must keep in agreement. - name: 'multi-org-membership', - userId: 'u_multi', - opts: { tenantId: 'org_a', seedEmail: 'multi@x.com', nowMs: T0 }, - tables: { - sys_user: [{ id: 'u_multi' }], - sys_member: [ - { user_id: 'u_multi', organization_id: 'org_a', role: 'owner' }, - { user_id: 'u_multi', organization_id: 'org_b', role: 'admin' }, - { user_id: 'u_multi', organization_id: 'org_c', role: 'member', valid_until: past }, - { user_id: 'peer_1', organization_id: 'org_a', role: 'owner' }, - { user_id: 'peer_2', organization_id: 'org_b', role: 'owner' }, - ], - sys_user_position: [], - sys_user_permission_set: [], - }, - }, - { - // ⚠️ THE DIVERGENCE CASE the batch could plausibly introduce — and the - // reason the two `sys_member` reads are NOT merged into one `$or`. - // - // `sys_member {user_id}` (memberships + org-admin roles) and - // `sys_member {organization_id}` (fellow-org peers, limit 1000) read the - // SAME table and are now issued in the same wave — the obvious "improvement" - // is to merge them into one `$or`/unfiltered read and partition in memory. - // Here that merge is a privilege escalation with no error anywhere: - // `u_lapsed`'s OWN membership in org_a has lapsed (ADR-0091), while peers - // hold ACTIVE `owner` rows in org_a. A merged read would put those peer rows - // through the `accessible_org_ids` loop (granting org_a — the `group` - // posture's whole read reach) and through the `activeMembers` role loop - // (granting `org_owner`, and with it TENANT_ADMIN). - // - // Golden provenance (#10982 handoff, 2026-08-22 maintainer ruling item 2): - // an earlier branch of this suite deliberately pinned the then-current - // WRONG answer (`positions` ignored the validity window) so fixing it - // would be an act, not drift. That fix has since landed on `main` (#11088 - // family): a lapsed membership is NO membership. The golden here is - // captured from post-#10982 `main` and records the CORRECTED answer — - // `accessible_org_ids: []`, no `org_owner`, posture MEMBER — which is the - // right baseline, stated here so the value and its explanation cannot - // drift apart again. The durable pins for the semantic itself live in - // resolve-authz-context.test.ts (`#10982 — a lapsed sys_member row - // confers no role either`); THIS fixture's job is the no-\$or-merge - // scheduling control above. Peer rows still appear in `org_user_ids`, - // because THAT is what the fellow-org read is for. - name: 'lapsed-own-membership-among-active-peers', - userId: 'u_lapsed', - opts: { tenantId: 'org_a', seedEmail: 'lapsed@x.com', nowMs: T0 }, - tables: { - sys_user: [{ id: 'u_lapsed' }], - sys_member: [ - { user_id: 'u_lapsed', organization_id: 'org_a', role: 'member', valid_until: past }, - { user_id: 'peer_1', organization_id: 'org_a', role: 'owner' }, - { user_id: 'peer_2', organization_id: 'org_a', role: 'admin' }, - ], - sys_user_position: [], - sys_user_permission_set: [], - }, - }, - { - // Position-derived grants: a global row, an org-scoped row for ANOTHER org - // (must not resolve), a lapsed row, a not-yet-valid row, plus the ADR-0049 - // deactivated position whose NAME must leave `positions` too. - name: 'position-derived-grants', - userId: 'u_pos', - opts: { tenantId: 'org_a', seedEmail: 'pos@x.com', nowMs: T0 }, - tables: { - sys_user: [{ id: 'u_pos' }], - sys_member: [{ user_id: 'u_pos', organization_id: 'org_a', role: 'member' }], - sys_user_position: [ - { user_id: 'u_pos', position: 'contributor', organization_id: null }, - { user_id: 'u_pos', position: 'auditor', organization_id: 'org_a' }, - { user_id: 'u_pos', position: 'foreigner', organization_id: 'org_z' }, - { user_id: 'u_pos', position: 'expired_role', organization_id: null, valid_until: past }, - { user_id: 'u_pos', position: 'future_role', organization_id: null, valid_from: future }, - { user_id: 'u_pos', position: 'retired', organization_id: null }, - ], - sys_position: [ - { id: 'p_contrib', name: 'contributor' }, - { id: 'p_auditor', name: 'auditor', active: true }, - { id: 'p_retired', name: 'retired', active: false }, - { id: 'p_everyone', name: 'everyone' }, - ], - sys_position_permission_set: [ - { position_id: 'p_contrib', permission_set_id: 'ps_write' }, - { position_id: 'p_auditor', permission_set_id: 'ps_read' }, - { position_id: 'p_retired', permission_set_id: 'ps_admin' }, - { position_id: 'p_everyone', permission_set_id: 'ps_base' }, - ], - sys_permission_set: [ - { id: 'ps_write', name: 'write_all', system_permissions: '["record_write"]' }, - { id: 'ps_read', name: 'read_all', tab_permissions: '{"crm":"visible"}' }, - { id: 'ps_admin', name: 'admin_full_access' }, - { id: 'ps_base', name: 'base_access', tab_permissions: { crm: 'default_on' } }, - ], - sys_user_permission_set: [], - }, - }, - { - // Permission-set-derived grants: the UNSCOPED `admin_full_access` user grant - // is the ONLY thing that derives platform_admin, an org-scoped copy is not, - // a lapsed one is not, and a DEACTIVATED set grants nothing at all. - name: 'permission-set-derived-grants', - userId: 'u_ps', - opts: { tenantId: 'org_a', seedEmail: 'ps@x.com', nowMs: T0 }, - tables: { - sys_user: [{ id: 'u_ps' }], - sys_member: [{ user_id: 'u_ps', organization_id: 'org_a', role: 'member' }], - sys_user_position: [], - sys_user_permission_set: [ - { user_id: 'u_ps', permission_set_id: 'ps_admin', organization_id: null }, - { user_id: 'u_ps', permission_set_id: 'ps_org', organization_id: 'org_a' }, - { user_id: 'u_ps', permission_set_id: 'ps_other', organization_id: 'org_z' }, - { user_id: 'u_ps', permission_set_id: 'ps_lapsed', organization_id: null, valid_until: past }, - { user_id: 'u_ps', permission_set_id: 'ps_dead', organization_id: null }, - ], - sys_permission_set: [ - { id: 'ps_admin', name: 'admin_full_access', system_permissions: ['manage_users'] }, - { id: 'ps_org', name: 'org_tools', tab_permissions: { crm: 'hidden' } }, - { id: 'ps_other', name: 'other_org_tools' }, - { id: 'ps_lapsed', name: 'lapsed_set' }, - { id: 'ps_dead', name: 'dead_set', active: false }, - ], - sys_position: [], - }, - }, - { - // TENANT_ADMIN rung: the org-admin capability, held through a position, - // with a tab merge across two sets (highest visibility wins). - name: 'tenant-admin-via-position', - userId: 'u_ta', - opts: { tenantId: 'org_a', seedEmail: 'ta@x.com', nowMs: T0 }, - tables: { - sys_user: [{ id: 'u_ta' }], - sys_member: [{ user_id: 'u_ta', organization_id: 'org_a', role: 'admin' }], - sys_user_position: [], - sys_user_permission_set: [{ user_id: 'u_ta', permission_set_id: 'ps_low', organization_id: null }], - sys_position: [{ id: 'p_orgadmin', name: 'org_admin' }, { id: 'p_everyone', name: 'everyone' }], - sys_position_permission_set: [{ position_id: 'p_orgadmin', permission_set_id: 'ps_oa' }], - sys_permission_set: [ - { id: 'ps_low', name: 'low', tab_permissions: { crm: 'default_off' } }, - { id: 'ps_oa', name: 'organization_admin', tab_permissions: { crm: 'default_on' } }, - ], - }, - }, - { - // The `ai_seat` read: no seedEmail, so BOTH the `current_user.email` - // fallback and the ADR-0024 seat synthesis need `sys_user` — and it must - // still be read exactly ONCE (the #2409 memo). - name: 'ai-seat-and-email-from-sys-user', - userId: 'u_ai', - opts: { tenantId: 'org_a', nowMs: T0 }, - tables: { - sys_user: [{ id: 'u_ai', email: 'ai@x.com', ai_access: 1 }], - sys_member: [{ user_id: 'u_ai', organization_id: 'org_a', role: 'member' }], - sys_user_position: [], - sys_user_permission_set: [], - }, - }, - { - // `ai_access` falsy → NO seat, and the email fallback still lands. The - // negative half of the read above: a batch that read the row but stopped - // consulting the flag would pass the fixture above and fail this one. - name: 'ai-seat-denied', - userId: 'u_noai', - opts: { nowMs: T0 }, - tables: { - sys_user: [{ id: 'u_noai', email: 'noai@x.com', ai_access: 0 }], - sys_member: [], sys_user_position: [], sys_user_permission_set: [], - }, - }, - { - // Caller-seeded principal (the API-key shape): seeded scopes come FIRST and - // in order, the seeded email wins over `sys_user`, and because `ai_seat` is - // already held the seat read must NOT be issued at all. - name: 'seeded-permissions-and-email', - userId: 'u_seed', - opts: { - tenantId: 'org_a', - seedEmail: 'seed@x.com', - seedPermissions: ['key_scope_b', 'key_scope_a', 'ai_seat'], - nowMs: T0, - }, - tables: { - sys_user: [{ id: 'u_seed', email: 'ignored@x.com', ai_access: 1 }], - sys_member: [{ user_id: 'u_seed', organization_id: 'org_a', role: 'member' }], - sys_user_position: [], - sys_user_permission_set: [{ user_id: 'u_seed', permission_set_id: 'ps_x', organization_id: null }], - sys_permission_set: [{ id: 'ps_x', name: 'extra' }], - sys_position: [], - }, - }, - { - // Limits interacting with the batch: 205 own memberships against the 200 - // limit, 1005 peers against the 1000 limit. Truncation is OBSERVABLE here - // (the double slices like a driver), so a batch that changed either limit — - // or merged the two reads under one of them — moves these arrays. - name: 'read-limits-truncate', - userId: 'u_lim', - opts: { tenantId: 'org_0000', seedEmail: 'lim@x.com', nowMs: T0 }, - tables: { - sys_user: [{ id: 'u_lim' }], - sys_member: [...manyOwnMemberships, ...manyPeers], - sys_user_position: [], - sys_user_permission_set: [], - }, - }, - { - // No active organization: every membership contributes its role (the - // pre-ADR-0105-D2 org-less behaviour) and the fellow-org read is skipped - // entirely — one fewer query, and the batch must skip it too. - name: 'no-active-org', - userId: 'u_noorg', - opts: { seedEmail: 'noorg@x.com', nowMs: T0 }, - tables: { - sys_user: [{ id: 'u_noorg' }], - sys_member: [ - { user_id: 'u_noorg', organization_id: 'org_a', role: 'owner' }, - { user_id: 'u_noorg', organization_id: 'org_b', role: 'member,admin' }, - ], - sys_user_position: [], - sys_user_permission_set: [], - }, - }, -]; +// The recording double and the 11-fixture matrix live in the sibling +// `.testkit.ts` (extracted for #11971 so the grants-cache identity pins can +// drive the SAME fixtures without importing this test file and re-registering +// its suites). The goldens stay HERE, next to the assertions they control. +import { + makeRecordingQl, + FIXTURES, + type RecordedCall, +} from './resolve-authz-context.batch-equivalence.testkit.js'; // ── Goldens captured from the SEQUENTIAL implementation ───────────────────── diff --git a/packages/core/src/security/resolve-authz-context.batch-equivalence.testkit.ts b/packages/core/src/security/resolve-authz-context.batch-equivalence.testkit.ts new file mode 100644 index 0000000000..8c5e16b420 --- /dev/null +++ b/packages/core/src/security/resolve-authz-context.batch-equivalence.testkit.ts @@ -0,0 +1,334 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The reusable harness half of `resolve-authz-context.batch-equivalence.test.ts` + * — the recording ObjectQL double and the 11-fixture matrix, extracted under + * the repo's `.testkit.ts` convention (#11633 §7 notes the harness is meant to + * be reused) so a second suite can drive the SAME fixtures without importing a + * test file and re-registering its suites. + * + * ⛔ The GOLDENS stay in the test file, next to the assertions they control. + * This module carries only inputs: fixtures and the double that records what a + * resolution did with them. Moving a golden here would put the record of what + * the sequential code DID one import away from the code being tested — the + * drift the differential control exists to prevent. + */ + +import type { ResolveUserAuthzGrantsOptions } from './resolve-authz-context.js'; + +// ── Recording ObjectQL double ─────────────────────────────────────────────── + +export interface RecordedCall { object: string; where: unknown; limit: unknown; isSystem: boolean } + +/** + * An in-memory ObjectQL double that (a) records every read, (b) ENFORCES the + * `limit` the caller passed — a real driver does, and a batch that quietly + * changed a limit would otherwise be invisible — and (c) counts LEGS. + * + * Leg counting: every read yields on a real macrotask boundary before it + * answers, so reads issued together are genuinely in flight together. A read + * that starts while nothing else is in flight OPENS a leg; one that starts + * while another is in flight JOINS the open leg. Sequential awaits therefore + * count one leg each, and a `Promise.all` of any width counts one — which is + * exactly the definition cloud#1539 measured latency against. + */ +export function makeRecordingQl(tables: Record) { + const calls: RecordedCall[] = []; + const legOf: number[] = []; + let inFlight = 0; + let legs = 0; + const matches = (row: any, where: any): boolean => + Object.entries(where ?? {}).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]); + return row[k] === v; + }); + return { + calls, + legOf, + get legs() { return legs; }, + async find(object: string, opts: any) { + if (inFlight === 0) legs += 1; + inFlight += 1; + legOf.push(legs); + calls.push({ + object, + where: opts?.where, + limit: opts?.limit, + isSystem: opts?.context?.isSystem === true, + }); + try { + await new Promise((r) => setTimeout(r, 0)); + const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); + return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; + } finally { + inFlight -= 1; + } + }, + }; +} + +// ── Fixture matrix — one entry per shape the eight reads discriminate on ──── + +/** Fixed clock; every validity window in the fixtures is relative to it. */ +export const T0 = Date.UTC(2026, 0, 1); +const past = new Date(T0 - 86_400_000).toISOString(); +const future = new Date(T0 + 86_400_000).toISOString(); + +export interface Fixture { + name: string; + userId: string; + opts: ResolveUserAuthzGrantsOptions; + tables: Record; +} + +/** 205 memberships for one user — the `sys_member {user_id}` limit is 200. */ +const manyOwnMemberships = Array.from({ length: 205 }, (_, i) => ({ + user_id: 'u_lim', + organization_id: `org_${String(i).padStart(4, '0')}`, + role: 'member', +})); +/** 1005 peers in the active org — the fellow-org `sys_member` limit is 1000. */ +const manyPeers = Array.from({ length: 1005 }, (_, i) => ({ + user_id: `peer_${String(i).padStart(4, '0')}`, + organization_id: 'org_0000', + role: 'member', +})); + +export const FIXTURES: Fixture[] = [ + { + // An authenticated principal that holds nothing at all. Fails closed to the + // `everyone` anchor and an empty everything-else — never null. + name: 'empty-principal', + userId: 'u_empty', + opts: { seedEmail: 'empty@x.com', nowMs: T0 }, + tables: { sys_user: [{ id: 'u_empty' }], sys_member: [], sys_user_position: [], sys_user_permission_set: [] }, + }, + { + // Multi-org membership: positions come from the ACTIVE org only, while + // `accessible_org_ids` spans every org — the two facts the ONE sys_member + // read must keep in agreement. + name: 'multi-org-membership', + userId: 'u_multi', + opts: { tenantId: 'org_a', seedEmail: 'multi@x.com', nowMs: T0 }, + tables: { + sys_user: [{ id: 'u_multi' }], + sys_member: [ + { user_id: 'u_multi', organization_id: 'org_a', role: 'owner' }, + { user_id: 'u_multi', organization_id: 'org_b', role: 'admin' }, + { user_id: 'u_multi', organization_id: 'org_c', role: 'member', valid_until: past }, + { user_id: 'peer_1', organization_id: 'org_a', role: 'owner' }, + { user_id: 'peer_2', organization_id: 'org_b', role: 'owner' }, + ], + sys_user_position: [], + sys_user_permission_set: [], + }, + }, + { + // ⚠️ THE DIVERGENCE CASE the batch could plausibly introduce — and the + // reason the two `sys_member` reads are NOT merged into one `$or`. + // + // `sys_member {user_id}` (memberships + org-admin roles) and + // `sys_member {organization_id}` (fellow-org peers, limit 1000) read the + // SAME table and are now issued in the same wave — the obvious "improvement" + // is to merge them into one `$or`/unfiltered read and partition in memory. + // Here that merge is a privilege escalation with no error anywhere: + // `u_lapsed`'s OWN membership in org_a has lapsed (ADR-0091), while peers + // hold ACTIVE `owner` rows in org_a. A merged read would put those peer rows + // through the `accessible_org_ids` loop (granting org_a — the `group` + // posture's whole read reach) and through the `activeMembers` role loop + // (granting `org_owner`, and with it TENANT_ADMIN). + // + // Golden provenance (#10982 handoff, 2026-08-22 maintainer ruling item 2): + // an earlier branch of this suite deliberately pinned the then-current + // WRONG answer (`positions` ignored the validity window) so fixing it + // would be an act, not drift. That fix has since landed on `main` (#11088 + // family): a lapsed membership is NO membership. The golden here is + // captured from post-#10982 `main` and records the CORRECTED answer — + // `accessible_org_ids: []`, no `org_owner`, posture MEMBER — which is the + // right baseline, stated here so the value and its explanation cannot + // drift apart again. The durable pins for the semantic itself live in + // resolve-authz-context.test.ts (`#10982 — a lapsed sys_member row + // confers no role either`); THIS fixture's job is the no-\$or-merge + // scheduling control above. Peer rows still appear in `org_user_ids`, + // because THAT is what the fellow-org read is for. + name: 'lapsed-own-membership-among-active-peers', + userId: 'u_lapsed', + opts: { tenantId: 'org_a', seedEmail: 'lapsed@x.com', nowMs: T0 }, + tables: { + sys_user: [{ id: 'u_lapsed' }], + sys_member: [ + { user_id: 'u_lapsed', organization_id: 'org_a', role: 'member', valid_until: past }, + { user_id: 'peer_1', organization_id: 'org_a', role: 'owner' }, + { user_id: 'peer_2', organization_id: 'org_a', role: 'admin' }, + ], + sys_user_position: [], + sys_user_permission_set: [], + }, + }, + { + // Position-derived grants: a global row, an org-scoped row for ANOTHER org + // (must not resolve), a lapsed row, a not-yet-valid row, plus the ADR-0049 + // deactivated position whose NAME must leave `positions` too. + name: 'position-derived-grants', + userId: 'u_pos', + opts: { tenantId: 'org_a', seedEmail: 'pos@x.com', nowMs: T0 }, + tables: { + sys_user: [{ id: 'u_pos' }], + sys_member: [{ user_id: 'u_pos', organization_id: 'org_a', role: 'member' }], + sys_user_position: [ + { user_id: 'u_pos', position: 'contributor', organization_id: null }, + { user_id: 'u_pos', position: 'auditor', organization_id: 'org_a' }, + { user_id: 'u_pos', position: 'foreigner', organization_id: 'org_z' }, + { user_id: 'u_pos', position: 'expired_role', organization_id: null, valid_until: past }, + { user_id: 'u_pos', position: 'future_role', organization_id: null, valid_from: future }, + { user_id: 'u_pos', position: 'retired', organization_id: null }, + ], + sys_position: [ + { id: 'p_contrib', name: 'contributor' }, + { id: 'p_auditor', name: 'auditor', active: true }, + { id: 'p_retired', name: 'retired', active: false }, + { id: 'p_everyone', name: 'everyone' }, + ], + sys_position_permission_set: [ + { position_id: 'p_contrib', permission_set_id: 'ps_write' }, + { position_id: 'p_auditor', permission_set_id: 'ps_read' }, + { position_id: 'p_retired', permission_set_id: 'ps_admin' }, + { position_id: 'p_everyone', permission_set_id: 'ps_base' }, + ], + sys_permission_set: [ + { id: 'ps_write', name: 'write_all', system_permissions: '["record_write"]' }, + { id: 'ps_read', name: 'read_all', tab_permissions: '{"crm":"visible"}' }, + { id: 'ps_admin', name: 'admin_full_access' }, + { id: 'ps_base', name: 'base_access', tab_permissions: { crm: 'default_on' } }, + ], + sys_user_permission_set: [], + }, + }, + { + // Permission-set-derived grants: the UNSCOPED `admin_full_access` user grant + // is the ONLY thing that derives platform_admin, an org-scoped copy is not, + // a lapsed one is not, and a DEACTIVATED set grants nothing at all. + name: 'permission-set-derived-grants', + userId: 'u_ps', + opts: { tenantId: 'org_a', seedEmail: 'ps@x.com', nowMs: T0 }, + tables: { + sys_user: [{ id: 'u_ps' }], + sys_member: [{ user_id: 'u_ps', organization_id: 'org_a', role: 'member' }], + sys_user_position: [], + sys_user_permission_set: [ + { user_id: 'u_ps', permission_set_id: 'ps_admin', organization_id: null }, + { user_id: 'u_ps', permission_set_id: 'ps_org', organization_id: 'org_a' }, + { user_id: 'u_ps', permission_set_id: 'ps_other', organization_id: 'org_z' }, + { user_id: 'u_ps', permission_set_id: 'ps_lapsed', organization_id: null, valid_until: past }, + { user_id: 'u_ps', permission_set_id: 'ps_dead', organization_id: null }, + ], + sys_permission_set: [ + { id: 'ps_admin', name: 'admin_full_access', system_permissions: ['manage_users'] }, + { id: 'ps_org', name: 'org_tools', tab_permissions: { crm: 'hidden' } }, + { id: 'ps_other', name: 'other_org_tools' }, + { id: 'ps_lapsed', name: 'lapsed_set' }, + { id: 'ps_dead', name: 'dead_set', active: false }, + ], + sys_position: [], + }, + }, + { + // TENANT_ADMIN rung: the org-admin capability, held through a position, + // with a tab merge across two sets (highest visibility wins). + name: 'tenant-admin-via-position', + userId: 'u_ta', + opts: { tenantId: 'org_a', seedEmail: 'ta@x.com', nowMs: T0 }, + tables: { + sys_user: [{ id: 'u_ta' }], + sys_member: [{ user_id: 'u_ta', organization_id: 'org_a', role: 'admin' }], + sys_user_position: [], + sys_user_permission_set: [{ user_id: 'u_ta', permission_set_id: 'ps_low', organization_id: null }], + sys_position: [{ id: 'p_orgadmin', name: 'org_admin' }, { id: 'p_everyone', name: 'everyone' }], + sys_position_permission_set: [{ position_id: 'p_orgadmin', permission_set_id: 'ps_oa' }], + sys_permission_set: [ + { id: 'ps_low', name: 'low', tab_permissions: { crm: 'default_off' } }, + { id: 'ps_oa', name: 'organization_admin', tab_permissions: { crm: 'default_on' } }, + ], + }, + }, + { + // The `ai_seat` read: no seedEmail, so BOTH the `current_user.email` + // fallback and the ADR-0024 seat synthesis need `sys_user` — and it must + // still be read exactly ONCE (the #2409 memo). + name: 'ai-seat-and-email-from-sys-user', + userId: 'u_ai', + opts: { tenantId: 'org_a', nowMs: T0 }, + tables: { + sys_user: [{ id: 'u_ai', email: 'ai@x.com', ai_access: 1 }], + sys_member: [{ user_id: 'u_ai', organization_id: 'org_a', role: 'member' }], + sys_user_position: [], + sys_user_permission_set: [], + }, + }, + { + // `ai_access` falsy → NO seat, and the email fallback still lands. The + // negative half of the read above: a batch that read the row but stopped + // consulting the flag would pass the fixture above and fail this one. + name: 'ai-seat-denied', + userId: 'u_noai', + opts: { nowMs: T0 }, + tables: { + sys_user: [{ id: 'u_noai', email: 'noai@x.com', ai_access: 0 }], + sys_member: [], sys_user_position: [], sys_user_permission_set: [], + }, + }, + { + // Caller-seeded principal (the API-key shape): seeded scopes come FIRST and + // in order, the seeded email wins over `sys_user`, and because `ai_seat` is + // already held the seat read must NOT be issued at all. + name: 'seeded-permissions-and-email', + userId: 'u_seed', + opts: { + tenantId: 'org_a', + seedEmail: 'seed@x.com', + seedPermissions: ['key_scope_b', 'key_scope_a', 'ai_seat'], + nowMs: T0, + }, + tables: { + sys_user: [{ id: 'u_seed', email: 'ignored@x.com', ai_access: 1 }], + sys_member: [{ user_id: 'u_seed', organization_id: 'org_a', role: 'member' }], + sys_user_position: [], + sys_user_permission_set: [{ user_id: 'u_seed', permission_set_id: 'ps_x', organization_id: null }], + sys_permission_set: [{ id: 'ps_x', name: 'extra' }], + sys_position: [], + }, + }, + { + // Limits interacting with the batch: 205 own memberships against the 200 + // limit, 1005 peers against the 1000 limit. Truncation is OBSERVABLE here + // (the double slices like a driver), so a batch that changed either limit — + // or merged the two reads under one of them — moves these arrays. + name: 'read-limits-truncate', + userId: 'u_lim', + opts: { tenantId: 'org_0000', seedEmail: 'lim@x.com', nowMs: T0 }, + tables: { + sys_user: [{ id: 'u_lim' }], + sys_member: [...manyOwnMemberships, ...manyPeers], + sys_user_position: [], + sys_user_permission_set: [], + }, + }, + { + // No active organization: every membership contributes its role (the + // pre-ADR-0105-D2 org-less behaviour) and the fellow-org read is skipped + // entirely — one fewer query, and the batch must skip it too. + name: 'no-active-org', + userId: 'u_noorg', + opts: { seedEmail: 'noorg@x.com', nowMs: T0 }, + tables: { + sys_user: [{ id: 'u_noorg' }], + sys_member: [ + { user_id: 'u_noorg', organization_id: 'org_a', role: 'owner' }, + { user_id: 'u_noorg', organization_id: 'org_b', role: 'member,admin' }, + ], + sys_user_position: [], + sys_user_permission_set: [], + }, + }, +]; diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 712b2e9305..e0ae8b7b1c 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -37,7 +37,8 @@ import { postureEnforcesWall } from '@objectstack/spec/security'; import { resolveApiKeyAdmission } from './api-key.js'; import type { ApiKeyRefusalReason } from './api-key.js'; -import { isGrantActive } from './grant-validity.js'; +import { isGrantActive, nextGrantValidityBoundary } from './grant-validity.js'; +import { openUserGrantsCache } from './resolve-user-grants-cache.js'; import { matchesConfiguredPlatformAdmin, reportLegacyPlatformAdminGrant, @@ -300,6 +301,25 @@ export interface ResolveUserAuthzGrantsOptions { seedPermissions?: string[]; /** A caller-supplied email (e.g. from the session) that wins over the `sys_user` read. */ seedEmail?: string; + /** + * ⭐ Force a FRESH resolution even when the #11971 grants cache is enabled + * — the ruled bypass list of #11633 (leg B, maintainer acceptance + * 2026-08-25). Two call sites carry it, for two ruled reasons: + * + * - `plugin-security/src/explain-engine.ts` (`buildContextForUser`): the + * permission explainer is the tool an administrator uses to VERIFY that + * a revocation took effect. An explainer answering from cache would + * explain a state that no longer exists, and would do it at exactly the + * moment someone is checking. + * - `service-automation/src/plugin.ts` (`runAs:'user'` runs): automation + * runs are not request-shaped and can be long-lived; they must not pin + * an envelope. + * + * Bypassing reads NOTHING from the cache and writes NOTHING into it — a + * bypassed resolution must not repopulate an entry the next cached caller + * would then trust. + */ + bypassGrantsCache?: boolean; } /** @@ -329,6 +349,18 @@ export async function resolveUserAuthzGrants( userId: string, opts: ResolveUserAuthzGrantsOptions = {}, ): Promise { + // [#11971 / #11633 leg B] The cross-request grants cache. OFF by default + // (`OS_AUTHZ_GRANTS_CACHE_TTL_MS=0`, Fork 4) — `openUserGrantsCache` returns + // `undefined` then, with zero side effects, so the shipped default resolves + // byte-identically to the uncached implementation below. When enabled, a hit + // serves a clone of an envelope this engine resolved earlier, retired by any + // watched-object write, any non-`write` epoch bump, the earliest upcoming + // 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. + const grantsCache = openUserGrantsCache(ql, userId, opts); + if (grantsCache?.hit) return grantsCache.hit; + const { tenantId } = opts; const grants: UserAuthzGrants = { positions: [], @@ -690,6 +722,18 @@ export async function resolveUserAuthzGrants( if (aiAccess === true || aiAccess === 1 || aiAccess === '1') grants.permissions.push('ai_seat'); } + // [#11971] Store the fresh envelope (miss path only; the argument list is + // not evaluated when the cache is off/bypassed/declined). The boundary scan + // covers exactly the rows `isGrantActive` was applied to — own memberships, + // 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), + ); + return grants; } diff --git a/packages/core/src/security/resolve-user-grants-cache.test.ts b/packages/core/src/security/resolve-user-grants-cache.test.ts new file mode 100644 index 0000000000..36135e3717 --- /dev/null +++ b/packages/core/src/security/resolve-user-grants-cache.test.ts @@ -0,0 +1,530 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#11971] Pins for the #11633 leg-B grants cache — the design's §7 test plan, + * pins 1–5, 7 and 9 (pin 6, the explain bypass, lives with `buildContextForUser` + * in `plugin-security/src/explain-engine.test.ts`; pin 8 is leg C's and already + * stands in `analytics-timezone.dogfood.test.ts`). + * + * Two disciplines carried from `session-of-record.test.ts` via the design: + * + * - **Assert the END of the chain, not the middle.** The revocation pins + * assert the capability is ABSENT from the resolved envelope — never that + * "the cache was cleared" — because a clear-then-repopulate-from-a-stale-read + * implementation passes the middle and fails the end. + * - **Equality alone passes on a cache that never caches**, so every identity + * pin also asserts the hit issued ZERO reads. + * + * The doubles here mirror the REAL engine's seam order + * (`objectql/src/engine.ts` `executeWithMiddleware`): the write epoch advances + * FIRST, ahead of every middleware, and the middleware chain wraps the + * executor — so what these pins exercise is the order the cache actually sees. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resetPlatformAdminEmailMemo } from './platform-admin.js'; +import { resolveUserAuthzGrants } from './resolve-authz-context.js'; +import type { ResolveUserAuthzGrantsOptions } from './resolve-authz-context.js'; +import { + makeRecordingQl, + FIXTURES, +} from './resolve-authz-context.batch-equivalence.testkit.js'; + +// ── An engine double that carries the #11968 seams ────────────────────────── + +interface SeamCall { object: string; where: unknown; limit: unknown } +type SeamMiddleware = ( + ctx: { object: string; operation: string }, + next: () => Promise, +) => Promise; + +/** + * A `where`-honouring in-memory engine WITH the write-epoch and middleware + * seams: reads are recorded (so "zero reads" is measurable), writes go through + * `epoch.bump('write')` first and then the middleware onion — the real + * engine's order. `middlewares.length` / `epochListenerCount` are exposed so + * pin 7 can assert the OFF path wired NOTHING. + */ +function makeSeamQl(tables: Record) { + const calls: SeamCall[] = []; + const middlewares: SeamMiddleware[] = []; + const listeners = new Set<(epoch: number, reason: string) => void>(); + const epoch = { + current: 0, + bump(reason: string): number { + epoch.current += 1; + const at = epoch.current; + for (const l of [...listeners]) l(at, reason); + return at; + }, + subscribe(l: (epoch: number, reason: string) => void): () => void { + listeners.add(l); + return () => listeners.delete(l); + }, + }; + const matches = (row: any, where: any): boolean => + Object.entries(where ?? {}).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]); + return row[k] === v; + }); + async function writeOp(object: string, operation: 'insert' | 'update' | 'delete', executor: () => void): Promise { + // Real order: the epoch advances ahead of the whole chain. + epoch.bump('write'); + const ctx = { object, operation }; + const run = async (i: number): Promise => { + if (i < middlewares.length) return middlewares[i](ctx, () => run(i + 1)); + executor(); + }; + await run(0); + } + return { + calls, + tables, + writeEpoch: epoch, + epoch, + get middlewareCount() { return middlewares.length; }, + get epochListenerCount() { return listeners.size; }, + registerMiddleware(fn: SeamMiddleware) { middlewares.push(fn); }, + async find(object: string, opts: any) { + calls.push({ object, where: opts?.where, limit: opts?.limit }); + const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); + return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; + }, + async insert(object: string, row: any) { + await writeOp(object, 'insert', () => { (tables[object] ??= []).push(row); }); + }, + async update(object: string, patch: any, where: any) { + await writeOp(object, 'update', () => { + for (const r of (tables[object] ?? []).filter((r) => matches(r, where))) Object.assign(r, patch); + }); + }, + async delete(object: string, where: any) { + await writeOp(object, 'delete', () => { + tables[object] = (tables[object] ?? []).filter((r) => !matches(r, where)); + }); + }, + }; +} + +type SeamQl = ReturnType; + +// ── Fixtures ──────────────────────────────────────────────────────────────── + +/** Fixed clock. Injected everywhere, so no pin sleeps and no pin races. */ +const T = Date.UTC(2026, 5, 1); + +/** A principal whose PLATFORM_ADMIN standing hangs on one revocable row. */ +function adminTables(): Record { + return { + sys_user: [{ id: 'u1', email: 'u1@x.com' }], + sys_member: [{ user_id: 'u1', organization_id: 'org_a', role: 'member' }], + sys_user_position: [], + sys_user_permission_set: [ + { user_id: 'u1', permission_set_id: 'ps_admin', organization_id: null }, + ], + sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access' }], + sys_position: [], + sys_position_permission_set: [], + }; +} + +const OPTS: ResolveUserAuthzGrantsOptions = { tenantId: 'org_a', nowMs: T }; + +const TTL_ENV = 'OS_AUTHZ_GRANTS_CACHE_TTL_MS'; +const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; +let savedTtl: string | undefined; +let savedOwner: string | undefined; + +beforeEach(() => { + savedTtl = process.env[TTL_ENV]; + savedOwner = process.env[OWNER_ENV]; + delete process.env[TTL_ENV]; + // The fixtures (like the batch-equivalence goldens) assume a deployment that + // declared no platform administrators; an ambient CI value would add a + // conditional `sys_user` read and break the suppressed-read pin. + delete process.env[OWNER_ENV]; + resetPlatformAdminEmailMemo(); +}); +afterEach(() => { + if (savedTtl === undefined) delete process.env[TTL_ENV]; else process.env[TTL_ENV] = savedTtl; + if (savedOwner === undefined) delete process.env[OWNER_ENV]; else process.env[OWNER_ENV] = savedOwner; + resetPlatformAdminEmailMemo(); +}); + +const cacheOn = (ms = 60_000) => { process.env[TTL_ENV] = String(ms); }; + +/** Resolve and assert the resolution issued no reads at all (a true hit). */ +async function resolveExpectingZeroReads(ql: SeamQl, userId: string, opts: ResolveUserAuthzGrantsOptions) { + const before = ql.calls.length; + const grants = await resolveUserAuthzGrants(ql, userId, opts); + expect(ql.calls.length).toBe(before); + return grants; +} + +// ── Pin 7 — zero means OFF, and off is a REAL path ────────────────────────── + +describe('pin 7 — TTL 0 (the shipped default) is a real off path', () => { + it('default config: resolves twice with the SAME query multiset, and wires NOTHING onto the engine', async () => { + const ql = makeSeamQl(adminTables()); + const first = await resolveUserAuthzGrants(ql, 'u1', OPTS); + const afterFirst = ql.calls.length; + const second = await resolveUserAuthzGrants(ql, 'u1', OPTS); + + expect(second).toEqual(first); + // The second resolution re-issued every read — nothing was cached. + expect(ql.calls.length).toBe(afterFirst * 2); + const key = (c: SeamCall) => JSON.stringify(c); + expect(ql.calls.slice(afterFirst).map(key).sort()).toEqual(ql.calls.slice(0, afterFirst).map(key).sort()); + // ⭐ The bit-identical half: with the cache off this module must leave NO + // footprint — no engine middleware, no epoch subscription. This is what + // separates "off" from "a cache with a degenerate TTL". + expect(ql.middlewareCount).toBe(0); + expect(ql.epochListenerCount).toBe(0); + }); + + it('explicit OS_AUTHZ_GRANTS_CACHE_TTL_MS=0 takes the same off path', async () => { + process.env[TTL_ENV] = '0'; + const ql = makeSeamQl(adminTables()); + await resolveUserAuthzGrants(ql, 'u1', OPTS); + const afterFirst = ql.calls.length; + await resolveUserAuthzGrants(ql, 'u1', OPTS); + expect(ql.calls.length).toBe(afterFirst * 2); + expect(ql.middlewareCount).toBe(0); + expect(ql.epochListenerCount).toBe(0); + }); +}); + +// ── Pin 1 — identity: cached ≡ uncached, and the hit reads nothing ────────── + +describe('pin 1 — identity over the 11-fixture batch-equivalence matrix', () => { + describe.each(FIXTURES.map((f) => [f.name, f] as const))('%s', (_name, f) => { + it('cached ≡ uncached (deep-equal INCLUDING array order), and the second resolution issues ZERO reads', async () => { + cacheOn(); + // The uncached truth, from an identical table set via the ruled bypass. + const uncached = await resolveUserAuthzGrants( + makeSeamQl(structuredClone(f.tables)), + f.userId, + { ...f.opts, bypassGrantsCache: true }, + ); + + const ql = makeSeamQl(structuredClone(f.tables)); + const first = await resolveUserAuthzGrants(ql, f.userId, f.opts); + expect(first).toEqual(uncached); + + // Both halves are needed — equality alone passes on a cache that never + // caches (#11633 §7 pin 1). + const second = await resolveExpectingZeroReads(ql, f.userId, f.opts); + expect(second).toEqual(uncached); + }); + }); + + it('a served envelope is a CLONE — a caller mutating its answer cannot poison the next caller', async () => { + cacheOn(); + const ql = makeSeamQl(adminTables()); + const first = await resolveUserAuthzGrants(ql, 'u1', OPTS); + const snapshot = structuredClone(first); + first.permissions.push('injected_by_caller'); + first.org_user_ids.length = 0; + (first as any).posture = 'PLATFORM_ADMIN'; + + const second = await resolveExpectingZeroReads(ql, 'u1', OPTS); + expect(second).toEqual(snapshot); + }); +}); + +// ── Pin 2 — revocation observed by the NEXT request, with NO clock advance ── + +describe('pin 2 — read-after-write on the writing node (invalidation, not TTL)', () => { + it('a revoke THROUGH THE ENGINE is absent from the very next resolution', async () => { + // TTL one hour: if this pin passes, it cannot have passed by expiry. + cacheOn(3_600_000); + const ql = makeSeamQl(adminTables()); + + const before = await resolveUserAuthzGrants(ql, 'u1', OPTS); + expect(before.permissions).toContain('admin_full_access'); + expect(before.posture).toBe('PLATFORM_ADMIN'); + // Control: the entry IS being served (otherwise this pin tests nothing). + await resolveExpectingZeroReads(ql, 'u1', OPTS); + + // Revoke through the engine — the same seam a real revoke uses. + await ql.delete('sys_user_permission_set', { user_id: 'u1' }); + + // ⭐ Assert the END of the chain: the capability is GONE, not "the cache + // was cleared". Same injected clock — nothing here is allowed to lean on + // time passing. + const after = await resolveUserAuthzGrants(ql, 'u1', OPTS); + expect(after.permissions).not.toContain('admin_full_access'); + expect(after.positions).not.toContain('platform_admin'); + expect(after.posture).toBe('MEMBER'); + }); + + it('a GRANT through the engine is present on the very next resolution (both directions of staleness)', async () => { + cacheOn(3_600_000); + const tables = adminTables(); + tables.sys_user_permission_set = []; + const ql = makeSeamQl(tables); + + const before = await resolveUserAuthzGrants(ql, 'u1', OPTS); + expect(before.permissions).not.toContain('admin_full_access'); + + await ql.insert('sys_user_permission_set', { + user_id: 'u1', permission_set_id: 'ps_admin', organization_id: null, + }); + + const after = await resolveUserAuthzGrants(ql, 'u1', OPTS); + expect(after.permissions).toContain('admin_full_access'); + expect(after.posture).toBe('PLATFORM_ADMIN'); + }); + + it('⛔ the ruled keying trap: a `sys_session` write does NOT retire the cache (sys_session is not watched)', async () => { + cacheOn(3_600_000); + const tables = adminTables(); + tables.sys_session = [{ id: 's1', user_id: 'u1', last_activity_at: T }]; + const ql = makeSeamQl(tables); + + await resolveUserAuthzGrants(ql, 'u1', OPTS); + const epochBefore = ql.epoch.current; + + // The once-a-minute `last_activity_at` cadence, through the engine. + await ql.update('sys_session', { last_activity_at: T + 60_000 }, { id: 's1' }); + + // Control: the write DID advance the engine's epoch — the cache survived + // by its watched-set filter, not because the write was invisible. + expect(ql.epoch.current).toBeGreaterThan(epochBefore); + await resolveExpectingZeroReads(ql, 'u1', OPTS); + }); + + it('an unwatched business-object write does not retire the cache either', async () => { + cacheOn(3_600_000); + const ql = makeSeamQl(adminTables()); + await resolveUserAuthzGrants(ql, 'u1', OPTS); + await ql.insert('crm_lead', { id: 'l1', name: 'lead' }); + await resolveExpectingZeroReads(ql, 'u1', OPTS); + }); + + it("epoch reasons the middleware cannot see — 'metadata' and 'manual' — retire wholesale", async () => { + cacheOn(3_600_000); + for (const reason of ['metadata', 'manual'] as const) { + const tables = adminTables(); + const ql = makeSeamQl(tables); + const before = await resolveUserAuthzGrants(ql, 'u1', OPTS); + expect(before.permissions).toContain('admin_full_access'); + + // Mutate the rows DIRECTLY — no engine write, so ONLY the epoch bump + // below can make the next resolution look again. (A declared permission + // set changing in metadata is exactly a permission change with no row + // written; plugin-security bumps the engine epoch with 'metadata'.) + tables.sys_user_permission_set.length = 0; + await resolveExpectingZeroReads(ql, 'u1', OPTS); // control: entry still standing + + ql.epoch.bump(reason); + const after = await resolveUserAuthzGrants(ql, 'u1', OPTS); + expect(after.permissions).not.toContain('admin_full_access'); + } + }); +}); + +// ── Pin 3 — the expiry-boundary rule: min(ttl, nextBoundary) ──────────────── + +describe('pin 3 — ADR-0091 validity boundaries expire entries WITHOUT any write', () => { + it('a grant whose valid_until falls inside the TTL stops resolving AT the boundary, not at TTL expiry', async () => { + cacheOn(3_600_000); // TTL one hour; the boundary is 2 seconds away + const tables = adminTables(); + tables.sys_user_permission_set[0].valid_until = new Date(T + 2_000).toISOString(); + const ql = makeSeamQl(tables); + + const at = (nowMs: number) => ({ ...OPTS, nowMs }); + + const before = await resolveUserAuthzGrants(ql, 'u1', at(T)); + expect(before.permissions).toContain('admin_full_access'); + + // Just inside the boundary: still the SAME cached entry (zero reads) — + // the boundary cap must not turn into "always expired". + const inside = await resolveExpectingZeroReads(ql, 'u1', at(T + 1_999)); + expect(inside.permissions).toContain('admin_full_access'); + + // Past the boundary, with NO write anywhere: the entry is dead and the + // fresh resolution drops the lapsed grant. Write-invalidation is + // structurally blind here — the timer is the only mechanism (#11633 B.1). + const after = await resolveUserAuthzGrants(ql, 'u1', at(T + 2_001)); + expect(after.permissions).not.toContain('admin_full_access'); + expect(after.posture).toBe('MEMBER'); + }); + + it('the OTHER direction flips too: a future valid_from becomes active at its boundary', async () => { + cacheOn(3_600_000); + const tables = adminTables(); + tables.sys_user_permission_set[0].valid_from = new Date(T + 2_000).toISOString(); + const ql = makeSeamQl(tables); + + const at = (nowMs: number) => ({ ...OPTS, nowMs }); + + const before = await resolveUserAuthzGrants(ql, 'u1', at(T)); + expect(before.permissions).not.toContain('admin_full_access'); + + const after = await resolveUserAuthzGrants(ql, 'u1', at(T + 2_001)); + expect(after.permissions).toContain('admin_full_access'); + }); +}); + +// ── Pin 4 — peer membership (#11633 B.3) ──────────────────────────────────── + +describe("pin 4 — user X's envelope changes when user Y's sys_member row is written", () => { + it("inserting Y's membership through the engine appears in X's org_user_ids on the next resolution", async () => { + cacheOn(3_600_000); + const ql = makeSeamQl(adminTables()); + + const before = await resolveUserAuthzGrants(ql, 'u1', OPTS); + expect(before.org_user_ids).not.toContain('u_y'); + + // A DIFFERENT user's row in the same organization — the write whose + // consequence lands on X. Coarse invalidation gets this right by + // construction; a per-user keyed scheme is exactly where it would break. + await ql.insert('sys_member', { user_id: 'u_y', organization_id: 'org_a', role: 'member' }); + + const after = await resolveUserAuthzGrants(ql, 'u1', OPTS); + expect(after.org_user_ids).toContain('u_y'); + }); +}); + +// ── Pin 5 — seed isolation (#11633 B.2) ───────────────────────────────────── + +describe('pin 5 — seeds are part of the answer, so seeds are part of the key', () => { + it('different seedPermissions never see each other, ordering holds, and the suppressed sys_user read stays suppressed', async () => { + cacheOn(); + const ql = makeSeamQl(adminTables()); + const seedsA: ResolveUserAuthzGrantsOptions = { + tenantId: 'org_a', nowMs: T, seedEmail: 'a@x.com', seedPermissions: ['scope_a2', 'scope_a1', 'ai_seat'], + }; + const seedsB: ResolveUserAuthzGrantsOptions = { + tenantId: 'org_a', nowMs: T, seedEmail: 'b@x.com', seedPermissions: ['scope_b', 'ai_seat'], + }; + + const a1 = await resolveUserAuthzGrants(ql, 'u1', seedsA); + // Seeds come FIRST and in caller order — the contractual ordering the + // existing golden pins for the uncached path. + expect(a1.permissions.slice(0, 3)).toEqual(['scope_a2', 'scope_a1', 'ai_seat']); + expect(a1.email).toBe('a@x.com'); + + // B is a different principal shape → a different entry → a real resolve. + const readsAfterA = ql.calls.length; + const b1 = await resolveUserAuthzGrants(ql, 'u1', seedsB); + expect(ql.calls.length).toBeGreaterThan(readsAfterA); + expect(b1.permissions).toContain('scope_b'); + expect(b1.permissions).not.toContain('scope_a1'); + expect(b1.email).toBe('b@x.com'); + + // A's entry survived B's miss — and serves A byte-identically. + const a2 = await resolveExpectingZeroReads(ql, 'u1', seedsA); + expect(a2).toEqual(a1); + + // The seeded-API-key path's SUPPRESSED `sys_user` read is preserved in the + // query multiset: email + ai_seat are seeded and no platform admins are + // declared, so NO resolution in this test may touch `sys_user` (#11633 + // B.2's query-multiset half — the reason the seedless-envelope option was + // rejected; see the cache module's keying doc). + expect(ql.calls.every((c) => c.object !== 'sys_user')).toBe(true); + }); +}); + +// ── The ruled bypass (core half of pin 6) ─────────────────────────────────── + +describe('bypassGrantsCache — the ruled force-fresh path', () => { + it('a bypassed resolution never reads FROM the cache: it observes a change no seam reported', async () => { + cacheOn(3_600_000); + const tables = adminTables(); + const ql = makeSeamQl(tables); + + await resolveUserAuthzGrants(ql, 'u1', OPTS); + // Mutate rows directly — entry stays live, nothing retired it. + tables.sys_user_permission_set.length = 0; + const stale = await resolveExpectingZeroReads(ql, 'u1', OPTS); + expect(stale.permissions).toContain('admin_full_access'); // control: cache IS stale + + const fresh = await resolveUserAuthzGrants(ql, 'u1', { ...OPTS, bypassGrantsCache: true }); + expect(fresh.permissions).not.toContain('admin_full_access'); + }); + + it('a bypassed resolution never writes INTO the cache: the stale entry stands after it', async () => { + cacheOn(3_600_000); + const tables = adminTables(); + const ql = makeSeamQl(tables); + + await resolveUserAuthzGrants(ql, 'u1', OPTS); + tables.sys_user_permission_set.length = 0; + await resolveUserAuthzGrants(ql, 'u1', { ...OPTS, bypassGrantsCache: true }); + + // If the bypass had committed its fresh answer, this hit would now be + // fresh. It must not be — a bypassed resolution repopulating the cache + // would hand the NEXT cached caller an envelope resolved outside the + // cache's own snapshot discipline. + const after = await resolveExpectingZeroReads(ql, 'u1', OPTS); + expect(after.permissions).toContain('admin_full_access'); + }); +}); + +// ── The decline rule — no seam, no cache ──────────────────────────────────── + +describe('a ql without the #11968 seams declines to cache (never degrades to TTL-only)', () => { + it('the recording double (find only, no writeEpoch/registerMiddleware) resolves uncached every time', async () => { + cacheOn(3_600_000); + const ql = makeRecordingQl(adminTables()); + await resolveUserAuthzGrants(ql, 'u1', OPTS); + const afterFirst = ql.calls.length; + await resolveUserAuthzGrants(ql, 'u1', OPTS); + // Same reads again: a ql whose writes this cache cannot see is a ql whose + // answers it must not serve stale — declining IS the contract. + expect(ql.calls.length).toBe(afterFirst * 2); + }); +}); + +// ── Pin 9 — multi-node: the bus narrows, the TTL bounds ───────────────────── + +describe('pin 9 — two nodes over one database', () => { + it("WITH a bus: node 1's write retires node 2's entry in one hop (reason 'remote'), no clock advance", async () => { + cacheOn(3_600_000); + const tables = adminTables(); + const node1 = makeSeamQl(tables); + const node2 = makeSeamQl(tables); + // A bridge double with the real bridge's two rules: never forward a + // 'remote' bump (loopback), deliver as 'remote' (authz-invalidation-bridge). + node1.epoch.subscribe((_e, reason) => { + if (reason !== 'remote') node2.epoch.bump('remote'); + }); + + const before = await resolveUserAuthzGrants(node2, 'u1', OPTS); + expect(before.permissions).toContain('admin_full_access'); + await resolveExpectingZeroReads(node2, 'u1', OPTS); // control: node 2 is serving its entry + + await node1.delete('sys_user_permission_set', { user_id: 'u1' }); + + const after = await resolveUserAuthzGrants(node2, 'u1', OPTS); + expect(after.permissions).not.toContain('admin_full_access'); + }); + + it('WITHOUT a bus: node 2 stays stale inside the TTL (the accepted window) and CONVERGES at the TTL — the bound exists independently of the transport', async () => { + const TTL = 5_000; + cacheOn(TTL); + const tables = adminTables(); + const node1 = makeSeamQl(tables); + const node2 = makeSeamQl(tables); + const at = (nowMs: number) => ({ ...OPTS, nowMs }); + + const before = await resolveUserAuthzGrants(node2, 'u1', at(T)); + expect(before.permissions).toContain('admin_full_access'); + + await node1.delete('sys_user_permission_set', { user_id: 'u1' }); + + // Node 2 heard nothing. Inside the TTL it answers from its entry — this + // IS the staleness window the deployment accepted by setting a TTL, and + // the one the boot-time posture statement names out loud. + const stale = await resolveExpectingZeroReads(node2, 'u1', at(T + TTL - 1)); + expect(stale.permissions).toContain('admin_full_access'); + + // …and the TTL is the correctness contract: past it, node 2 converges + // with no message ever delivered. + const converged = await resolveUserAuthzGrants(node2, 'u1', at(T + TTL + 1)); + expect(converged.permissions).not.toContain('admin_full_access'); + }); +}); diff --git a/packages/core/src/security/resolve-user-grants-cache.ts b/packages/core/src/security/resolve-user-grants-cache.ts new file mode 100644 index 0000000000..8a3875a7b4 --- /dev/null +++ b/packages/core/src/security/resolve-user-grants-cache.ts @@ -0,0 +1,329 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ── Leg B of #11633: the cross-request grants cache (#11971) ──────────────── + * + * Caches the `UserAuthzGrants` envelope `resolveUserAuthzGrants` produces — + * the answer to "what may this user do", resolved on every authenticated + * request. Ruled shape (maintainer acceptance on #11633, 2026-08-25, verbatim + * 「接受你的建议,继续」), none of it re-litigable here: + * + * - **Fork 4 — default OFF.** `OS_AUTHZ_GRANTS_CACHE_TTL_MS` defaults to `0` + * and `0` is a REAL path: nothing is wired, nothing subscribes, nothing is + * stored, and the query pattern is byte-identical to the uncached one. + * A deployment that turns it on accepts the staleness window explicitly. + * - **Fork 1 → A — coarse invalidation.** Any write to a watched object + * retires EVERY entry for that engine. The seam cannot say whose entry a + * `where`-shaped update touches without reading the row back (#11633 + * §2.2), and `org_user_ids` depends on OTHER users' rows — a `sys_member` + * write must retire the organization's entries, not the writer's, which + * coarse invalidation gets right by construction. Keyed invalidation is + * ⛔ not a scheduled follow-up; it first needs a write-heavy-tenant + * measurement. + * - **Expiry-boundary rule.** An entry expires at `min(ttl, nextBoundary)`: + * ADR-0091 validity windows flip with **no write at the boundary**, so + * write-invalidation is structurally blind there and the timer is the only + * mechanism for that class (`nextGrantValidityBoundary`). + * - **The TTL is the correctness contract; every invalidation signal only + * narrows the typical window.** `authz-invalidation-channel.ts` carries + * the full statement: no shipped cluster driver delivers better than + * at-most-once, so a missed cross-node hint is EXPECTED and the entry's + * expiry is what bounds the staleness it leaves behind. + * + * ## What invalidates, and through which seam + * + * 1. **Local writes — the engine middleware seam, filtered to the watched + * set.** The engine's write epoch advances on EVERY write to ANY object + * (`objectql/src/engine.ts`), and consuming it raw would revive the + * measured keying trap the ruling names: `sys_session.last_activity_at` + * is written on a once-a-minute cadence per active session + * (`enforceSessionControls`), and a watched object with a background + * write cadence silently converts a cache into a non-cache. `sys_session` + * does not feed the grants envelope, so this cache registers its own + * engine middleware and retires entries only on writes to + * {@link GRANTS_CACHE_WATCHED_OBJECTS} — the tables the resolver reads, + * plus `sys_user` (`ai_access`, and the #11663 config-anchor email). + * The bump lands AFTER the write completes (settings-seam discipline: + * `emitChange` below `upsertRow`), so a resolution in flight across a + * write stamps a pre-write generation and is dead on arrival — the safe + * direction. + * 2. **Everything the middleware cannot see — the epoch's non-`write` + * reasons, wholesale.** `'metadata'` (a permission set can be DECLARED, + * so a Studio edit is a permission change with no row written — + * plugin-security bumps the engine epoch for it), `'remote'` (a peer's + * `authz.invalidated` hint, which carries no object and whose only + * correct reading is "retire everything"), and `'manual'`. Local + * `'write'` bumps are deliberately ignored HERE because seam 1 already + * handled them with object precision. + * + * ## Caching requires the seams — a `ql` without them declines + * + * Same load-bearing rule as leg C (`resolve-authz-context.ts`): a `ql` that + * exposes no write epoch or no `registerMiddleware` is a `ql` whose writes + * this cache cannot observe, and the acceptance criterion — a grant/revoke is + * observed by the NEXT request on the writing node, by invalidation and not + * by TTL — is unmeetable against it. Rather than degrade to the TTL-only + * shape, the cache declines and every such caller keeps its exact uncached + * behaviour. Every existing test double takes this path. + * + * ## Keying — seeds are part of the answer, so seeds are part of the key + * + * One entry per `(userId, tenantId, seedEmail, seedPermissions)`. #11633 §4 + * B.2 sketches the alternative — cache the seedless envelope, re-apply seeds + * outside — and conditions it on seeding being "a pure prepend, which must be + * *pinned*, not assumed". Measured here before implementing: it is NOT a pure + * prepend. Seeds flow into derivations, not just into the array — a seed + * named in `ORGANIZATION_ADMIN_GRANTS` moves the ADR-0095 posture rung, and + * seeded `email`/`ai_seat` suppress the `sys_user` read (`needsUserRow`), so + * a seedless resolution issues a query the seeded path must not issue (the + * multiset half of pin 5). Re-deriving those outside the resolver would be a + * second copy of authorization logic — the drift shape #10348 exists to end. + * Keying on the seeds keeps every cached answer bit-identical to its own + * uncached resolution by construction, at the cost of one entry per key-scope + * combination — bounded by active principals, and retired wholesale anyway. + * + * `nowMs` is deliberately NOT in the key: time is not identity. A caller's + * clock participates through entry expiry (`expiresAt` is compared against + * the RESOLVING call's clock), which is what makes the validity-boundary pin + * testable with an injected clock. + * + * ## Served values are clones + * + * `resolveAuthzContext` assigns the envelope's arrays into the request + * context, and downstream enforcement is free to mutate what it was handed. + * Entries therefore store a private `structuredClone` and every hit serves a + * fresh one — a caller's mutation can never corrupt the cached answer. + */ + +import type { + ResolveUserAuthzGrantsOptions, + UserAuthzGrants, +} from './resolve-authz-context.js'; +import { readAuthzGrantsCacheTtlMs } from './authz-cache-posture.js'; + +/** + * The watched set — the objects whose rows feed the grants envelope, derived + * in #11633 §4 (leg B) by reading the resolver, and ruled with two measured + * keying traps attached: + * + * ⛔ `sys_session` stays OUT. It does not feed the envelope, and its + * `last_activity_at` is written once a minute per active session — watching + * it would retire the whole cache on that cadence (the "background write + * cadence converts a cache into a non-cache" trap). + * + * `metadata.changed` is the eighth trigger and is not an object: it arrives + * as an epoch bump with reason `'metadata'` (see the module doc). + */ +export const GRANTS_CACHE_WATCHED_OBJECTS: ReadonlySet = new Set([ + 'sys_member', + 'sys_user_position', + 'sys_user_permission_set', + 'sys_position', + 'sys_position_permission_set', + 'sys_permission_set', + 'sys_user', +]); + +interface GrantsCacheEntry { + /** A private clone of the resolved envelope. Never handed out directly. */ + value: UserAuthzGrants; + /** The generation this value was resolved AT (read before the reads). */ + gen: number; + /** `min(resolvedAt + ttl, nextValidityBoundary)` on the resolving clock. */ + expiresAt: number; +} + +interface GrantsCacheState { + /** + * Coarse retirement counter. An entry is live only while this has not + * moved since the entry's resolution began. Advanced by seam 1 (watched + * writes, post-completion) and seam 2 (non-`write` epoch reasons). + */ + gen: number; + entries: Map; +} + +/** + * Per-engine state. WeakMap-keyed on the `ql` instance for the same reason as + * leg C: two environments/tenant engines sharing a process must never see each + * other's entries, and a dropped engine takes its cache with it. + * + * `null` records a `ql` whose wiring FAILED partway — poisoned, never cached, + * so a half-attached invalidation seam can never stand behind an entry. + */ +const grantsCacheStates = new WeakMap(); + +/** Structural mirror of `WriteEpochLike` — see `readWriteEpoch` in leg C for + * why the import direction is unavailable (`@objectstack/objectql` depends on + * this package). The WHOLE surface is checked so a bare `{ current }` on some + * unrelated double cannot license caching against a counter nothing bumps. */ +interface EpochSeam { + readonly current: number; + bump(reason: string): unknown; + subscribe(listener: (epoch: number, reason: string) => void): unknown; +} + +interface MiddlewareSeamQl { + writeEpoch?: unknown; + registerMiddleware?: unknown; + find?: unknown; +} + +const WRITE_OPERATIONS = new Set(['insert', 'update', 'delete']); + +/** + * Fetch — and on first sight of an engine, wire — the invalidation state. + * Returns `undefined` for a `ql` without both seams (declines, uncached path) + * and for one whose wiring threw (poisoned; see {@link grantsCacheStates}). + * + * ⚠️ Only ever called with a non-zero TTL in hand: with the cache OFF this + * module must leave NO footprint on the engine — no middleware, no epoch + * subscription — so that `0` stays a real path, not a degenerate TTL + * (#11633 §7 pin 7). + */ +function grantsCacheState(ql: object): GrantsCacheState | undefined { + const existing = grantsCacheStates.get(ql); + if (existing !== undefined) return existing ?? undefined; + + const seamQl = ql as MiddlewareSeamQl; + const epoch = seamQl.writeEpoch as Partial | null | undefined; + const hasEpoch = + !!epoch && + typeof epoch === 'object' && + typeof epoch.current === 'number' && + typeof epoch.bump === 'function' && + typeof epoch.subscribe === 'function'; + if (!hasEpoch || typeof seamQl.registerMiddleware !== 'function') { + // Not memoized: probing is cheap and a double's shape is static anyway. + return undefined; + } + + const state: GrantsCacheState = { gen: 0, entries: new Map() }; + try { + // Seam 1 — watched writes, object-filtered, bumped AFTER the write + // completes (and on a throw: a partial write is a write; over-invalidation + // is the safe direction and costs one re-read). + (seamQl.registerMiddleware as ( + fn: ( + ctx: { object?: unknown; operation?: unknown }, + next: () => Promise, + ) => Promise, + ) => void)(async (ctx, next) => { + if ( + typeof ctx?.operation !== 'string' || + !WRITE_OPERATIONS.has(ctx.operation) || + typeof ctx?.object !== 'string' || + !GRANTS_CACHE_WATCHED_OBJECTS.has(ctx.object) + ) { + return next(); + } + try { + await next(); + } finally { + state.gen += 1; + } + }); + + // Seam 2 — everything object-less: 'metadata' (declared permission sets), + // 'remote' (peer hints carry no object; wholesale is the only correct + // response), 'manual'. Local 'write' bumps are seam 1's job, already done + // with object precision — retiring on them here would re-import the + // sys_session trap this module exists to keep out. Never disposed, like + // leg C's settings subscription: it holds one integer per engine and the + // engine outlives this module's interest in it. + (epoch as EpochSeam).subscribe((_epoch, reason) => { + if (reason !== 'write') state.gen += 1; + }); + } catch { + // Wiring failed partway — poison this ql rather than cache behind a seam + // that may be half-attached. Entries never existed, so nothing to drop. + grantsCacheStates.set(ql, null); + return undefined; + } + + grantsCacheStates.set(ql, state); + return state; +} + +/** Seeds and identity in, collision-free key out. JSON, not delimiters — a + * seed permission is caller-supplied text and must not be able to alias + * another caller's key by containing a separator. */ +function grantsCacheKey(userId: string, opts: ResolveUserAuthzGrantsOptions): string { + return JSON.stringify([ + userId, + opts.tenantId ?? null, + opts.seedEmail ?? null, + Array.isArray(opts.seedPermissions) ? opts.seedPermissions : [], + ]); +} + +const cloneGrants = (grants: UserAuthzGrants): UserAuthzGrants => structuredClone(grants); + +/** + * One attempted cache interaction, opened at the top of + * `resolveUserAuthzGrants` and committed (on a miss) with the resolved + * envelope. The generation and clock are snapshotted at OPEN — before any + * read is issued — so a write landing while the resolution is in flight + * moves `state.gen` past what `commit` stamps and the entry is dead on + * arrival (leg C's clear-then-repopulate discipline, #11633 §7 pin 2). + */ +export interface GrantsCacheAttempt { + /** A live entry's envelope, already cloned for the caller. */ + hit?: UserAuthzGrants; + /** + * Store a freshly resolved envelope. `nextBoundaryMs` is the earliest + * upcoming ADR-0091 validity boundary among the rows consulted + * (`nextGrantValidityBoundary`, computed by the resolver), capping the expiry + * below the TTL — the expiry-boundary rule. + */ + commit(grants: UserAuthzGrants, nextBoundaryMs: number | undefined): void; +} + +/** + * Open the grants cache for one resolution. Returns `undefined` — the fully + * uncached path, zero side effects — when any of these holds: + * + * - the caller is on the ruled bypass list (`bypassGrantsCache`); + * - `OS_AUTHZ_GRANTS_CACHE_TTL_MS` is unset, `0`, or malformed (Fork 4: + * OFF is the default and a real path); + * - `ql` is not an engine-shaped object, or lacks the write-epoch / + * middleware seams the invalidation contract requires (declines — see + * the module doc). + */ +export function openUserGrantsCache( + ql: unknown, + userId: string, + opts: ResolveUserAuthzGrantsOptions, +): GrantsCacheAttempt | undefined { + if (opts.bypassGrantsCache) return undefined; + const { ttlMs } = readAuthzGrantsCacheTtlMs(); + if (ttlMs <= 0) return undefined; + if (!ql || typeof ql !== 'object' || typeof (ql as MiddlewareSeamQl).find !== 'function') { + return undefined; + } + const state = grantsCacheState(ql); + if (!state) return undefined; + + const key = grantsCacheKey(userId, opts); + const now = opts.nowMs ?? Date.now(); + const genAtOpen = state.gen; + + const existing = state.entries.get(key); + if (existing) { + if (existing.gen === state.gen && existing.expiresAt > now) { + return { hit: cloneGrants(existing.value), commit: () => {} }; + } + // Dead entry (retired or expired) — drop it now rather than letting it + // shadow the fresh value if the commit below never stores (e.g. a + // boundary already inside this instant). + state.entries.delete(key); + } + + return { + commit(grants, nextBoundaryMs) { + const expiresAt = Math.min(now + ttlMs, nextBoundaryMs ?? Number.POSITIVE_INFINITY); + if (expiresAt <= now) return; + state.entries.set(key, { value: cloneGrants(grants), gen: genAtOpen, expiresAt }); + }, + }; +} diff --git a/packages/plugins/plugin-security/src/explain-engine.test.ts b/packages/plugins/plugin-security/src/explain-engine.test.ts index 044303e95d..702aace340 100644 --- a/packages/plugins/plugin-security/src/explain-engine.test.ts +++ b/packages/plugins/plugin-security/src/explain-engine.test.ts @@ -1154,3 +1154,80 @@ describe('explainAccess — export axis (#3544)', () => { expect(d.allowed).toBe(false); }); }); + +// ─── #11971 — pin 6: the explainer bypasses the leg-B grants cache ────────── +// +// The ruled bypass list of #11633 (maintainer acceptance 2026-08-25): the +// explainer is the tool an administrator uses to VERIFY that a revocation took +// effect, so an explainer answering from cache would explain a state that no +// longer exists at exactly the moment someone is checking. This pin makes the +// cache demonstrably STALE first (the control), then asserts the explainer +// does not repeat it — the end of the chain, not "the flag was passed". + +describe('buildContextForUser bypasses the #11971 grants cache (ruled bypass list)', () => { + const TTL_ENV = 'OS_AUTHZ_GRANTS_CACHE_TTL_MS'; + + /** + * `makeGrantQl` plus the #11968 seams (write epoch + `registerMiddleware`), + * so the grants cache WOULD engage for this ql if the explainer let it — + * a seamless double would make this pin pass vacuously (the cache declines + * to cache against a ql whose writes it cannot see). + */ + function makeSeamGrantQl(tables: Rows) { + type Listener = (epoch: number, reason: string) => void; + type Middleware = ( + ctx: { object: string; operation: string }, + next: () => Promise, + ) => Promise; + const middlewares: Middleware[] = []; + const listeners = new Set(); + const epoch = { + current: 0, + bump(reason: string): number { + epoch.current += 1; + for (const l of [...listeners]) l(epoch.current, reason); + return epoch.current; + }, + subscribe(l: Listener): () => void { + listeners.add(l); + return () => { listeners.delete(l); }; + }, + }; + return { + ...makeGrantQl(tables), + writeEpoch: epoch, + registerMiddleware(fn: Middleware): void { middlewares.push(fn); }, + }; + } + + it('with the cache ON and provably stale, the explainer still observes the revocation immediately', async () => { + const saved = process.env[TTL_ENV]; + process.env[TTL_ENV] = '3600000'; // one hour — nothing here may pass by expiry + try { + const tables: Rows = { + sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psAdmin' }], + sys_permission_set: [{ id: 'psAdmin', name: 'admin_full_access' }], + }; + const ql = makeSeamGrantQl(tables); + + // Populate the cache through the SAME resolver every request uses. + const cached = await resolveUserAuthzGrants(ql, 'u2', { nowMs: NOW }); + expect(cached.permissions).toContain('admin_full_access'); + + // Revoke WITHOUT an engine write: no seam reports it, so nothing + // retires the entry and the cached path keeps the old answer… + tables.sys_user_permission_set = []; + const stale = await resolveUserAuthzGrants(ql, 'u2', { nowMs: NOW }); + expect(stale.permissions).toContain('admin_full_access'); // control: cache IS stale + + // …and the explainer must not repeat it: force-fresh, whatever the TTL. + const ctx = await buildContextForUser(ql, 'u2', NOW); + expect(ctx.permissions).not.toContain('admin_full_access'); + expect(ctx.hasPlatformAdminGrant).toBe(false); + expect(ctx.posture).not.toBe('PLATFORM_ADMIN'); + } finally { + if (saved === undefined) delete process.env[TTL_ENV]; + else process.env[TTL_ENV] = saved; + } + }); +}); diff --git a/packages/plugins/plugin-security/src/explain-engine.ts b/packages/plugins/plugin-security/src/explain-engine.ts index 0cb39b4ed7..6442421ead 100644 --- a/packages/plugins/plugin-security/src/explain-engine.ts +++ b/packages/plugins/plugin-security/src/explain-engine.ts @@ -470,7 +470,13 @@ async function collectGrantProvenance( * the grant rows. */ export async function buildContextForUser(ql: any, userId: string, nowMs: number = Date.now()): Promise { - const grants = await resolveUserAuthzGrants(ql, userId, { nowMs }); + // [#11971] ⭐ Ruled bypass of the #11633 leg-B grants cache (maintainer + // acceptance 2026-08-25): the explainer is the tool an administrator uses to + // VERIFY that a revocation took effect. An explainer answering from cache + // would explain a state that no longer exists — and would do it at exactly + // the moment someone is checking. `explain` therefore takes the force-fresh + // path unconditionally, whatever `OS_AUTHZ_GRANTS_CACHE_TTL_MS` says. + const grants = await resolveUserAuthzGrants(ql, userId, { nowMs, bypassGrantsCache: true }); const { droppedGrants, delegatedPositions } = await collectGrantProvenance(ql, userId, nowMs); return { userId, diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index 08c5723e99..77d68db934 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -866,7 +866,12 @@ export class AutomationServicePlugin implements Plugin { if (ql && typeof ql.find === 'function') { const engineQl = ql; this.engine.setUserGrantsResolver(async (userId, tenantId) => { - const grants = await resolveUserAuthzGrants(engineQl, userId, { tenantId }); + // [#11971] ⭐ Ruled bypass of the #11633 leg-B grants cache + // (maintainer acceptance 2026-08-25): `runAs:'user'` runs + // are not request-shaped and can be long-lived — they must + // not pin a cached envelope, whatever + // `OS_AUTHZ_GRANTS_CACHE_TTL_MS` says. + const grants = await resolveUserAuthzGrants(engineQl, userId, { tenantId, bypassGrantsCache: true }); return { positions: grants.positions, permissions: grants.permissions, From ae0ec14b68cd63baa7dd8488a433b2af5ecfde9f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 10:41:29 +0000 Subject: [PATCH 2/3] test(core): pin the seam double's write verbs to the engine dispatch contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check:engine-double-contract flagged makeSeamQl — its update()/delete() accepted calls the real ObjectQL refuses. Both verbs now open with the producer's own predicates (assertEngineUpdateDispatch / assertEngineDeleteDispatch from @objectstack/metadata-core — the non-cycle edge for a package objectql depends on), the pins spell their writes legally (multi delete carries multi:true, the session-activity update is by-id), and the RETAINED ledger records the new pinned double. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012WkdHQwHr2KQmaX7P1BHzi --- .../resolve-user-grants-cache.test.ts | 29 ++++++++++++++----- scripts/engine-double-contract.pinned.json | 10 +++++++ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/packages/core/src/security/resolve-user-grants-cache.test.ts b/packages/core/src/security/resolve-user-grants-cache.test.ts index 36135e3717..fed2977c2c 100644 --- a/packages/core/src/security/resolve-user-grants-cache.test.ts +++ b/packages/core/src/security/resolve-user-grants-cache.test.ts @@ -22,6 +22,13 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + assertEngineDeleteDispatch, + assertEngineUpdateDispatch, + type EngineDeleteDispatchInput, + type EngineUpdateDispatchData, + type EngineUpdateDispatchInput, +} from '@objectstack/metadata-core'; import { resetPlatformAdminEmailMemo } from './platform-admin.js'; import { resolveUserAuthzGrants } from './resolve-authz-context.js'; import type { ResolveUserAuthzGrantsOptions } from './resolve-authz-context.js'; @@ -94,13 +101,21 @@ function makeSeamQl(tables: Record) { async insert(object: string, row: any) { await writeOp(object, 'insert', () => { (tables[object] ??= []).push(row); }); }, - async update(object: string, patch: any, where: any) { + // Both write verbs open with the PRODUCER's dispatch predicate + // (`check:engine-double-contract`): a call the real `ObjectQL.update` / + // `ObjectQL.delete` refuses is refused here too, before any row moves — + // this double can never become the lax fake of #4434. + async update(object: string, data: EngineUpdateDispatchData, options?: EngineUpdateDispatchInput) { + const dispatch = assertEngineUpdateDispatch(data, options); await writeOp(object, 'update', () => { - for (const r of (tables[object] ?? []).filter((r) => matches(r, where))) Object.assign(r, patch); + const where = dispatch.kind === 'by-id' ? { id: dispatch.id } : (options?.where ?? {}); + for (const r of (tables[object] ?? []).filter((r) => matches(r, where))) Object.assign(r, data); }); }, - async delete(object: string, where: any) { + async delete(object: string, options?: EngineDeleteDispatchInput) { + const dispatch = assertEngineDeleteDispatch(options); await writeOp(object, 'delete', () => { + const where = dispatch.kind === 'by-id' ? { id: dispatch.id } : (options?.where ?? {}); tables[object] = (tables[object] ?? []).filter((r) => !matches(r, where)); }); }, @@ -248,7 +263,7 @@ describe('pin 2 — read-after-write on the writing node (invalidation, not TTL) await resolveExpectingZeroReads(ql, 'u1', OPTS); // Revoke through the engine — the same seam a real revoke uses. - await ql.delete('sys_user_permission_set', { user_id: 'u1' }); + await ql.delete('sys_user_permission_set', { where: { user_id: 'u1' }, multi: true }); // ⭐ Assert the END of the chain: the capability is GONE, not "the cache // was cleared". Same injected clock — nothing here is allowed to lean on @@ -287,7 +302,7 @@ describe('pin 2 — read-after-write on the writing node (invalidation, not TTL) const epochBefore = ql.epoch.current; // The once-a-minute `last_activity_at` cadence, through the engine. - await ql.update('sys_session', { last_activity_at: T + 60_000 }, { id: 's1' }); + await ql.update('sys_session', { last_activity_at: T + 60_000 }, { where: { id: 's1' } }); // Control: the write DID advance the engine's epoch — the cache survived // by its watched-set filter, not because the write was invisible. @@ -497,7 +512,7 @@ describe('pin 9 — two nodes over one database', () => { expect(before.permissions).toContain('admin_full_access'); await resolveExpectingZeroReads(node2, 'u1', OPTS); // control: node 2 is serving its entry - await node1.delete('sys_user_permission_set', { user_id: 'u1' }); + await node1.delete('sys_user_permission_set', { where: { user_id: 'u1' }, multi: true }); const after = await resolveUserAuthzGrants(node2, 'u1', OPTS); expect(after.permissions).not.toContain('admin_full_access'); @@ -514,7 +529,7 @@ describe('pin 9 — two nodes over one database', () => { const before = await resolveUserAuthzGrants(node2, 'u1', at(T)); expect(before.permissions).toContain('admin_full_access'); - await node1.delete('sys_user_permission_set', { user_id: 'u1' }); + await node1.delete('sys_user_permission_set', { where: { user_id: 'u1' }, multi: true }); // Node 2 heard nothing. Inside the TTL it answers from its entry — this // IS the staleness window the deployment accepted by setting a TTL, and diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 0fcd38e600..08908896b1 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1,6 +1,16 @@ { "$comment": "GENERATED — the RETAINED ledger of check-engine-double-contract.mjs (#9680). Regenerate with `node scripts/check-engine-double-contract.mjs --write`; never hand-edit. Each row is one (file, verb) whose engine double routes through the producer's dispatch predicate. This is the OPPOSITE polarity to engine-double-contract.baseline.json: that ledger records DEBT and may only shrink, this one records COVERAGE and may only grow. A row that disappears is a pinned double that left the population — the blind spot #9680 measured, where deleting a double's delete() member took 319 pinned to 318 with the gate green. Read a removal in this file's diff as a coverage loss and check it was intended.", "entries": [ + { + "file": "packages/core/src/security/resolve-user-grants-cache.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/core/src/security/resolve-user-grants-cache.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/core/src/utils/metadata-activation-store.test.ts", "verb": "update", From 1b877ef27f50cda7faa4a1f521b7a9d7b256c2ce Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 13:24:47 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(core):=20classify=20the=20batch-equival?= =?UTF-8?q?ence=20testkit=20as=20test=20code=20by=20path=20=E2=80=94=20cen?= =?UTF-8?q?sus=20population=20restored=20to=20baseline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's check:system-context-census (landed on main after this branch was cut, outside the path-derived gate set) went red on the harness extraction: the recording double's isSystem-recording line, excluded from the census for as long as it lived in a .test.ts file, entered the population when it moved to a sibling .testkit.ts — the census classifies test code BY PATH (.test. / .spec. / tests/ / __tests__/ / qa/) and the .testkit.ts suffix is not in that rule. The file is test scaffolding (consumed only by suites, extracted from one), so the honest repair is classification, not a hand-written page row: it now lives under security/__tests__/, inside the census's own published exclusion, and the population returns to its 109-site / 45-file baseline — page, counts, and the #4707 ruling's quoted premise all untouched. Whether .testkit.ts should join the census's path rule generally is the gate owner's call and is reported on the card, not taken here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012WkdHQwHr2KQmaX7P1BHzi --- ...e-authz-context.batch-equivalence.testkit.ts | 17 ++++++++++++++++- ...olve-authz-context.batch-equivalence.test.ts | 2 +- .../security/resolve-user-grants-cache.test.ts | 2 +- 3 files changed, 18 insertions(+), 3 deletions(-) rename packages/core/src/security/{ => __tests__}/resolve-authz-context.batch-equivalence.testkit.ts (93%) diff --git a/packages/core/src/security/resolve-authz-context.batch-equivalence.testkit.ts b/packages/core/src/security/__tests__/resolve-authz-context.batch-equivalence.testkit.ts similarity index 93% rename from packages/core/src/security/resolve-authz-context.batch-equivalence.testkit.ts rename to packages/core/src/security/__tests__/resolve-authz-context.batch-equivalence.testkit.ts index 8c5e16b420..05a269b59f 100644 --- a/packages/core/src/security/resolve-authz-context.batch-equivalence.testkit.ts +++ b/packages/core/src/security/__tests__/resolve-authz-context.batch-equivalence.testkit.ts @@ -12,9 +12,24 @@ * resolution did with them. Moving a golden here would put the record of what * the sequential code DID one import away from the code being tested — the * drift the differential control exists to prevent. + * + * ## Why this file lives under `__tests__/` + * + * It is TEST SCAFFOLDING — consumed only by test suites, and extracted from a + * `.test.ts` file — and the `isSystem` census (`scripts/isystem-census.mjs`, + * published on `content/docs/permissions/system-context.mdx`) classifies test + * code BY PATH: `.test.` / `.spec.` or a `tests/` / `__tests__/` / `qa/` + * segment. The recording double reads `opts?.context?.isSystem` — that is a + * RECORDER of what the resolver passed (the goldens assert every resolver read + * runs as system), not a shipped elevation behaviour, and as a sibling + * `security/*.testkit.ts` it would enter the census population the `.test.ts` + * file it came from was never in. The `__tests__/` segment states what this + * file is in the census's own published vocabulary. ⚠️ Whether `.testkit.ts` + * should join that path rule generally is the census owner's call, not this + * file's — see the #11971 report. */ -import type { ResolveUserAuthzGrantsOptions } from './resolve-authz-context.js'; +import type { ResolveUserAuthzGrantsOptions } from '../resolve-authz-context.js'; // ── Recording ObjectQL double ─────────────────────────────────────────────── diff --git a/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts b/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts index 47e191e947..17ede81a9d 100644 --- a/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts +++ b/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts @@ -49,7 +49,7 @@ import { makeRecordingQl, FIXTURES, type RecordedCall, -} from './resolve-authz-context.batch-equivalence.testkit.js'; +} from './__tests__/resolve-authz-context.batch-equivalence.testkit.js'; // ── Goldens captured from the SEQUENTIAL implementation ───────────────────── diff --git a/packages/core/src/security/resolve-user-grants-cache.test.ts b/packages/core/src/security/resolve-user-grants-cache.test.ts index fed2977c2c..3951bc4a81 100644 --- a/packages/core/src/security/resolve-user-grants-cache.test.ts +++ b/packages/core/src/security/resolve-user-grants-cache.test.ts @@ -35,7 +35,7 @@ import type { ResolveUserAuthzGrantsOptions } from './resolve-authz-context.js'; import { makeRecordingQl, FIXTURES, -} from './resolve-authz-context.batch-equivalence.testkit.js'; +} from './__tests__/resolve-authz-context.batch-equivalence.testkit.js'; // ── An engine double that carries the #11968 seams ──────────────────────────