-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathauthz-matrix-gate.test.ts
More file actions
343 lines (326 loc) · 20.6 KB
/
Copy pathauthz-matrix-gate.test.ts
File metadata and controls
343 lines (326 loc) · 20.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// ── Unit-layer authorization matrix gate (ADR-0095 D1) ──────────────────────
//
// ADR-0095 makes tenant isolation a refactor validated by a `role × object ×
// expected-visible-rows` snapshot: every cell must match across the Layer 0
// extraction EXCEPT the four deltas the architect accepted (all W1-class,
// all toward stronger/correcter isolation — see below). The existing
// conformance matrix (`packages/dogfood/test/authz-conformance.matrix.ts`)
// proves this end-to-end through a real app boot (minutes). This file is the
// UNIT-LAYER equivalent so the loop is seconds: it drives the real
// SecurityPlugin CRUD middleware with the real seeded permission sets and
// snapshots the *effective RLS filter* each (role × object × operation) cell
// produces — the compiled filter the engine ANDs onto a read, or verifies the
// by-id write target against. Two filters selecting the same rows are the same
// visibility; the snapshot is that filter, verbatim.
//
// This file adds NO production code; it is the gate the extraction landed behind.
// The four accepted, ADR-authorized deltas (post-extraction values below):
// (a) [W1 read] a permissive business policy no longer OR-widens tenant scope
// — a cross-org `public` row becomes INVISIBLE (Layer0 AND Layer1).
// (b) [W1 write] `owner_only_writes` is no longer OR-diluted by the tenant
// policy — a member's by-id write narrows to OWNER-only (was org-wide).
// (c) [tenancy-disabled] a member reading a `tenancy.enabled:false` global
// object is no longer scoped by a phantom org filter — the global catalog
// is VISIBLE (Layer 0 correctly treats it as a non-tenant object; this
// also retires the `extractTargetField` `==` blind spot for tenant scope).
// (e) [no active org] a write by a principal with no active organization on a
// tenant object is FAIL-CLOSED by Layer 0 (was owner-scoped only).
// Every OTHER change vs the pre-extraction snapshot is a same-visibility filter
// simplification (duplicate-OR dedup; dead org-clause removal on non-tenant
// objects) and is annotated inline.
import { describe, it, expect, vi } from 'vitest';
import { SecurityPlugin } from './security-plugin.js';
import { defaultPermissionSets } from './objects/default-permission-sets.js';
import { RLS_DENY_FILTER } from './rls-compiler.js';
import type { PermissionSet } from '@objectstack/spec/security';
// A permissive, admin-authored business RLS policy (ADR-0095 W1's worked
// example): "everyone may read rows whose status is public". At the RLS layer
// this is OR-merged with the wildcard tenant policy today — so it is, by itself,
// sufficient to admit a row from ANOTHER organization. Modeled here as a custom
// set because W1 is about ANY permissive business policy, not a seeded one.
const publicReader: PermissionSet = {
name: 'public_reader',
label: 'Public Reader (permissive business RLS)',
objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true } },
rowLevelSecurity: [
{ name: 'public_read', object: '*', operation: 'select', using: "status == 'public'" },
],
} as any;
const ALL_SETS: PermissionSet[] = [...defaultPermissionSets, publicReader];
const DENY = RLS_DENY_FILTER.id; // the fail-closed sentinel's marker value
// ── Minimal middleware harness ──────────────────────────────────────────────
// Drives the REAL security CRUD middleware against a single-object schema whose
// posture (public / private / tenancy-disabled / better-auth-managed) and field
// set are configurable, so one helper covers the whole object axis.
function makeHarness(opts: {
objectName: string;
objectFields: string[];
schemaExtra?: Record<string, any>;
orgScoping?: boolean;
findOneImpl?: (q: any) => any;
}) {
const fields: Record<string, any> = {};
for (const f of opts.objectFields) fields[f] = { name: f };
const baseSchema: any = { name: opts.objectName, fields, ...(opts.schemaExtra ?? {}) };
let middleware: any;
const findOne = vi.fn(async (_o: string, q: any) => (opts.findOneImpl ? opts.findOneImpl(q) : null));
const ql = {
registerMiddleware: (mw: any) => { if (!middleware) middleware = mw; },
getSchema: () => baseSchema,
findOne,
};
const metadata = { get: async () => baseSchema, list: () => ALL_SETS };
const services: Record<string, any> = { manifest: { register: vi.fn() }, objectql: ql, metadata };
// Multi-org isolation active iff org-scoping is wired (ADR-0093 D4 — the exact
// signal SecurityPlugin probes). `tenancy` service is absent here, so the
// plugin falls back to the `org-scoping` probe (same as production baseline).
if (opts.orgScoping) services['org-scoping'] = { name: 'org-scoping' };
const ctx: any = {
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
registerService: vi.fn(),
getService: (name: string) => {
if (!(name in services)) throw new Error(`no service: ${name}`);
return services[name];
},
};
return { ctx, findOne, run: async (opCtx: any) => { await middleware(opCtx, async () => {}); return opCtx; } };
}
/** Effective READ filter the engine would AND onto a `find` (the visible-row set). */
async function readFilter(cell: any, roleCtx: any): Promise<unknown> {
const plugin = new SecurityPlugin();
const h = makeHarness({ ...cell, orgScoping: cell.orgScoping ?? true });
await plugin.init(h.ctx); await plugin.start(h.ctx);
const opCtx: any = { object: cell.objectName, operation: 'find', ast: { where: undefined }, context: roleCtx };
try { await h.run(opCtx); } catch (e: any) { return `CRUD_DENY:${e?.name ?? 'err'}`; }
return opCtx.ast.where ?? null;
}
/**
* Effective WRITE filter used by the by-id update pre-image check — the row the
* caller is allowed to mutate must satisfy it. Returned as the array of RLS
* parts ANDed with the `{id}` guard (that guard is stripped). `BYPASS` = no
* write filter (superuser). `CRUD_DENY` = blocked before the pre-image check.
*/
async function writeFilter(cell: any, roleCtx: any): Promise<unknown> {
const plugin = new SecurityPlugin();
const h = makeHarness({ ...cell, orgScoping: cell.orgScoping ?? true, findOneImpl: () => null });
await plugin.init(h.ctx); await plugin.start(h.ctx);
const opCtx: any = {
object: cell.objectName, operation: 'update',
data: { id: 'r1', name: 'x' }, options: { where: { id: 'r1' } }, context: roleCtx,
};
let threw: any = null;
try { await h.run(opCtx); } catch (e: any) { threw = e; }
if (h.findOne.mock.calls.length === 0) {
return threw ? `CRUD_DENY:${threw?.name ?? 'err'}` : 'BYPASS(no-write-filter)';
}
return h.findOne.mock.calls[0][1].where.$and.slice(1);
}
/**
* [#2937] Effective INSERT outcome for a supplied `organization_id`. Drives the
* REAL insert path through the security CRUD middleware and reports either the
* denial marker or the post-image `organization_id` the row would land with.
* `CRUD_DENY:*` = blocked (CRUD gate or the Layer 0 tenant post-image check).
*/
async function insertOrg(cell: any, roleCtx: any, orgId: unknown): Promise<unknown> {
const plugin = new SecurityPlugin();
const h = makeHarness({ ...cell, orgScoping: cell.orgScoping ?? true });
await plugin.init(h.ctx); await plugin.start(h.ctx);
const opCtx: any = {
object: cell.objectName, operation: 'insert',
data: { name: 'x', ...(orgId === undefined ? {} : { organization_id: orgId }) },
context: roleCtx,
};
try { await h.run(opCtx); } catch (e: any) { return `CRUD_DENY:${e?.name ?? 'err'}`; }
return 'organization_id' in opCtx.data ? opCtx.data.organization_id : '<<absent>>';
}
// ── Axes ─────────────────────────────────────────────────────────────────────
const OBJECTS = {
// Ordinary tenant business object: has organization_id, public posture.
task: { objectName: 'task', objectFields: ['id', 'organization_id', 'created_by', 'status', 'name'] },
// Private object (access.default: private) — plain wildcard grant does NOT cover it (ADR-0066 ④).
private_obj: { objectName: 'crm_secret', objectFields: ['id', 'organization_id', 'created_by', 'name'], schemaExtra: { access: { default: 'private' } } },
// Platform-global object (tenancy.enabled: false), no organization_id column.
platform_global: { objectName: 'sys_package', objectFields: ['id', 'name', 'visibility'], schemaExtra: { tenancy: { enabled: false } } },
// Better-auth-managed identity table (managedBy: 'better-auth'); writes flow through better-auth.
better_auth: { objectName: 'sys_user', objectFields: ['id', 'email', 'name'], schemaExtra: { managedBy: 'better-auth' } },
};
const ROLES = {
// Platform admin: holds admin_full_access (viewAllRecords/modifyAllRecords) — the superuser bypass evidence.
platform_admin: { userId: 'padmin', tenantId: 'org-1', positions: ['platform_admin'], permissions: ['admin_full_access'] },
// Org admin: holds organization_admin (also viewAll/modifyAll, but tenant-scoped by its RLS).
org_admin: { userId: 'oadmin', tenantId: 'org-1', positions: ['org_admin'], permissions: ['organization_admin'] },
// Rank-and-file member: only the additive member_default baseline; org_member gates owner_only_*.
member: { userId: 'u1', tenantId: 'org-1', positions: ['org_member'], permissions: [] },
// Authenticated user with NO active organization → tenant scoping cannot resolve → fail-closed.
no_org_member: { userId: 'u2', positions: ['org_member'], permissions: [] },
};
// The locked snapshot of POST-EXTRACTION behavior (Layer0 AND Layer1). Read the
// annotations against ADR-0095. Cells tagged [D1 accepted delta] are the four
// authorized changes; all others are unchanged or same-visibility simplifications.
const EXPECTED_MATRIX: Record<string, Record<string, { read: unknown; write: unknown }>> = {
task: {
// [posture-gate] Public business object → superuser bypass withheld, admin stays org-scoped (Layer 0).
platform_admin: { read: { organization_id: 'org-1' }, write: [{ organization_id: 'org-1' }] },
// Simplification: the pre-extraction duplicate-OR (`{$or:[{org},{org}]}` from
// organization_admin + baseline) collapses to `{org}` — Layer 0 is the single owner.
org_admin: {
read: { organization_id: 'org-1' },
write: [{ organization_id: 'org-1' }],
},
// [D1 accepted delta (b)] WRITE narrows from `org OR created_by` (org-wide) to
// `org AND created_by` (owner-only) — owner_only_writes finally enforces.
member: {
read: { organization_id: 'org-1' },
write: [{ $and: [{ organization_id: 'org-1' }, { created_by: 'u1' }] }],
},
// [D1 accepted delta (e)] no active org: Layer 0 fail-closes the WRITE
// (was owner-scoped only). Read stays deny-sentinel (unchanged).
no_org_member: { read: { id: DENY }, write: [{ $and: [{ id: DENY }, { created_by: 'u2' }] }] },
},
private_obj: {
// [W2] Layer 0 exemption + Layer 1 short-circuit both fire for superuser on a private object → null.
platform_admin: { read: null, write: 'BYPASS(no-write-filter)' },
org_admin: { read: null, write: 'BYPASS(no-write-filter)' },
// A member's plain wildcard grant does not cover a private object → denied at the CRUD gate, before RLS.
member: { read: 'CRUD_DENY:PermissionDeniedError', write: 'CRUD_DENY:PermissionDeniedError' },
no_org_member: { read: 'CRUD_DENY:PermissionDeniedError', write: 'CRUD_DENY:PermissionDeniedError' },
},
platform_global: {
// [W2] superuser bypass fires on tenancy-disabled posture → null.
platform_admin: { read: null, write: 'BYPASS(no-write-filter)' },
org_admin: { read: null, write: 'BYPASS(no-write-filter)' },
// [D1 accepted delta (c)] A member reading a `tenancy.enabled:false` global
// object: Layer 0 treats it as a NON-tenant object (no phantom org filter) →
// the global catalog is VISIBLE (read: null). The write drops the dead
// org-disjunct to plain owner scope (same visibility, no org column exists).
member: { read: null, write: [{ created_by: 'u1' }] },
// [D1 accepted delta (c)] no-org member likewise sees the global catalog (was deny-sentinel).
no_org_member: { read: null, write: [{ created_by: 'u2' }] },
},
better_auth: {
// [W2] better-auth-managed posture → superuser read bypass → null.
platform_admin: { read: null, write: 'BYPASS(no-write-filter)' },
// Simplification: `sys_user` has no organization_id, so the pre-extraction
// dead org-disjuncts drop; the duplicated `_self` policies remain (self only).
org_admin: {
read: { $or: [{ id: 'oadmin' }, { id: 'oadmin' }] },
write: 'CRUD_DENY:PermissionDeniedError',
},
// Member: self only (dead org-clause removed); writes denied (better-auth door).
member: { read: { id: 'u1' }, write: 'CRUD_DENY:PermissionDeniedError' },
// No-org member: self only; writes denied.
no_org_member: { read: { id: 'u2' }, write: 'CRUD_DENY:PermissionDeniedError' },
},
};
describe('authz Layer-0 matrix gate — ADR-0095 D1 (post-extraction)', () => {
it('locks the role × object × {read,write} effective-filter matrix', async () => {
const actual: Record<string, Record<string, { read: unknown; write: unknown }>> = {};
for (const [oName, cell] of Object.entries(OBJECTS)) {
actual[oName] = {};
for (const [rName, role] of Object.entries(ROLES)) {
actual[oName][rName] = { read: await readFilter(cell, role), write: await writeFilter(cell, role) };
}
}
expect(actual).toEqual(EXPECTED_MATRIX);
});
// ── W1: cross-tenant read leak, CLOSED by Layer 0 ─────────────────────────
// [ADR-0095 D1 accepted delta (a)] A user holding a permissive business RLS
// policy (`status == 'public'`) reads a tenant object. Pre-extraction the
// wildcard tenant policy was OR-merged with it (`tenant OR status==public`), so
// a foreign-org public row matched the second disjunct and was VISIBLE. Layer 0
// now AND-composes the tenant wall ahead of business RLS, so the effective read
// is `Layer0(org) AND Layer1(status==public)` — the foreign-org public row is
// INVISIBLE. This is the W1 fix.
it('[W1] permissive business RLS is AND-composed under Layer 0 (cross-org public row INVISIBLE)', async () => {
const filter = await readFilter(OBJECTS.task, {
userId: 'u3', tenantId: 'org-1', positions: ['org_member'], permissions: ['public_reader'],
});
// Post-D1: tenant wall AND business policy — no OR-widening.
expect(filter).toEqual({ $and: [{ organization_id: 'org-1' }, { status: 'public' }] });
// A foreign-org public row now FAILS the tenant conjunct → invisible.
const foreignPublicRow: Record<string, unknown> = { organization_id: 'org-2', status: 'public' };
const andClauses = (filter as any).$and as Array<Record<string, unknown>>;
const visible = andClauses.every((c) =>
Object.entries(c).every(([k, v]) => foreignPublicRow[k] === v));
expect(visible).toBe(false); // ← [ADR-0095 W1 fix] the leak is closed.
});
// ── W1's write-side twin: owner_only now enforces (delta b) ───────────────
// [ADR-0095 D1 accepted delta (b)] The same OR-merge that leaked reads also
// WIDENED a restrictive write policy: a member's `owner_only_writes`
// (created_by == me) was OR'd with the tenant policy, so the by-id write
// pre-image resolved to `org OR created_by` = any row in the member's org.
// Layer 0 now AND-composes the tenant scope, so the pre-image is
// `Layer0(org) AND Layer1(created_by == me)` = owner-only, as authored.
it('[W1-write] member by-id write narrows to owner-only under Layer 0', async () => {
const wf = await writeFilter(OBJECTS.task, ROLES.member);
expect(wf).toEqual([{ $and: [{ organization_id: 'org-1' }, { created_by: 'u1' }] }]);
});
// ── W2: the superuser bypass short-circuits BOTH layers via one bit ────────
// On private / platform-global / better-auth objects a superuser-bit holder
// skips ALL wildcard RLS — tenant wall included — through a single check. On a
// PUBLIC business object the posture gate withholds the bypass, so the admin
// stays org-scoped. Both facets locked.
it('[W2] superuser bypass fires on private/platform-global/better-auth, withheld on public business objects', async () => {
expect(await readFilter(OBJECTS.private_obj, ROLES.platform_admin)).toBeNull();
expect(await readFilter(OBJECTS.platform_global, ROLES.platform_admin)).toBeNull();
expect(await readFilter(OBJECTS.better_auth, ROLES.platform_admin)).toBeNull();
// Withheld on a public tenant object → admin remains org-scoped (the posture gate).
expect(await readFilter(OBJECTS.task, ROLES.platform_admin)).toEqual({ organization_id: 'org-1' });
});
// ── Fail-closed: an authenticated user with no active org sees no tenant rows ─
it('[fail-closed] no active organization → tenant read denies via the sentinel', async () => {
expect(await readFilter(OBJECTS.task, ROLES.no_org_member)).toEqual({ id: DENY });
});
// ── #2937: Layer 0 INSERT post-image tenant check ─────────────────────────
// insert has no pre-image, so the tenant wall never reached it: a member could
// `insert` a row with a FORGED organization_id and land it in another tenant.
// The Layer 0 insert post-image check closes this — a SUPPLIED organization_id
// must equal the caller's active org; an absent value is left to the
// enterprise auto-stamp (organizations plugin), and system/platform-admin/
// single-mode paths are exempt exactly as on the read side.
describe('[#2937] Layer 0 insert post-image tenant guard', () => {
it('member inserting a FORGED cross-tenant organization_id is DENIED', async () => {
expect(await insertOrg(OBJECTS.task, ROLES.member, 'org-2')).toBe('CRUD_DENY:PermissionDeniedError');
});
it('member inserting the SAME-tenant organization_id is allowed (row keeps it)', async () => {
expect(await insertOrg(OBJECTS.task, ROLES.member, 'org-1')).toBe('org-1');
});
it('member inserting with NO organization_id passes the wall (auto-stamp territory)', async () => {
// plugin-security does not stamp; an absent value is the organizations
// plugin's job. The Layer 0 check must NOT deny it (ordering-independent).
expect(await insertOrg(OBJECTS.task, ROLES.member, undefined)).toBe('<<absent>>');
});
it('member with NO active org supplying ANY organization_id is fail-closed DENIED', async () => {
expect(await insertOrg(OBJECTS.task, ROLES.no_org_member, 'org-1')).toBe('CRUD_DENY:PermissionDeniedError');
});
it('platform admin may insert a cross-org row on a PRIVATE object (posture exemption)', async () => {
// private posture permits the platform-admin superuser bypass → Layer 0 null.
expect(await insertOrg(OBJECTS.private_obj, ROLES.platform_admin, 'org-2')).toBe('org-2');
});
it('platform admin stays org-scoped on a PUBLIC business object (no exemption)', async () => {
// public tenant business object → posture gate withholds the bypass → the
// forged cross-org insert is DENIED even for a platform admin.
expect(await insertOrg(OBJECTS.task, ROLES.platform_admin, 'org-2')).toBe('CRUD_DENY:PermissionDeniedError');
});
it('platform-global (tenancy-disabled) object: supplied org value is untouched', async () => {
// non-tenant object → Layer 0 contributes nothing → no insert check.
expect(await insertOrg(OBJECTS.platform_global, ROLES.member, 'org-2')).toBe('org-2');
});
it('single-org mode: Layer 0 inert, a supplied organization_id is NOT checked', async () => {
const single = { ...OBJECTS.task, orgScoping: false };
expect(await insertOrg(single, ROLES.member, 'org-2')).toBe('org-2');
});
});
// ── Single-org mode: Layer 0 is inert; tenant policy stripped (parity today) ─
// With org-scoping absent, collectRLSPolicies strips the wildcard tenant policy
// entirely, so a member's read carries NO tenant where and the write keeps only
// the owner scope. ADR-0095: in single mode Layer 0 contributes nothing — this
// cell must NOT move after the extraction.
it('[single-mode] tenant policy stripped when org-scoping is absent', async () => {
const single = { ...OBJECTS.task, orgScoping: false };
expect(await readFilter(single, ROLES.member)).toBeNull();
expect(await writeFilter(single, ROLES.member)).toEqual([{ created_by: 'u1' }]);
});
});