Skip to content

Commit f0d5b64

Browse files
os-warrenclaude
andauthored
test(security): pin the four Layer 0 shapes as RECORDED VERDICTS at the middleware line (#15887) (#16275)
* test(security): pin the four Layer 0 shapes as RECORDED VERDICTS at the middleware line (#15887) Nothing here claims anything is broken — all four shapes hold on `main` today. What was missing is a reading on the RECORDING side. Each rested on the projection's own pins, i.e. on the identity "the recorded verdict IS the object the predicate was projected from" — the identity a future change to this seam would break first, and when it breaks the projection-side pins stay green. Adds middleware-level pins in `tenant-layer0-verdict-on-operation.test.ts` that read `opCtx.tenantLayer0Verdict` — the recorded object itself, not the downstream filter and not `getReadFilter`'s projection — for: * `systemFields.tenant: false` beside an author-declared `organization_id` (P1): `none`, with the readable column asserted as present so the trap is explicit, plus a walled sibling as the firing control; * the #7835 phantom anchor: a federated object carrying the anchor spread from `TENANT_SCOPE_FIELD_DEF` records `none`, while a federated object whose author declared a real remote `organization_id` records `organization` — the exit is provenance, never `external != null`; * a custom `tenancy.tenantField`, both directions: not an exit by itself (still `organization`, and the predicate names `organization_id`, never `workspace_id`), and never a substitute (no kernel column ⇒ `none`); * the ADR-0090 D10 on-behalf-of INTERSECTION at the middleware line, which had no pin at all. `intersectTenantLayer0Verdicts` is unit-pinned in `tenant-layer.test.ts`; what had no reading is the line that calls it. The case asserts an organization set NEITHER injected wall names alone, with the same fixture minus the delegation link as the control, plus the fail-closed `deny` leg on the read shape. `boot()` grows an optional delegator seed: without it the tables are empty, `findOne` answers `null` for every object as before, and no `find` is exposed — the existing cases are byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y * test(security): the delegator-seed double answers its bound and refuses combinators (#15887) Two findings from the derived gate family, both about the fake engine the on-behalf-of leg reads through — not about the pins: * `check:objectql-double-limit`: core's grants resolution hands every read a `limit` (200 on the `sys_member` legs), and the double ignored it. The bound is now applied AFTER the filter and BY PRESENCE, the shape the gate names. * `check:where-matcher`: `matches` compared a `$`-prefixed key as a FIELD NAME, which matches nothing and says nothing. The double now REFUSES the combinators and operators it does not implement — plain equality is all the delegator resolution ever asks it for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a775510 commit f0d5b64

1 file changed

Lines changed: 313 additions & 2 deletions

File tree

packages/plugins/plugin-security/src/tenant-layer0-verdict-on-operation.test.ts

Lines changed: 313 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@
2222
* 3. Nothing is recorded where no wall was composed: a system context (the
2323
* middleware's first exit) and a by-id write (no predicate to compose
2424
* onto). Absence is a distinct state from `none`.
25+
* 4. [#15887] The four shapes that were CORRECT BY CONSTRUCTION but pinned
26+
* only one layer away — `systemFields.tenant: false` beside an
27+
* author-declared column, the #7835 phantom anchor, a custom
28+
* `tenancy.tenantField`, and the ADR-0090 D10 on-behalf-of INTERSECTION,
29+
* whose middleware line carried no pin at all. Each rested on the
30+
* projection's own pins (`security-plugin.test.ts`,
31+
* `federated-tenant-layer0.test.ts`, `tenant-layer.test.ts`), i.e. on the
32+
* identity「the recorded verdict IS what the predicate was projected from」
33+
* — the very identity that would break first. The pins below read the
34+
* RECORDED object, so a divergence is caught on the recording side.
2535
*
2636
* Harness: `deployment-platform-global-exemption.test.ts` — a SecurityPlugin
2737
* over a fake ObjectQL. The registered middleware is captured and driven with
@@ -30,6 +40,7 @@
3040
*/
3141

3242
import { describe, it, expect, vi } from 'vitest';
43+
import { TENANT_SCOPE_FIELD_DEF } from '@objectstack/metadata-core';
3344
import type { PermissionSet } from '@objectstack/spec/security';
3445
import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity';
3546
import { SecurityPlugin } from './security-plugin.js';
@@ -70,21 +81,139 @@ const localSchema = (name: string, extra: Record<string, unknown> = {}) => ({
7081
...extra,
7182
});
7283

84+
/** `localSchema` plus extra columns — the custom-tenant-column shapes need one. */
85+
const withFields = (
86+
schema: Record<string, unknown>,
87+
fields: Record<string, unknown>,
88+
): Record<string, unknown> => ({
89+
...schema,
90+
fields: { ...(schema.fields as Record<string, unknown>), ...fields },
91+
});
92+
7393
const SCHEMAS: Record<string, Record<string, unknown>> = {
7494
crm_task: localSchema('crm_task'),
7595
sys_widget_registry: localSchema('sys_widget_registry'),
7696
sys_catalog: localSchema('sys_catalog', { tenancy: { enabled: false } }),
7797
crm_secret: localSchema('crm_secret', { access: { default: 'private' } }),
98+
99+
// ── [#15887] the three object shapes ──────────────────────────────────────
100+
//
101+
// P1. The object opted OUT of the tenant system field while the author's own
102+
// `organization_id` stays declared and perfectly readable.
103+
// `getObjectSecurityMeta` folds `systemFields.tenant === false` into
104+
// `tenancyDisabled` beside `tenancy.enabled === false`, so the wall composes
105+
// NOTHING here. The readable column is the trap: a reader answering from the
106+
// column instead of from the wall stamps this batch with the caller's
107+
// organization — a MISLABEL, on rows the wall never constrained.
108+
shared_catalog: localSchema('shared_catalog', { systemFields: { tenant: false } }),
109+
110+
// [#7835] Federated, carrying the anchor `applySystemFields` injects —
111+
// spread from the shipped constant exactly as the registry does
112+
// (`additions.organization_id = { ...TENANT_SCOPE_FIELD_DEF }`), so
113+
// `hasPhantomTenantAnchor` reads the PLATFORM's provenance and Layer 0 treats
114+
// the object as carrying no tenant column. The platform issues no DDL for an
115+
// `external` object, so that column exists in the registry and nowhere else.
116+
ext_customer: {
117+
name: 'ext_customer',
118+
external: { remoteName: 'customers' },
119+
fields: {
120+
organization_id: { ...TENANT_SCOPE_FIELD_DEF },
121+
title: { type: 'text', label: 'Title' },
122+
status: { type: 'text', label: 'Status' },
123+
},
124+
},
125+
// The provenance control, federated too: this `organization_id` is the
126+
// AUTHOR's — a real remote column — so the wall is doing real work and stays.
127+
// The phantom exit is about PROVENANCE, never about `external != null`.
128+
ext_ledger: {
129+
name: 'ext_ledger',
130+
external: { remoteName: 'ledgers' },
131+
fields: {
132+
organization_id: { type: 'text', label: 'Org (a real remote column)' },
133+
title: { type: 'text', label: 'Title' },
134+
status: { type: 'text', label: 'Status' },
135+
},
136+
},
137+
138+
// A custom tenant column declared BESIDE the kernel one — the wall keys on
139+
// the literal `organization_id` and never reads `tenancy.tenantField`.
140+
workspace_doc: withFields(localSchema('workspace_doc', { tenancy: { tenantField: 'workspace_id' } }), {
141+
workspace_id: { type: 'text', label: 'Workspace' },
142+
}),
143+
// The same declaration on an object that carries NO `organization_id`: the
144+
// custom column is never read as a stand-in, so there is no wall to record.
145+
workspace_note: {
146+
name: 'workspace_note',
147+
tenancy: { tenantField: 'workspace_id' },
148+
fields: {
149+
workspace_id: { type: 'text', label: 'Workspace' },
150+
title: { type: 'text', label: 'Title' },
151+
status: { type: 'text', label: 'Status' },
152+
},
153+
},
78154
};
79155

80-
async function boot(opts: { entitlement?: Record<string, unknown>; tenancy?: { posture: string }; sets?: PermissionSet[] } = {}) {
156+
/**
157+
* [#15887 / ADR-0090 D10] Seed for the on-behalf-of leg: the `sys_*` rows the
158+
* delegator resolution reads (`resolveDelegatorContext` -> `buildContextForUser`
159+
* -> core's `resolveUserAuthzGrants`). `memberOf` becomes `sys_member` rows,
160+
* which is where the delegator's OWN `accessible_org_ids` come from — and that
161+
* set is the one Layer 0 input a delegated context does NOT inherit from the
162+
* live principal, so it is the only way the two walls can differ at all.
163+
* An empty `memberOf` still seeds the `sys_user` row: a MISSING delegator is a
164+
* different contract (a fail-closed refusal before any wall is composed).
165+
*/
166+
type DelegatorSeed = { userId: string; memberOf: string[] };
167+
168+
async function boot(opts: {
169+
entitlement?: Record<string, unknown>;
170+
tenancy?: { posture: string };
171+
sets?: PermissionSet[];
172+
delegator?: DelegatorSeed;
173+
} = {}) {
81174
const middlewares: Array<(opCtx: any, next: () => Promise<void>) => Promise<void>> = [];
175+
// [#15887] Only the delegated leg needs a readable store; without a seed the
176+
// tables are empty and `findOne` answers `null` for every object exactly as
177+
// before, so the cases above are byte-identical. `find` is added ONLY under a
178+
// seed — the non-delegated path never issues one, and a `ql` without `find`
179+
// is what the earlier cases were measured against.
180+
const del = opts.delegator;
181+
const tables: Record<string, Record<string, unknown>[]> = del
182+
? {
183+
sys_user: [{ id: del.userId, email: `${del.userId}@example.test` }],
184+
sys_member: del.memberOf.map((organization_id) => ({ user_id: del.userId, organization_id })),
185+
}
186+
: {};
187+
// Plain equality is all the delegator resolution ever asks for (`{ id }`,
188+
// `{ user_id }`). A combinator read as a FIELD NAME would match nothing and
189+
// say nothing, so this double REFUSES what it does not implement rather than
190+
// answering quietly — `check:where-matcher` refuses exactly the quiet shape.
191+
const matches = (row: Record<string, unknown>, where: Record<string, unknown> | undefined): boolean =>
192+
Object.entries(where ?? {}).every(([k, v]) => {
193+
if (k.startsWith('$')) throw new Error(`fake engine: unsupported combinator ${k}`);
194+
if (v && typeof v === 'object') throw new Error(`fake engine: unsupported operator on '${k}'`);
195+
return row[k] === v;
196+
});
197+
const rowsOf = (object: string, where: Record<string, unknown> | undefined) =>
198+
(tables[object] ?? []).filter((r) => matches(r, where));
82199
const services: Record<string, unknown> = {
83200
manifest: { register: vi.fn() },
84201
objectql: {
85202
registerMiddleware: (mw: any) => middlewares.push(mw),
86203
getSchema: (name: string) => SCHEMAS[name],
87-
findOne: vi.fn(async () => null),
204+
findOne: vi.fn(async (object: string, o: any) => rowsOf(object, o?.where)[0] ?? null),
205+
...(del
206+
? {
207+
// The caller's bound is applied AFTER the filter and BY PRESENCE:
208+
// core's grants resolution hands every one of these reads a `limit`,
209+
// and a double that silently ignores it cannot report what the real
210+
// engine would (`check:objectql-double-limit`).
211+
find: async (object: string, o: any) => {
212+
const rows = rowsOf(object, o?.where);
213+
return typeof o?.limit === 'number' ? rows.slice(0, o.limit) : rows;
214+
},
215+
}
216+
: {}),
88217
},
89218
metadata: {
90219
get: async (_type: string, name: string) => SCHEMAS[name],
@@ -123,6 +252,18 @@ function sweep(object: string, operation: 'update' | 'delete', context: Record<s
123252
return opCtx;
124253
}
125254

255+
/**
256+
* [#15887] A READ — the other half of the `opCtx.ast` branch the verdict is
257+
* recorded in. Needed for the fail-closed delegated leg: a predicate WRITE by a
258+
* principal whose delegator holds no organization scope is refused by the
259+
* ADR-0123 D2 write check BEFORE the wall is composed, so the write shape could
260+
* not observe the recorded verdict there at all.
261+
*/
262+
function readSweep(object: string, context: Record<string, unknown>) {
263+
const where = { status: 'open' };
264+
return { object, operation: 'find', context: { ...context }, options: { where }, ast: { where } } as any;
265+
}
266+
126267
const hasVerdict = (opCtx: any) => Object.prototype.hasOwnProperty.call(opCtx, 'tenantLayer0Verdict');
127268
const injectedOrgWall = (opCtx: any): unknown => {
128269
// The wall is AND-ed under the caller's predicate; find the organization_id clause.
@@ -137,6 +278,22 @@ const injectedOrgWall = (opCtx: any): unknown => {
137278
};
138279
return walk(opCtx.ast?.where);
139280
};
281+
/**
282+
* [#15887] EVERY `organization_id` clause in the composed tree, in composition
283+
* order. The on-behalf-of leg injects TWO walls (the caller's, then the
284+
* delegator's — `extra` is pushed in that order and spread into one `$and`),
285+
* and the single-hit walker above would report only the first.
286+
*/
287+
const injectedOrgWalls = (opCtx: any): unknown[] => {
288+
const out: unknown[] = [];
289+
const walk = (node: any): void => {
290+
if (!node || typeof node !== 'object') return;
291+
if ('organization_id' in node) out.push(node.organization_id);
292+
for (const part of node.$and ?? []) walk(part);
293+
};
294+
walk(opCtx.ast?.where);
295+
return out;
296+
};
140297

141298
describe('[#15813] the middleware records the Layer 0 verdict it composed — one pass, verdict and predicate agree', () => {
142299
it('`isolated`, a member with an active organization: `organization`, and the injected wall is that equality', async () => {
@@ -263,3 +420,157 @@ describe('[#15813] nothing is recorded where no wall was composed — absence is
263420
expect(await (plugin as any).getReadFilter('sys_catalog', MEMBER_CTX)).toBeUndefined();
264421
});
265422
});
423+
424+
// ---------------------------------------------------------------------------
425+
// [#15887] The four shapes that were correct BY CONSTRUCTION and pinned only
426+
// one layer away. Nothing below claims any of them is broken — every one of
427+
// them holds on `main` today. What was missing is a reading on the RECORDING
428+
// side: each rested on the projection's own pins, i.e. on the identity 「the
429+
// verdict recorded is the object the predicate was projected from」. That
430+
// identity is exactly what a future change to this seam would break first, and
431+
// when it breaks, the projection-side pins stay green.
432+
//
433+
// So each case reads `opCtx.tenantLayer0Verdict` — the recorded object itself,
434+
// never the downstream filter and never `getReadFilter`'s projection — off ONE
435+
// middleware pass, and reads the injected predicate beside it as the control.
436+
// ---------------------------------------------------------------------------
437+
describe('[#15887] the three object shapes record their verdict HERE, not one layer away', () => {
438+
it('`systemFields.tenant: false` beside an AUTHOR-DECLARED `organization_id` records `none` (P1) — a readable column is not a wall', async () => {
439+
const { middleware } = await boot();
440+
// The fixture's premise, asserted rather than recalled: the column really
441+
// is there to be misread. (That the REGISTRY leaves an authored column
442+
// standing under this opt-out is the registry's own fact, pinned where the
443+
// registry lives; here it is the input.)
444+
expect((SCHEMAS.shared_catalog.fields as Record<string, unknown>).organization_id).toBeDefined();
445+
446+
const opCtx = sweep('shared_catalog', 'update', MEMBER_CTX);
447+
await middleware(opCtx, async () => {});
448+
expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'none' });
449+
expect(injectedOrgWall(opCtx)).toBeUndefined();
450+
451+
// Firing control on the same boot: the sibling with no opt-out IS walled,
452+
// so `none` above is this object's verdict and not a dead middleware.
453+
const sibling = sweep('crm_task', 'update', MEMBER_CTX);
454+
await middleware(sibling, async () => {});
455+
expect(sibling.tenantLayer0Verdict).toEqual({ kind: 'organization', organizationId: 'org-1' });
456+
});
457+
458+
it('[#7835] a FEDERATED object carrying the PLATFORM\'s injected anchor records `none` — the phantom column is not a wall', async () => {
459+
const { middleware } = await boot();
460+
const opCtx = sweep('ext_customer', 'update', MEMBER_CTX);
461+
await middleware(opCtx, async () => {});
462+
expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'none' });
463+
expect(injectedOrgWall(opCtx)).toBeUndefined();
464+
});
465+
466+
it('[#7835] a FEDERATED object whose AUTHOR declared a real remote `organization_id` records `organization` — the exit is PROVENANCE, not `external`', async () => {
467+
// The half that keeps the phantom exit from becoming "suppress Layer 0 for
468+
// every federated object", which would delete a wall that is doing its job.
469+
const { middleware } = await boot();
470+
const opCtx = sweep('ext_ledger', 'update', MEMBER_CTX);
471+
await middleware(opCtx, async () => {});
472+
expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'organization', organizationId: 'org-1' });
473+
expect(injectedOrgWall(opCtx)).toBe('org-1');
474+
});
475+
476+
it('a custom `tenancy.tenantField` is NOT an exit by itself: still `organization`, and the wall names `organization_id`', async () => {
477+
// Layer 0 keys on the literal `organization_id` and never reads
478+
// `tenancy.tenantField`. An object declaring a custom tenant column while
479+
// still carrying the kernel one is walled on the kernel one — so the
480+
// recorded verdict names the organization, and the injected predicate names
481+
// `organization_id`, never `workspace_id`.
482+
const { middleware } = await boot();
483+
const opCtx = sweep('workspace_doc', 'update', MEMBER_CTX);
484+
await middleware(opCtx, async () => {});
485+
expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'organization', organizationId: 'org-1' });
486+
expect(injectedOrgWall(opCtx)).toBe('org-1');
487+
expect(JSON.stringify(opCtx.ast.where)).not.toContain('workspace_id');
488+
});
489+
490+
it('a custom `tenancy.tenantField` on an object carrying NO `organization_id` records `none` — the custom column is never a substitute', async () => {
491+
// The other direction of the same fact: the declaration does not make
492+
// `workspace_id` a tenant column the wall can key on, so with the kernel
493+
// column absent there is no wall — and `none` is the honest verdict, not a
494+
// wall silently relocated onto the author's column.
495+
const { middleware } = await boot();
496+
expect((SCHEMAS.workspace_note.fields as Record<string, unknown>).organization_id).toBeUndefined();
497+
const opCtx = sweep('workspace_note', 'update', MEMBER_CTX);
498+
await middleware(opCtx, async () => {});
499+
expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'none' });
500+
expect(injectedOrgWall(opCtx)).toBeUndefined();
501+
});
502+
});
503+
504+
describe('[#15887 / ADR-0090 D10] the on-behalf-of INTERSECTION is recorded at the middleware line', () => {
505+
// `intersectTenantLayer0Verdicts` is unit-pinned in `tenant-layer.test.ts`.
506+
// What had no pin is the LINE that calls it: that after a delegated pass the
507+
// recorded verdict is the intersection of the two walls the middleware
508+
// AND-composed — not the caller's half, which is what the site would record
509+
// if that call were ever dropped. These cases therefore assert something
510+
// NEITHER wall states on its own, which a re-run of the unit assertion in
511+
// this file could not do.
512+
const DELEGATOR = 'u2';
513+
// `group` is the only posture in which the two walls can differ: the
514+
// delegator's `accessible_org_ids` are resolved from ITS OWN memberships and
515+
// are deliberately not inherited, while `tenantId` is (so under `isolated`
516+
// both halves resolve to the same organization by construction).
517+
const GROUP = { posture: 'group' };
518+
const CALLER_ORGS = ['org-1', 'org-2', 'org-3'];
519+
520+
it('the recorded verdict is the INTERSECTION — an organization set neither wall names alone', async () => {
521+
const { middleware } = await boot({
522+
tenancy: GROUP,
523+
delegator: { userId: DELEGATOR, memberOf: ['org-2', 'org-3', 'org-9'] },
524+
});
525+
const opCtx = sweep('crm_task', 'update', {
526+
...MEMBER_CTX,
527+
accessible_org_ids: CALLER_ORGS,
528+
onBehalfOf: { userId: DELEGATOR },
529+
});
530+
await middleware(opCtx, async () => {});
531+
532+
// Both walls really were composed onto this one operation, in order.
533+
expect(injectedOrgWalls(opCtx)).toEqual([
534+
{ $in: ['org-1', 'org-2', 'org-3'] },
535+
{ $in: ['org-2', 'org-3', 'org-9'] },
536+
]);
537+
// And the recorded verdict is what a row must satisfy to clear BOTH — a
538+
// set that is neither injected clause. Dropping the intersection at the
539+
// call site records the caller's three organizations while the composed
540+
// predicate admits two: the recorded verdict would then over-state the
541+
// batch's reach, on a wall the middleware itself narrowed.
542+
expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'organizations', organizationIds: ['org-2', 'org-3'] });
543+
});
544+
545+
it('the SAME fixture without the delegation link records the caller\'s half — the link is what moves the answer', async () => {
546+
// Identical boot and identical caller, minus `onBehalfOf`: the case above
547+
// is a reading about the intersection, not about the `group` posture.
548+
const { middleware } = await boot({
549+
tenancy: GROUP,
550+
delegator: { userId: DELEGATOR, memberOf: ['org-2', 'org-3', 'org-9'] },
551+
});
552+
const opCtx = sweep('crm_task', 'update', { ...MEMBER_CTX, accessible_org_ids: CALLER_ORGS });
553+
await middleware(opCtx, async () => {});
554+
expect(injectedOrgWalls(opCtx)).toEqual([{ $in: ['org-1', 'org-2', 'org-3'] }]);
555+
expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'organizations', organizationIds: CALLER_ORGS });
556+
});
557+
558+
it('a delegator with NO membership makes the recorded verdict `deny` — the composed wall fails closed, the caller\'s half does not', async () => {
559+
// A READ (see `readSweep`): the write twin is refused by the ADR-0123 D2
560+
// check before the wall is composed, so the write shape cannot observe this
561+
// at all. The caller's own half names three organizations; the delegator's
562+
// empty access set denies; the AND of the two admits no row, and the
563+
// recorded verdict says so rather than naming an organization.
564+
const { middleware } = await boot({
565+
tenancy: GROUP,
566+
delegator: { userId: DELEGATOR, memberOf: [] },
567+
});
568+
const opCtx = readSweep('crm_task', {
569+
...MEMBER_CTX,
570+
accessible_org_ids: CALLER_ORGS,
571+
onBehalfOf: { userId: DELEGATOR },
572+
});
573+
await middleware(opCtx, async () => {});
574+
expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'deny' });
575+
});
576+
});

0 commit comments

Comments
 (0)