|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// Metadata-driven RLS cross-owner proofs — the #1994 invariant. |
| 4 | +// |
| 5 | +// #1994 ("member edits others' records") was a by-id write that skipped the |
| 6 | +// row-level predicate: `driver.update(object, id, …)` builds no AST, so RLS |
| 7 | +// never scoped it. The clean, app-agnostic invariant that catches it without |
| 8 | +// interpreting each sharing rule: |
| 9 | +// |
| 10 | +// A user who CANNOT READ a record must not be able to WRITE it. |
| 11 | +// ("You can't mutate what you can't see.") |
| 12 | +// |
| 13 | +// Derivation, per object: admin creates a record; a fresh member (no roles or |
| 14 | +// grants) tries to read it, then tries to mutate it by id; we re-read as admin |
| 15 | +// to see if the row actually changed. If the member couldn't see it yet changed |
| 16 | +// it, that's the #1994 class of hole — regardless of the app's sharing config. |
| 17 | + |
| 18 | +/* eslint-disable @typescript-eslint/no-explicit-any */ |
| 19 | + |
| 20 | +import type { DogfoodStack } from './harness.js'; |
| 21 | +import { deriveCrudCases } from './derive.js'; |
| 22 | + |
| 23 | +const PROBE_TYPES = new Set(['text', 'textarea', 'string']); |
| 24 | +const MUTATION = 'rls-mutated-by-B'; |
| 25 | + |
| 26 | +export interface RlsResult { |
| 27 | + object: string; |
| 28 | + status: 'rls-consistent' | 'rls-hole' | 'member-visible' | 'skipped'; |
| 29 | + detail?: string; |
| 30 | +} |
| 31 | + |
| 32 | +export interface RlsReport { |
| 33 | + app: string; |
| 34 | + results: RlsResult[]; |
| 35 | + summary: { objects: number; consistent: number; holes: number; memberVisible: number; skipped: number }; |
| 36 | +} |
| 37 | + |
| 38 | +export async function runRlsProofs( |
| 39 | + stack: DogfoodStack, |
| 40 | + adminToken: string, |
| 41 | + memberToken: string, |
| 42 | + config: any, |
| 43 | +): Promise<RlsReport> { |
| 44 | + const cases = deriveCrudCases(config); |
| 45 | + const results: RlsResult[] = []; |
| 46 | + |
| 47 | + for (const c of cases) { |
| 48 | + if (c.blocked) { results.push({ object: c.object, status: 'skipped', detail: c.blocked }); continue; } |
| 49 | + |
| 50 | + // A plain-text field to mutate (avoid email/url/phone — their format checks |
| 51 | + // would reject the probe for a benign reason, masking the RLS signal). |
| 52 | + const probe = (c.asserts ?? []).find((a) => PROBE_TYPES.has(a.type)); |
| 53 | + if (!probe) { results.push({ object: c.object, status: 'skipped', detail: 'no plain-text probe field' }); continue; } |
| 54 | + |
| 55 | + // Admin (owner) creates the record. |
| 56 | + const created = await stack.apiAs(adminToken, 'POST', `/data/${c.object}`, c.body); |
| 57 | + if (created.status >= 300) { |
| 58 | + results.push({ object: c.object, status: 'skipped', detail: `admin create failed (${created.status})` }); |
| 59 | + continue; |
| 60 | + } |
| 61 | + const cj = (await created.json()) as any; |
| 62 | + const id = cj?.id ?? cj?.record?.id; |
| 63 | + if (!id) { results.push({ object: c.object, status: 'skipped', detail: 'no id from create' }); continue; } |
| 64 | + |
| 65 | + // Member B: can they SEE it? |
| 66 | + const bRead = await stack.apiAs(memberToken, 'GET', `/data/${c.object}/${id}`); |
| 67 | + let canRead = false; |
| 68 | + if (bRead.status === 200) { |
| 69 | + const rec = ((await bRead.json()) as any)?.record; |
| 70 | + canRead = !!rec && rec.id === id; |
| 71 | + } |
| 72 | + |
| 73 | + // Member B: try to MUTATE it by id. |
| 74 | + const bWrite = await stack.apiAs(memberToken, 'PATCH', `/data/${c.object}/${id}`, { [probe.field]: MUTATION }); |
| 75 | + |
| 76 | + // Ground truth: re-read as admin — did the row actually change? |
| 77 | + const after = await stack.apiAs(adminToken, 'GET', `/data/${c.object}/${id}`); |
| 78 | + const afterVal = (((await after.json()) as any)?.record ?? {})[probe.field]; |
| 79 | + const changed = afterVal === MUTATION; |
| 80 | + |
| 81 | + if (canRead) { |
| 82 | + results.push({ object: c.object, status: 'member-visible', detail: 'member can read this object — not a cross-owner scenario (no RLS isolation, or read is granted)' }); |
| 83 | + } else if (changed) { |
| 84 | + results.push({ |
| 85 | + object: c.object, |
| 86 | + status: 'rls-hole', |
| 87 | + detail: `member B cannot read it (GET ${bRead.status}) yet MUTATED it by id (PATCH ${bWrite.status}) — by-id write bypassed RLS (#1994 class)`, |
| 88 | + }); |
| 89 | + } else { |
| 90 | + results.push({ |
| 91 | + object: c.object, |
| 92 | + status: 'rls-consistent', |
| 93 | + detail: `member B cannot read (GET ${bRead.status}) and could not mutate (PATCH ${bWrite.status}, row unchanged)`, |
| 94 | + }); |
| 95 | + } |
| 96 | + } |
| 97 | + |
| 98 | + const summary = { |
| 99 | + objects: results.length, |
| 100 | + consistent: results.filter((r) => r.status === 'rls-consistent').length, |
| 101 | + holes: results.filter((r) => r.status === 'rls-hole').length, |
| 102 | + memberVisible: results.filter((r) => r.status === 'member-visible').length, |
| 103 | + skipped: results.filter((r) => r.status === 'skipped').length, |
| 104 | + }; |
| 105 | + return { app: config?.manifest?.id ?? 'app', results, summary }; |
| 106 | +} |
| 107 | + |
| 108 | +export function formatRlsReport(report: RlsReport): string { |
| 109 | + const lines: string[] = [`\n=== objectstack verify (RLS / #1994) — ${report.app} ===`]; |
| 110 | + for (const r of report.results) { |
| 111 | + const mark = r.status === 'rls-hole' ? '✗✗' : r.status === 'rls-consistent' ? '✓' : r.status === 'member-visible' ? '·' : '–'; |
| 112 | + lines.push(` ${mark} ${r.object} [${r.status}] ${r.detail ?? ''}`); |
| 113 | + } |
| 114 | + const s = report.summary; |
| 115 | + lines.push(` ── ${s.consistent} consistent, ${s.holes} HOLES, ${s.memberVisible} member-visible, ${s.skipped} skipped`); |
| 116 | + return lines.join('\n'); |
| 117 | +} |
0 commit comments