From 5cad7b98ddde5b4eca3775bc17ab92d33e404478 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 23:03:48 +0000 Subject: [PATCH] test(plugin-security): the explain/enforce parity table shares a read-only principal rig per posture source The 60 principal rows only read (explain, the explained user's own find, the resolved sets, a system read of an explained filter), so the rows of one posture source now share one rig, booted by its first row and torn down after the table. Rows that can write (RLS, sharing) still boot their own. Every rig's teardown also disables the AsyncLocalStorage instances its engine and plugin own: on Node 22 each one that served a run() stays on a process-wide async_hooks list that every later promise creation walks, and neither destroy() disables them. No it/describe/expect line changes. Claude-Session: https://claude.ai/code/session_017WLmd5in9RXu7AjyHR8fKQ Co-authored-by: Claude --- .../src/explain-enforce-parity.test.ts | 70 ++++++++++++++++--- 1 file changed, 62 insertions(+), 8 deletions(-) diff --git a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts index be90935ad5b..378d595c808 100644 --- a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts +++ b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts @@ -34,7 +34,9 @@ * Every row drives BOTH faces through the real stack — the real * `SecurityPlugin`, a real `ObjectQL` over a real SQL driver (better-sqlite3, * one in-memory database per rig) — and asserts enforcement's own outcome as - * well, so a row cannot go green by both faces drifting together. The rows are + * well, so a row cannot go green by both faces drifting together. A row that + * can write boots its own rig; the principal rows, which only read, share one + * rig per posture source ({@link withPrincipal}). The rows are * the explain-versus-enforce family's shapes so far: the record-grained write * verdict's inputs (#19963), the record-grained read verdict's read depth * (#19986), a shared dependency that throws (#20002), the record matcher under @@ -75,7 +77,8 @@ * built `dist/` here, as this package's other suites read them. */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, afterAll } from 'vitest'; +import { AsyncLocalStorage } from 'node:async_hooks'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { resolveAuthzContext, assembleExecutionContext } from '@objectstack/core'; @@ -95,6 +98,22 @@ import { defaultPermissionSets } from './objects/default-permission-sets.js'; const SYS = { context: { isSystem: true } } as never; const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!; +/** + * Releases the `AsyncLocalStorage` instances a torn-down rig's engine and + * plugin hold. On Node 22 a storage runs on `async_hooks`: once it has served a + * `run()` it stays on a process-wide list that EVERY later promise creation + * walks, until `disable()`. `ObjectQL` holds three and `SecurityPlugin` one, + * and neither `destroy()` disables them, so each rig this table tore down left + * its storages behind and every later row paid to walk all of them. Called + * after the rig's last row is judged, on objects nothing uses again, so it + * changes nothing any row observes. + */ +const releaseAsyncContexts = (...owners: object[]): void => { + for (const owner of owners) { + for (const value of Object.values(owner)) if (value instanceof AsyncLocalStorage) value.disable(); + } +}; + // ── the two faces ───────────────────────────────────────────────────────── /** @@ -322,7 +341,10 @@ async function bootRls(predicate: string, opts: { grantCrud?: boolean } = {}) { readAs: async (filter: unknown) => ((await engine.find(OBJ, { ...(filter ? { where: filter } : {}), context: { isSystem: true } } as never)) as Array<{ id: string }>) .map((r) => String(r.id)).sort(), - teardown: async () => { try { await engine.destroy(); } catch { /* noop */ } }, + teardown: async () => { + try { await engine.destroy(); } catch { /* noop */ } + releaseAsyncContexts(engine, plugin); + }, }; } @@ -519,7 +541,10 @@ async function bootPrincipal(source: PostureSource) { ((await engine.find(objects[object], { ...(filter ? { where: filter } : {}), context: { isSystem: true } } as never)) as Array<{ id: string }>) .map((r) => String(r.id)).sort(), tenantOf: async (userId: string) => (await enforced(userId)).tenantId, - teardown: async () => { try { await engine.destroy(); } catch { /* noop */ } }, + teardown: async () => { + try { await engine.destroy(); } catch { /* noop */ } + releaseAsyncContexts(engine, plugin); + }, }; } @@ -622,7 +647,10 @@ async function bootSharing(opts: { faultReadFilter?: boolean } = {}) { find: (caller: object, id: string) => rowsOf(engine.find(LINE, { where: { id }, context: { ...caller } } as never)), update: (caller: object, id: string) => landed(engine.update(LINE, { name: 'renamed' }, { where: { id }, context: { ...caller } } as never)), - teardown: async () => { try { await engine.destroy(); } catch { /* noop */ } }, + teardown: async () => { + try { await engine.destroy(); } catch { /* noop */ } + releaseAsyncContexts(engine, plugin); + }, }; } @@ -650,7 +678,11 @@ interface Row { run: RowRun; } -/** A row's run: boots its own rig, and hands back the teardown the table calls once the row is judged. */ +/** + * A row's run: boots its own rig, or takes its posture source's shared one (a + * principal row), and hands back the teardown the table calls once the row is + * judged: the rig's own, or none for the shared rig, torn down after the table. + */ type RowRun = () => Promise Promise }>; interface RowFaces { explain: Explained; @@ -671,9 +703,31 @@ const withSharing = ( const rig = await bootSharing(opts); try { return { ...(await f(rig)), teardown: rig.teardown }; } catch (e) { await rig.teardown(); throw e; } }; +/** + * One principal rig per posture source, booted by the source's first row and + * shared by the rest. Every principal row only READS: explain, the explained + * user's own find, the sets enforcement resolves, and a system read of an + * explained filter. None of them writes a row, a grant or a policy, so a row + * reads the state the rig was booted with, as if the rig were its own. A row + * that can write (the RLS and sharing rigs) still boots its own. Booting all 60 + * principal rows' rigs was most of this file's wall. + */ +const principalRigs = new Map>(); +const sharedPrincipalRig = (source: PostureSource): Promise => { + const key = JSON.stringify(source); + let rig = principalRigs.get(key); + if (!rig) { + rig = bootPrincipal(source); + principalRigs.set(key, rig); + } + return rig; +}; +afterAll(async () => { + for (const rig of principalRigs.values()) await (await rig.catch(() => undefined))?.teardown(); +}); const withPrincipal = (source: PostureSource, f: (rig: PrincipalRig) => Promise): RowRun => async () => { - const rig = await bootPrincipal(source); - try { return { ...(await f(rig)), teardown: rig.teardown }; } catch (e) { await rig.teardown(); throw e; } + const rig = await sharedPrincipalRig(source); + return { ...(await f(rig)), teardown: async () => undefined }; }; const REFUSED_INVALID: Enforced = { kind: 'refused', ...INVALID };