Skip to content

Commit 43b0b15

Browse files
committed
test: unit-layer authz matrix gate before Layer 0 extraction
ADR-0095 Sequencing step 0. Adds a role × object × {read,write} snapshot at the plugin-security unit layer (seconds, not a dogfood boot) that drives the real SecurityPlugin CRUD middleware with the real seeded permission sets and locks the effective RLS filter each cell produces. Locks current behavior — including the two structural weaknesses ADR-0095 closes so the extraction (D1) flips exactly those cells and nothing else: - [W1] a permissive business RLS policy (status == 'public') is OR-merged with the wildcard tenant policy, so a cross-org public row is VISIBLE. - [W1-write] owner_only_writes is widened back to org-wide by the same OR-merge (a member can by-id-write any row in their org). - [W2] the superuser bypass short-circuits both layers on private / platform-global / better-auth objects; withheld on public business objects by the posture gate. - fail-closed no-active-org; single-mode tenant-policy stripping. No production code changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs
1 parent 94f33ec commit 43b0b15

1 file changed

Lines changed: 286 additions & 0 deletions

File tree

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// ── Unit-layer authorization matrix gate (ADR-0095 Sequencing step 0) ───────
4+
//
5+
// ADR-0095 makes tenant isolation a behavior-preserving-by-contract refactor:
6+
// every step lands behind a `role × object × expected-visible-rows` snapshot,
7+
// and any delta the snapshot exposes is a bug to chase — with ONE deliberate
8+
// exception (the W1 cross-tenant read fix). The existing conformance matrix
9+
// (`packages/dogfood/test/authz-conformance.matrix.ts`) proves this end-to-end
10+
// through a real app boot (minutes). This file is the UNIT-LAYER equivalent so
11+
// the extraction loop is seconds: it drives the real SecurityPlugin CRUD
12+
// middleware with the real seeded permission sets and snapshots the *effective
13+
// RLS filter* each (role × object × operation) cell produces.
14+
//
15+
// The "expected-visible-rows" semantics are encoded as the compiled filter that
16+
// the engine would AND onto the query (read path) or verify the target row
17+
// against (by-id write pre-image). Two filters that select the same rows are the
18+
// same visibility; the snapshot is that filter, verbatim.
19+
//
20+
// This file adds NO production code. It LOCKS current behavior — including the
21+
// two structural weaknesses ADR-0095 closes (W1 read leak, and its write-side
22+
// twin: `owner_only_writes` defeated by the tenant OR-merge) — so that when Layer
23+
// 0 (D1) is extracted, exactly those cells flip and nothing else moves.
24+
25+
import { describe, it, expect, vi } from 'vitest';
26+
import { SecurityPlugin } from './security-plugin.js';
27+
import { defaultPermissionSets } from './objects/default-permission-sets.js';
28+
import { RLS_DENY_FILTER } from './rls-compiler.js';
29+
import type { PermissionSet } from '@objectstack/spec/security';
30+
31+
// A permissive, admin-authored business RLS policy (ADR-0095 W1's worked
32+
// example): "everyone may read rows whose status is public". At the RLS layer
33+
// this is OR-merged with the wildcard tenant policy today — so it is, by itself,
34+
// sufficient to admit a row from ANOTHER organization. Modeled here as a custom
35+
// set because W1 is about ANY permissive business policy, not a seeded one.
36+
const publicReader: PermissionSet = {
37+
name: 'public_reader',
38+
label: 'Public Reader (permissive business RLS)',
39+
objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true } },
40+
rowLevelSecurity: [
41+
{ name: 'public_read', object: '*', operation: 'select', using: "status == 'public'" },
42+
],
43+
} as any;
44+
45+
const ALL_SETS: PermissionSet[] = [...defaultPermissionSets, publicReader];
46+
const DENY = RLS_DENY_FILTER.id; // the fail-closed sentinel's marker value
47+
48+
// ── Minimal middleware harness ──────────────────────────────────────────────
49+
// Drives the REAL security CRUD middleware against a single-object schema whose
50+
// posture (public / private / tenancy-disabled / better-auth-managed) and field
51+
// set are configurable, so one helper covers the whole object axis.
52+
function makeHarness(opts: {
53+
objectName: string;
54+
objectFields: string[];
55+
schemaExtra?: Record<string, any>;
56+
orgScoping?: boolean;
57+
findOneImpl?: (q: any) => any;
58+
}) {
59+
const fields: Record<string, any> = {};
60+
for (const f of opts.objectFields) fields[f] = { name: f };
61+
const baseSchema: any = { name: opts.objectName, fields, ...(opts.schemaExtra ?? {}) };
62+
let middleware: any;
63+
const findOne = vi.fn(async (_o: string, q: any) => (opts.findOneImpl ? opts.findOneImpl(q) : null));
64+
const ql = {
65+
registerMiddleware: (mw: any) => { if (!middleware) middleware = mw; },
66+
getSchema: () => baseSchema,
67+
findOne,
68+
};
69+
const metadata = { get: async () => baseSchema, list: () => ALL_SETS };
70+
const services: Record<string, any> = { manifest: { register: vi.fn() }, objectql: ql, metadata };
71+
// Multi-org isolation active iff org-scoping is wired (ADR-0093 D4 — the exact
72+
// signal SecurityPlugin probes). `tenancy` service is absent here, so the
73+
// plugin falls back to the `org-scoping` probe (same as production baseline).
74+
if (opts.orgScoping) services['org-scoping'] = { name: 'org-scoping' };
75+
const ctx: any = {
76+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
77+
registerService: vi.fn(),
78+
getService: (name: string) => {
79+
if (!(name in services)) throw new Error(`no service: ${name}`);
80+
return services[name];
81+
},
82+
};
83+
return { ctx, findOne, run: async (opCtx: any) => { await middleware(opCtx, async () => {}); return opCtx; } };
84+
}
85+
86+
/** Effective READ filter the engine would AND onto a `find` (the visible-row set). */
87+
async function readFilter(cell: any, roleCtx: any): Promise<unknown> {
88+
const plugin = new SecurityPlugin();
89+
const h = makeHarness({ ...cell, orgScoping: cell.orgScoping ?? true });
90+
await plugin.init(h.ctx); await plugin.start(h.ctx);
91+
const opCtx: any = { object: cell.objectName, operation: 'find', ast: { where: undefined }, context: roleCtx };
92+
try { await h.run(opCtx); } catch (e: any) { return `CRUD_DENY:${e?.name ?? 'err'}`; }
93+
return opCtx.ast.where ?? null;
94+
}
95+
96+
/**
97+
* Effective WRITE filter used by the by-id update pre-image check — the row the
98+
* caller is allowed to mutate must satisfy it. Returned as the array of RLS
99+
* parts ANDed with the `{id}` guard (that guard is stripped). `BYPASS` = no
100+
* write filter (superuser). `CRUD_DENY` = blocked before the pre-image check.
101+
*/
102+
async function writeFilter(cell: any, roleCtx: any): Promise<unknown> {
103+
const plugin = new SecurityPlugin();
104+
const h = makeHarness({ ...cell, orgScoping: cell.orgScoping ?? true, findOneImpl: () => null });
105+
await plugin.init(h.ctx); await plugin.start(h.ctx);
106+
const opCtx: any = {
107+
object: cell.objectName, operation: 'update',
108+
data: { id: 'r1', name: 'x' }, options: { where: { id: 'r1' } }, context: roleCtx,
109+
};
110+
let threw: any = null;
111+
try { await h.run(opCtx); } catch (e: any) { threw = e; }
112+
if (h.findOne.mock.calls.length === 0) {
113+
return threw ? `CRUD_DENY:${threw?.name ?? 'err'}` : 'BYPASS(no-write-filter)';
114+
}
115+
return h.findOne.mock.calls[0][1].where.$and.slice(1);
116+
}
117+
118+
// ── Axes ─────────────────────────────────────────────────────────────────────
119+
const OBJECTS = {
120+
// Ordinary tenant business object: has organization_id, public posture.
121+
task: { objectName: 'task', objectFields: ['id', 'organization_id', 'created_by', 'status', 'name'] },
122+
// Private object (access.default: private) — plain wildcard grant does NOT cover it (ADR-0066 ④).
123+
private_obj: { objectName: 'crm_secret', objectFields: ['id', 'organization_id', 'created_by', 'name'], schemaExtra: { access: { default: 'private' } } },
124+
// Platform-global object (tenancy.enabled: false), no organization_id column.
125+
platform_global: { objectName: 'sys_package', objectFields: ['id', 'name', 'visibility'], schemaExtra: { tenancy: { enabled: false } } },
126+
// Better-auth-managed identity table (managedBy: 'better-auth'); writes flow through better-auth.
127+
better_auth: { objectName: 'sys_user', objectFields: ['id', 'email', 'name'], schemaExtra: { managedBy: 'better-auth' } },
128+
};
129+
130+
const ROLES = {
131+
// Platform admin: holds admin_full_access (viewAllRecords/modifyAllRecords) — the superuser bypass evidence.
132+
platform_admin: { userId: 'padmin', tenantId: 'org-1', positions: ['platform_admin'], permissions: ['admin_full_access'] },
133+
// Org admin: holds organization_admin (also viewAll/modifyAll, but tenant-scoped by its RLS).
134+
org_admin: { userId: 'oadmin', tenantId: 'org-1', positions: ['org_admin'], permissions: ['organization_admin'] },
135+
// Rank-and-file member: only the additive member_default baseline; org_member gates owner_only_*.
136+
member: { userId: 'u1', tenantId: 'org-1', positions: ['org_member'], permissions: [] },
137+
// Authenticated user with NO active organization → tenant scoping cannot resolve → fail-closed.
138+
no_org_member: { userId: 'u2', positions: ['org_member'], permissions: [] },
139+
};
140+
141+
// The locked snapshot of CURRENT behavior (captured from the real middleware +
142+
// real seeded permission sets). Read the annotations against ADR-0095:
143+
// • [posture-gate] platform/org admin are org-scoped on PUBLIC business
144+
// objects — the ADR-0066 ① gate keeps the superuser bypass from crossing
145+
// the tenant wall on ordinary tenant data. The bypass only fires on
146+
// private / platform-global / better-auth objects (W2 short-circuit).
147+
// • [W1-write] member.task.write is `org OR created_by` — the tenant policy's
148+
// OR-merge WIDENS `owner_only_writes` back to org-wide, defeating the
149+
// owner restriction. This is W1's write-side twin.
150+
// • [fail-closed] no-org member on a tenant object → deny sentinel.
151+
const EXPECTED_MATRIX: Record<string, Record<string, { read: unknown; write: unknown }>> = {
152+
task: {
153+
// [posture-gate] org-scoped read; write = tenant only (owner_only is org_member-gated, admin is not).
154+
platform_admin: { read: { organization_id: 'org-1' }, write: [{ organization_id: 'org-1' }] },
155+
// org_admin resolves tenant_isolation from BOTH organization_admin and the member_default baseline → duplicated OR.
156+
org_admin: {
157+
read: { $or: [{ organization_id: 'org-1' }, { organization_id: 'org-1' }] },
158+
write: [{ $or: [{ organization_id: 'org-1' }, { organization_id: 'org-1' }] }],
159+
},
160+
// [W1-write] read is cleanly org-scoped, but the WRITE pre-image is widened to org-wide by the OR-merge.
161+
member: {
162+
read: { organization_id: 'org-1' },
163+
write: [{ $or: [{ organization_id: 'org-1' }, { created_by: 'u1' }] }],
164+
},
165+
// [fail-closed] no active org → tenant policy cannot compile → deny sentinel on read; write keeps only owner scope.
166+
no_org_member: { read: { id: DENY }, write: [{ created_by: 'u2' }] },
167+
},
168+
private_obj: {
169+
// [W2 bypass] superuser bit + private posture → all RLS skipped.
170+
platform_admin: { read: null, write: 'BYPASS(no-write-filter)' },
171+
org_admin: { read: null, write: 'BYPASS(no-write-filter)' },
172+
// A member's plain wildcard grant does not cover a private object → denied at the CRUD gate, before RLS.
173+
member: { read: 'CRUD_DENY:PermissionDeniedError', write: 'CRUD_DENY:PermissionDeniedError' },
174+
no_org_member: { read: 'CRUD_DENY:PermissionDeniedError', write: 'CRUD_DENY:PermissionDeniedError' },
175+
},
176+
platform_global: {
177+
// [W2 bypass] tenancy-disabled posture → superuser bypass fires.
178+
platform_admin: { read: null, write: 'BYPASS(no-write-filter)' },
179+
org_admin: { read: null, write: 'BYPASS(no-write-filter)' },
180+
// NOTE (pre-existing quirk): the seeded tenant_isolation uses canonical `==`,
181+
// which `extractTargetField` (single-`=`/IN only) cannot parse, so the
182+
// tenancy-disabled field-drop never fires for it — the policy compiles to an
183+
// org filter even though this object has no organization_id column. Locked
184+
// as-is; Layer 0 (D1) subsumes tenancy-disabled handling into its own "is
185+
// this a tenant object?" check, retiring this quirk structurally.
186+
member: {
187+
read: { organization_id: 'org-1' },
188+
write: [{ $or: [{ organization_id: 'org-1' }, { created_by: 'u1' }] }],
189+
},
190+
no_org_member: { read: { id: DENY }, write: [{ created_by: 'u2' }] },
191+
},
192+
better_auth: {
193+
// [W2 bypass] better-auth-managed posture → superuser read bypass.
194+
platform_admin: { read: null, write: 'BYPASS(no-write-filter)' },
195+
// Org admin sees own-org identity rows OR self (per-object _self carve-outs, duplicated via baseline); writes denied (better-auth door).
196+
org_admin: {
197+
read: { $or: [{ organization_id: 'org-1' }, { id: 'oadmin' }, { organization_id: 'org-1' }, { id: 'oadmin' }] },
198+
write: 'CRUD_DENY:PermissionDeniedError',
199+
},
200+
// Member: own-org collaborators OR self; writes denied.
201+
member: { read: { $or: [{ organization_id: 'org-1' }, { id: 'u1' }] }, write: 'CRUD_DENY:PermissionDeniedError' },
202+
// No-org member: only self (org disjunct cannot compile); writes denied.
203+
no_org_member: { read: { id: 'u2' }, write: 'CRUD_DENY:PermissionDeniedError' },
204+
},
205+
};
206+
207+
describe('authz Layer-0 matrix gate — ADR-0095 pre-extraction snapshot', () => {
208+
it('locks the current role × object × {read,write} effective-filter matrix', async () => {
209+
const actual: Record<string, Record<string, { read: unknown; write: unknown }>> = {};
210+
for (const [oName, cell] of Object.entries(OBJECTS)) {
211+
actual[oName] = {};
212+
for (const [rName, role] of Object.entries(ROLES)) {
213+
actual[oName][rName] = { read: await readFilter(cell, role), write: await writeFilter(cell, role) };
214+
}
215+
}
216+
expect(actual).toEqual(EXPECTED_MATRIX);
217+
});
218+
219+
// ── W1: cross-tenant read leak via a permissive business policy ────────────
220+
// ADR-0095 Sequencing step 0 asks specifically for this cell. A user holding a
221+
// permissive business RLS policy (`status == 'public'`) reads a tenant object.
222+
// TODAY the wildcard tenant policy is OR-merged with it, so the effective read
223+
// filter is `tenant OR status==public` — a row in ANOTHER org whose status is
224+
// public matches the second disjunct and IS VISIBLE. This test records that
225+
// leak. [ADR-0095 W1] After Layer 0 (D1) extraction the effective read becomes
226+
// `Layer0(organization_id == ctx.org) AND Layer1(status == public)`, and the
227+
// cross-org public row becomes invisible — at which point this assertion FLIPS
228+
// (see the trailing note). It is deliberately kept green now so the gate
229+
// locks the current (leaking) behavior before the fix.
230+
it('[W1] permissive business RLS currently OR-merges with tenant scope (cross-org public row VISIBLE)', async () => {
231+
const filter = await readFilter(OBJECTS.task, {
232+
userId: 'u3', tenantId: 'org-1', positions: ['org_member'], permissions: ['public_reader'],
233+
});
234+
// CURRENT (pre-D1): OR-merge — the tenant wall is just one disjunct.
235+
expect(filter).toEqual({ $or: [{ organization_id: 'org-1' }, { status: 'public' }] });
236+
// A concrete foreign-org public row would pass this filter today:
237+
const foreignPublicRow = { organization_id: 'org-2', status: 'public' };
238+
const orClauses = (filter as any).$or as Array<Record<string, unknown>>;
239+
const visibleToday = orClauses.some((c) =>
240+
Object.entries(c).every(([k, v]) => (foreignPublicRow as any)[k] === v));
241+
expect(visibleToday).toBe(true); // ← [ADR-0095 W1] the leak. D1 must make this false.
242+
});
243+
244+
// ── W1's write-side twin: owner_only defeated by the tenant OR-merge ───────
245+
// The same OR-merge that leaks reads also WIDENS a restrictive write policy.
246+
// A member's `owner_only_writes` (created_by == me) is OR'd with the wildcard
247+
// tenant policy, so the by-id write pre-image resolves to `org OR created_by`
248+
// = any row in the member's org. The owner restriction is silently defeated.
249+
// After D1 this becomes `Layer0(org) AND Layer1(created_by == me)` = owner-only
250+
// — a behavior change beyond the ADR's stated single (read) delta. Locked here
251+
// so the extraction surfaces it for explicit review rather than shipping it
252+
// silently. See the PR's "second delta" callout.
253+
it('[W1-write] member by-id write is currently widened to org-wide by the OR-merge', async () => {
254+
const wf = await writeFilter(OBJECTS.task, ROLES.member);
255+
expect(wf).toEqual([{ $or: [{ organization_id: 'org-1' }, { created_by: 'u1' }] }]);
256+
});
257+
258+
// ── W2: the superuser bypass short-circuits BOTH layers via one bit ────────
259+
// On private / platform-global / better-auth objects a superuser-bit holder
260+
// skips ALL wildcard RLS — tenant wall included — through a single check. On a
261+
// PUBLIC business object the posture gate withholds the bypass, so the admin
262+
// stays org-scoped. Both facets locked.
263+
it('[W2] superuser bypass fires on private/platform-global/better-auth, withheld on public business objects', async () => {
264+
expect(await readFilter(OBJECTS.private_obj, ROLES.platform_admin)).toBeNull();
265+
expect(await readFilter(OBJECTS.platform_global, ROLES.platform_admin)).toBeNull();
266+
expect(await readFilter(OBJECTS.better_auth, ROLES.platform_admin)).toBeNull();
267+
// Withheld on a public tenant object → admin remains org-scoped (the posture gate).
268+
expect(await readFilter(OBJECTS.task, ROLES.platform_admin)).toEqual({ organization_id: 'org-1' });
269+
});
270+
271+
// ── Fail-closed: an authenticated user with no active org sees no tenant rows ─
272+
it('[fail-closed] no active organization → tenant read denies via the sentinel', async () => {
273+
expect(await readFilter(OBJECTS.task, ROLES.no_org_member)).toEqual({ id: DENY });
274+
});
275+
276+
// ── Single-org mode: Layer 0 is inert; tenant policy stripped (parity today) ─
277+
// With org-scoping absent, collectRLSPolicies strips the wildcard tenant policy
278+
// entirely, so a member's read carries NO tenant where and the write keeps only
279+
// the owner scope. ADR-0095: in single mode Layer 0 contributes nothing — this
280+
// cell must NOT move after the extraction.
281+
it('[single-mode] tenant policy stripped when org-scoping is absent', async () => {
282+
const single = { ...OBJECTS.task, orgScoping: false };
283+
expect(await readFilter(single, ROLES.member)).toBeNull();
284+
expect(await writeFilter(single, ROLES.member)).toEqual([{ created_by: 'u1' }]);
285+
});
286+
});

0 commit comments

Comments
 (0)