|
| 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