// source-pin-contract: governed query catalog secrecy and tenant-filter invariant; every view swept is derived by parsing the active baseline named in schema/migrations/baseline.json, secret/curated/global names are reviewed rule parameters import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import test from "node:test"; /** * The governed query catalog is what the report query console and user scripts * can read, through the `openbooks_read` role. It is built entirely by * `public.openbooks_refresh_query_catalog()`, which drops the whole schema and * rebuilds it from the `openbooks_query_catalog_relations` registry plus a * set of curated views — and the baseline's own last statement calls that * function. * * So the function and its registry, not the CREATE VIEW statements pg_dump * emitted above them, are the real source of truth. These tests keep the two * in agreement: a curated view that omits a secret column is worthless if the * function rebuilds the relation as `select *`, and refreshing is one function * call with no review step in it. */ const MANIFEST = JSON.parse( readFileSync(join(import.meta.dirname, "migrations/baseline.json"), "utf8"), ) as { filename: string }; const BASELINE = readFileSync(join(import.meta.dirname, "migrations", MANIFEST.filename), "utf8"); /** Columns that must never be readable through the governed catalog. */ const NEVER_QUERYABLE = [ "sin_encrypted", "tin_encrypted", "account_number_encrypted", "password_encrypted", "password_hash", "secret_encrypted", "originator_secrets_encrypted", "reseal_secret", "birth_date", ]; /** Relations that hold a secret and therefore need an explicit column list. */ const MUST_BE_CURATED = [ "parties", "party_bank_accounts", "vendor_roles", "employee_payroll_profiles", "employee_roles", ]; /** * Relations the catalog exposes without a tenant filter on purpose: they hold * no `org_id` and are reviewed as global reference data. Every other governed * view must carry `where org_id = public.openbooks_query_org_id()`. */ const GLOBAL_RELATIONS = ["currencies"]; /** Relations whose private text is redacted by a curated projection. */ const PRIVATE_TEXT_PROJECTIONS = ["crm_activities", "time_entries"]; /** * The generic relation set: every relation registered in * `openbooks_query_catalog_relations`, which the function rebuilds as * `select *` (tenant-filtered when the table has org_id). */ function safeRelations(): string[] { const inserts = [ ...BASELINE.matchAll( /INSERT INTO public\.openbooks_query_catalog_relations \(relation, added_in\) VALUES\n([\s\S]*?);\n/g, ), ]; assert.ok( inserts.length > 0, "the governed relation registry seed was not found in the baseline", ); const relations = inserts.flatMap((insert) => [...insert[1]!.matchAll(/^\s*\('(\w+)',/gm)].map((m) => m[1]!), ); assert.ok( relations.length > 100, `expected the registry seed to govern the catalog; found ${relations.length}`, ); return relations; } /** Every `CREATE VIEW openbooks_query.x` and the columns it selects. */ function shippedViews(): Map | "star"> { const views = new Map | "star">(); const re = /CREATE VIEW openbooks_query\.(\w+) WITH \(security_barrier='true'\) AS\n([\s\S]*?);\n/g; for (const match of BASELINE.matchAll(re)) { const select = match[2]!.split(/\n\s*FROM /i)[0]!; if (/^\s*SELECT \*/i.test(select)) { views.set(match[1]!, "star"); continue; } views.set( match[1]!, new Set( select .replace(/^\s*SELECT\s+/i, "") .split(",") .map((c) => c .trim() .split(/\s+/)[0]! .replace(/^\w+\./, ""), ) .filter(Boolean), ), ); } return views; } /** Every `CREATE VIEW openbooks_query.x` and its full statement text. */ function shippedViewBodies(): Map { const bodies = new Map(); const re = /CREATE VIEW openbooks_query\.(\w+) WITH \(security_barrier='true'\) AS\n([\s\S]*?);\n/g; for (const match of BASELINE.matchAll(re)) bodies.set(match[1]!, match[2]!); return bodies; } /** The body of `openbooks_refresh_query_catalog()`, the real source of truth. */ function refreshFunctionBody(): string { const body = BASELINE.match( /CREATE FUNCTION public\.openbooks_refresh_query_catalog\(\)[\s\S]*?\n\$_\$;/, )?.[0]; assert.ok(body, "openbooks_refresh_query_catalog() not found in the baseline"); return body; } function baseTables(): Map { const tables = new Map(); const re = /CREATE TABLE public\.(\w+) \(\n([\s\S]*?)\n\);\n/g; for (const match of BASELINE.matchAll(re)) { // pg_dump emits every column before the first table CONSTRAINT, and it // wraps a CHECK containing a CASE expression across several lines whose // continuations (CASE / WHEN … / ELSE … / END) look exactly like column // definitions. Stop at the first CONSTRAINT rather than filtering line by // line, or those keywords are read as columns the shipped view "omits". const body = match[2]!.split("\n"); const firstConstraint = body.findIndex((l) => /^\s*CONSTRAINT/i.test(l)); tables.set( match[1]!, (firstConstraint === -1 ? body : body.slice(0, firstConstraint)) .map((l) => l.trim()) .filter(Boolean) .map((l) => l.split(/\s+/)[0]!) .filter((c) => /^\w+$/.test(c)), ); } return tables; } test("no secret column is readable through the governed query catalog", () => { const views = shippedViews(); const offenders: string[] = []; for (const [relation, columns] of views) { if (columns === "star") continue; for (const secret of NEVER_QUERYABLE) { if (columns.has(secret)) offenders.push(`openbooks_query.${relation}.${secret}`); } } assert.deepEqual( offenders, [], `secret columns exposed to openbooks_read: ${offenders.join(", ")}`, ); }); test("a relation holding a secret is curated, never in the generic allowlist", () => { const safe = safeRelations(); const tables = baseTables(); const leaked: string[] = []; // The named ones, explicitly. for (const relation of MUST_BE_CURATED) { if (safe.includes(relation)) leaked.push(`${relation} (named)`); } // And anything that grows a secret column later, so a new table is caught // without anyone remembering to extend MUST_BE_CURATED. for (const relation of safe) { const columns = tables.get(relation); if (!columns) continue; const secrets = columns.filter((c) => NEVER_QUERYABLE.includes(c)); if (secrets.length > 0) leaked.push(`${relation} (holds ${secrets.join(", ")})`); } assert.deepEqual( leaked, [], "these relations take the generic `select *` path but hold a secret; " + `give each an explicit column list in openbooks_refresh_query_catalog(): ${leaked.join("; ")}`, ); }); test("the function and the shipped views agree on every generic relation", () => { // A relation on the generic path is rebuilt as `select *`, so its shipped // view MUST list every base column. A shipped view that omits one is a view // the function will silently widen the next time it runs — which is exactly // how the sin_encrypted exposure was introduced and then hidden. const views = shippedViews(); const tables = baseTables(); const drifted: string[] = []; for (const relation of safeRelations()) { const view = views.get(relation); const columns = tables.get(relation); if (!view || view === "star" || !columns) continue; const missing = columns.filter((c) => !view.has(c)); if (missing.length > 0) drifted.push(`${relation} omits ${missing.join(", ")}`); } assert.deepEqual( drifted, [], "shipped view narrower than the function's `select *` — either add the " + `column to the view or curate the relation: ${drifted.join("; ")}`, ); }); test("every curated relation is granted to openbooks_read", () => { // A curated view that nobody can select from is a silent capability // regression. The function grants the whole governed schema in one // statement after its last curated view, and the shipped catalog carries a // grant for each curated view. const functionBody = refreshFunctionBody(); const lastCuratedView = Math.max( ...MUST_BE_CURATED.map((relation) => functionBody.indexOf(`create view openbooks_query.${relation} with`), ), ); const blanketGrant = functionBody.lastIndexOf( "grant select on all tables in schema openbooks_query to openbooks_read;", ); assert.ok( blanketGrant > lastCuratedView, "the refresh function must grant the governed schema after creating its curated views", ); for (const relation of MUST_BE_CURATED) { assert.ok( functionBody.includes(`create view openbooks_query.${relation} with`), `${relation} must be curated by the refresh function`, ); assert.ok( BASELINE.includes(`GRANT SELECT ON TABLE openbooks_query.${relation} TO openbooks_read;`), `${relation} has a curated view but the shipped catalog does not grant it`, ); } }); test("the shipped catalog carries the tenant filter", () => { // The baseline grants SELECT on the shipped views before its final rebuild, // so a shipped view without the tenant filter would be readable across // tenants for as long as the file takes to apply. The shipped DDL must be // as safe as what the function rebuilds. const views = shippedViewBodies(); assert.ok( views.size > 100, `expected the baseline to ship the governed catalog; found ${views.size}`, ); const unfiltered = [...views] .filter(([, body]) => !body.includes("org_id = public.openbooks_query_org_id()")) .map(([relation]) => relation) .sort(); assert.deepEqual( unfiltered, [...GLOBAL_RELATIONS].sort(), "a governed view ships without `where org_id = openbooks_query_org_id()`. " + "Add the filter and regenerate the baseline", ); }); test("the function tenant-filters every relation it rebuilds", () => { // The shipped DDL above is transient: the function drops the schema and // rebuilds it, so the function is what a live database actually gets. Both // routes must filter, or the catalog leaks across tenants. const tables = baseTables(); const functionBody = refreshFunctionBody(); const unprotected: string[] = []; for (const relation of safeRelations()) { if (GLOBAL_RELATIONS.includes(relation)) continue; // The generic path filters whenever the base table has org_id, and raises // for a relation that has neither org_id nor global review. if (!(tables.get(relation) ?? []).includes("org_id")) unprotected.push(relation); } for (const relation of MUST_BE_CURATED) { const curated = new RegExp( `create view openbooks_query\\.${relation} with[\\s\\S]*?org_id = public\\.openbooks_query_org_id\\(\\)`, ).test(functionBody); if (!curated) unprotected.push(`${relation} (curated)`); } assert.deepEqual( unprotected, [], `the refresh function does not tenant-filter these: ${unprotected.join(", ")}`, ); }); test("the function's global relations are exactly the reviewed ones", () => { const declared = refreshFunctionBody().match( /global_relations constant text\[\] := array\[([\s\S]*?)\];/, )?.[1]; assert.ok(declared, "global_relations array not found in the refresh function"); assert.deepEqual( [...declared.matchAll(/'(\w+)'/g)].map((m) => m[1]!).sort(), [...GLOBAL_RELATIONS].sort(), "a relation was added to the unfiltered global path without review", ); }); test("the refresh call is the baseline's final statement", () => { // Load-bearing. The baseline grants SELECT on every governed view to // openbooks_read before this call; the rebuild is what re-derives them from // the reviewed function. Any statement appended after it would ship whatever // pg_dump happened to emit. const statements = BASELINE.replace(/^\s*--.*$/gm, "") .split(";") .map((statement) => statement.trim()) .filter(Boolean); assert.equal( statements.at(-1), "SELECT public.openbooks_refresh_query_catalog()", "the baseline must end by rebuilding the governed query catalog", ); }); test("the catalog redacts private CRM bodies and time memos", () => { // These relations must stay off the generic `select *` path. Otherwise any // later catalog refresh would silently restore the private text projection. const generic = safeRelations(); for (const relation of PRIVATE_TEXT_PROJECTIONS) { assert.ok( !generic.includes(relation), `${relation} must not be registered for the generic path`, ); } // The curated definitions retain the row, tenant key, privacy flag, and // all public reporting columns while replacing only private text with NULL. const functionBody = refreshFunctionBody(); assert.match( functionBody, /create view openbooks_query\.crm_activities[\s\S]*?case when is_private then null else body end as body[\s\S]*?is_private[\s\S]*?where org_id = public\.openbooks_query_org_id\(\)/i, ); assert.match( functionBody, /create view openbooks_query\.time_entries[\s\S]*?case when memo_is_private then null else memo end as memo[\s\S]*?memo_is_private[\s\S]*?where org_id = public\.openbooks_query_org_id\(\)/i, ); // The shipped views redact the same way before the final rebuild runs. const shipped = shippedViewBodies(); assert.match( shipped.get("crm_activities") ?? "", /CASE\s+WHEN is_private THEN NULL::text\s+ELSE body\s+END AS body/, ); assert.match( shipped.get("time_entries") ?? "", /CASE\s+WHEN memo_is_private THEN NULL::text\s+ELSE memo\s+END AS memo/, ); });