Skip to content

Commit 5cad7b9

Browse files
committed
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 <noreply@anthropic.com>
1 parent 534ca13 commit 5cad7b9

1 file changed

Lines changed: 62 additions & 8 deletions

File tree

‎packages/plugins/plugin-security/src/explain-enforce-parity.test.ts‎

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@
3434
* Every row drives BOTH faces through the real stack — the real
3535
* `SecurityPlugin`, a real `ObjectQL` over a real SQL driver (better-sqlite3,
3636
* one in-memory database per rig) — and asserts enforcement's own outcome as
37-
* well, so a row cannot go green by both faces drifting together. The rows are
37+
* well, so a row cannot go green by both faces drifting together. A row that
38+
* can write boots its own rig; the principal rows, which only read, share one
39+
* rig per posture source ({@link withPrincipal}). The rows are
3840
* the explain-versus-enforce family's shapes so far: the record-grained write
3941
* verdict's inputs (#19963), the record-grained read verdict's read depth
4042
* (#19986), a shared dependency that throws (#20002), the record matcher under
@@ -75,7 +77,8 @@
7577
* built `dist/` here, as this package's other suites read them.
7678
*/
7779

78-
import { describe, it, expect, vi } from 'vitest';
80+
import { describe, it, expect, vi, afterAll } from 'vitest';
81+
import { AsyncLocalStorage } from 'node:async_hooks';
7982
import { ObjectQL } from '@objectstack/objectql';
8083
import { SqlDriver } from '@objectstack/driver-sql';
8184
import { resolveAuthzContext, assembleExecutionContext } from '@objectstack/core';
@@ -95,6 +98,22 @@ import { defaultPermissionSets } from './objects/default-permission-sets.js';
9598
const SYS = { context: { isSystem: true } } as never;
9699
const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!;
97100

101+
/**
102+
* Releases the `AsyncLocalStorage` instances a torn-down rig's engine and
103+
* plugin hold. On Node 22 a storage runs on `async_hooks`: once it has served a
104+
* `run()` it stays on a process-wide list that EVERY later promise creation
105+
* walks, until `disable()`. `ObjectQL` holds three and `SecurityPlugin` one,
106+
* and neither `destroy()` disables them, so each rig this table tore down left
107+
* its storages behind and every later row paid to walk all of them. Called
108+
* after the rig's last row is judged, on objects nothing uses again, so it
109+
* changes nothing any row observes.
110+
*/
111+
const releaseAsyncContexts = (...owners: object[]): void => {
112+
for (const owner of owners) {
113+
for (const value of Object.values(owner)) if (value instanceof AsyncLocalStorage) value.disable();
114+
}
115+
};
116+
98117
// ── the two faces ─────────────────────────────────────────────────────────
99118

100119
/**
@@ -322,7 +341,10 @@ async function bootRls(predicate: string, opts: { grantCrud?: boolean } = {}) {
322341
readAs: async (filter: unknown) =>
323342
((await engine.find(OBJ, { ...(filter ? { where: filter } : {}), context: { isSystem: true } } as never)) as Array<{ id: string }>)
324343
.map((r) => String(r.id)).sort(),
325-
teardown: async () => { try { await engine.destroy(); } catch { /* noop */ } },
344+
teardown: async () => {
345+
try { await engine.destroy(); } catch { /* noop */ }
346+
releaseAsyncContexts(engine, plugin);
347+
},
326348
};
327349
}
328350

@@ -519,7 +541,10 @@ async function bootPrincipal(source: PostureSource) {
519541
((await engine.find(objects[object], { ...(filter ? { where: filter } : {}), context: { isSystem: true } } as never)) as Array<{ id: string }>)
520542
.map((r) => String(r.id)).sort(),
521543
tenantOf: async (userId: string) => (await enforced(userId)).tenantId,
522-
teardown: async () => { try { await engine.destroy(); } catch { /* noop */ } },
544+
teardown: async () => {
545+
try { await engine.destroy(); } catch { /* noop */ }
546+
releaseAsyncContexts(engine, plugin);
547+
},
523548
};
524549
}
525550

@@ -622,7 +647,10 @@ async function bootSharing(opts: { faultReadFilter?: boolean } = {}) {
622647
find: (caller: object, id: string) => rowsOf(engine.find(LINE, { where: { id }, context: { ...caller } } as never)),
623648
update: (caller: object, id: string) =>
624649
landed(engine.update(LINE, { name: 'renamed' }, { where: { id }, context: { ...caller } } as never)),
625-
teardown: async () => { try { await engine.destroy(); } catch { /* noop */ } },
650+
teardown: async () => {
651+
try { await engine.destroy(); } catch { /* noop */ }
652+
releaseAsyncContexts(engine, plugin);
653+
},
626654
};
627655
}
628656

@@ -650,7 +678,11 @@ interface Row {
650678
run: RowRun;
651679
}
652680

653-
/** A row's run: boots its own rig, and hands back the teardown the table calls once the row is judged. */
681+
/**
682+
* A row's run: boots its own rig, or takes its posture source's shared one (a
683+
* principal row), and hands back the teardown the table calls once the row is
684+
* judged: the rig's own, or none for the shared rig, torn down after the table.
685+
*/
654686
type RowRun = () => Promise<RowFaces & { teardown: () => Promise<void> }>;
655687
interface RowFaces {
656688
explain: Explained;
@@ -671,9 +703,31 @@ const withSharing = (
671703
const rig = await bootSharing(opts);
672704
try { return { ...(await f(rig)), teardown: rig.teardown }; } catch (e) { await rig.teardown(); throw e; }
673705
};
706+
/**
707+
* One principal rig per posture source, booted by the source's first row and
708+
* shared by the rest. Every principal row only READS: explain, the explained
709+
* user's own find, the sets enforcement resolves, and a system read of an
710+
* explained filter. None of them writes a row, a grant or a policy, so a row
711+
* reads the state the rig was booted with, as if the rig were its own. A row
712+
* that can write (the RLS and sharing rigs) still boots its own. Booting all 60
713+
* principal rows' rigs was most of this file's wall.
714+
*/
715+
const principalRigs = new Map<string, Promise<PrincipalRig>>();
716+
const sharedPrincipalRig = (source: PostureSource): Promise<PrincipalRig> => {
717+
const key = JSON.stringify(source);
718+
let rig = principalRigs.get(key);
719+
if (!rig) {
720+
rig = bootPrincipal(source);
721+
principalRigs.set(key, rig);
722+
}
723+
return rig;
724+
};
725+
afterAll(async () => {
726+
for (const rig of principalRigs.values()) await (await rig.catch(() => undefined))?.teardown();
727+
});
674728
const withPrincipal = (source: PostureSource, f: (rig: PrincipalRig) => Promise<RowFaces>): RowRun => async () => {
675-
const rig = await bootPrincipal(source);
676-
try { return { ...(await f(rig)), teardown: rig.teardown }; } catch (e) { await rig.teardown(); throw e; }
729+
const rig = await sharedPrincipalRig(source);
730+
return { ...(await f(rig)), teardown: async () => undefined };
677731
};
678732

679733
const REFUSED_INVALID: Enforced = { kind: 'refused', ...INVALID };

0 commit comments

Comments
 (0)