From a46957167c5ef17022854fa4545078093c99fc08 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:03:57 +0200 Subject: [PATCH] fix: lock down invoice backfill snapshot (#1901) * fix: lock down invoice backfill snapshot * fix: document snapshot audit follow-up * docs: record snapshot risk treatment * docs: timebox snapshot compliance review --- DECISIONS.md | 2 + ...00_lock_down_invoice_backfill_snapshot.sql | 24 +++ ...oice-backfill-snapshot-lockdown.pg.test.ts | 187 ++++++++++++++++++ 3 files changed, 213 insertions(+) create mode 100644 supabase/migrations/20260825170000_lock_down_invoice_backfill_snapshot.sql create mode 100644 tests/pg/invoice-backfill-snapshot-lockdown.pg.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 48bcdfc0..0b57e4b0 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1233,3 +1233,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-25] CIMD is NOT advertised after all (reverses the 2026-08-24 entry; CodeRabbit on #1866): the spec expects an AS that advertises client_id_metadata_document_supported to fetch the document and match redirect_uri exactly against it, and our authorize endpoint only checks the global allowlist. Advertising would claim a check we skip. Add the flag together with an SSRF-safe cached CIMD fetch + exact redirect matching (localhost port-agnostic for Claude Code/Codex); DCR is free for us (stateless register), so nothing is lost meanwhile. [2026-08-25] Webshop orderunderlag (#1881) is a generated PDF via the existing @react-pdf/renderer + uploadDocument path (same mechanism as archiveIssuedInvoicePdf), not an HTML document: no new dependency, WORM-archive viewers already render PDFs, and magic-byte validation has no HTML arm. Only the verifikat_without_documents RPC gains 'webshop_order' in its needs-doc list; transactions_without_documents stays unchanged because webshop_order entries never hang on a transactions row (the legacy-feed cross-lock guarantees it), so the strict-subset invariant holds without touching it. [2026-08-25] Proposal line-pattern settlement leg now takes the counterparty template's learned legacy pair (credit for expense, debit for income, mirror-swapped, || 1930), passed raw from QuickReviewDialog: two skeptics refuted the 1930 default (engine books e.g. 2440 from SIE-learned patterns; preview/prefill showed 1930). Declined CodeRabbit's two suggestions on #1894 deliberately: the 3740 rounding line keeps the engine's business-side placement for BOTH diff signs (parity contract; the engine's negative-diff imbalance cannot reach the ledger, commit_journal_entry rejects it; engine-side sign fix is a separate issue) and the naiveOreRound baseline stays raised to 622 (engineRound is a documented parity exception, not drift). +[2026-08-25] The production-only _backfill_remaining_20260817 invoice repair snapshot is privilege-contained, not deleted or relocated: PR #1655 identifies it as the safety snapshot for the 337-row 2026-08-17 remaining_amount repair, but the repository establishes neither its retention classification nor approval to destroy financial evidence, so the migration enables RLS, revokes PUBLIC/anon/authenticated access, and limits service_role to read-only for an authorized follow-up review while postgres retains owner control. Neither PR #1655 nor repository history establishes the original repair's behandlingshistorik/rattelse traceability or whether any repaired invoice was linked to a posted voucher; verifying the repair's who/when/what trail and its relation to booked entries is an explicit compliance follow-up, not inferred or altered by this access fix. Default privileges stay unchanged in this scoped fix because Supabase's platform transition and the application's many existing implicit grants require a separate compatibility audit. +[2026-08-25] Risk ID RISK-2026-08-25-INVOICE-BACKFILL-SNAPSHOT treatment record (PR #1901): Risk Owner and follow-up owner Emil; classification restricted financial remediation evidence pending BFL review; treatment preserves all 337 rows and merges only the anonymous-access containment; BFL retention/rattelse review deadline 2026-09-25; residual risk after containment Low, explicitly including postgres-owner bypass until that review. Retention or deletion requires the separate reviewed follow-up, and this PR must not delete or alter snapshot rows. This entry and PR #1901 are the repository-native Risk Treatment Plan reference because the repository has no risk register. diff --git a/supabase/migrations/20260825170000_lock_down_invoice_backfill_snapshot.sql b/supabase/migrations/20260825170000_lock_down_invoice_backfill_snapshot.sql new file mode 100644 index 00000000..115f02cd --- /dev/null +++ b/supabase/migrations/20260825170000_lock_down_invoice_backfill_snapshot.sql @@ -0,0 +1,24 @@ +-- Contain the invoice remaining-balance backfill snapshot created during the +-- 2026-08-17 production repair recorded in PR #1655. The table is not part of +-- the application schema and no runtime code references it, but production +-- retains 337 financial snapshot rows whose retention status is unresolved. +-- Preserve the rows for review while removing every browser-facing access +-- path. The service role remains read-only for an authorized retention audit, +-- while the table owner retains control for a separately approved decision. +-- +-- Fresh databases do not contain this incident artifact, so the migration is +-- deliberately conditional. Retention, relocation, or deletion is a separate +-- decision and must not be inferred from this access-containment change. +DO $$ +BEGIN + IF pg_catalog.to_regclass('public._backfill_remaining_20260817') IS NOT NULL THEN + EXECUTE 'ALTER TABLE public._backfill_remaining_20260817 ENABLE ROW LEVEL SECURITY'; + EXECUTE 'REVOKE ALL PRIVILEGES ON TABLE public._backfill_remaining_20260817 FROM PUBLIC, anon, authenticated'; + EXECUTE 'REVOKE INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER ON TABLE public._backfill_remaining_20260817 FROM service_role'; + EXECUTE 'GRANT SELECT ON TABLE public._backfill_remaining_20260817 TO service_role'; + EXECUTE 'COMMENT ON TABLE public._backfill_remaining_20260817 IS ''Invoice remaining-balance repair snapshot from 2026-08-17. Browser access is revoked. Retention, relocation, or deletion requires a separate approved decision.'''; + END IF; +END; +$$; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/invoice-backfill-snapshot-lockdown.pg.test.ts b/tests/pg/invoice-backfill-snapshot-lockdown.pg.test.ts new file mode 100644 index 00000000..a64db88c --- /dev/null +++ b/tests/pg/invoice-backfill-snapshot-lockdown.pg.test.ts @@ -0,0 +1,187 @@ +/** + * pg-real coverage for 20260825170000_lock_down_invoice_backfill_snapshot.sql. + * + * The snapshot exists only in production because it was created during a + * one-time repair. A clean migration replay therefore has no relation to + * inspect. This suite recreates only its catalog shape and grants inside a + * transaction, reapplies the idempotent containment migration, and rolls the + * entire fixture back after the assertions. It never reads production data. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { readFile } from 'node:fs/promises' +import type { PoolClient } from 'pg' +import { getClient, getPool } from '@/tests/pg/setup' + +const SNAPSHOT = 'public._backfill_remaining_20260817' +let fixtureClient: PoolClient + +/** Assert grant-layer denial while restoring the fixture transaction after the expected error. */ +async function expectSnapshotDenied( + role: 'anon' | 'authenticated', + sql: string, +): Promise { + await fixtureClient.query('SAVEPOINT snapshot_access_attempt') + try { + await fixtureClient.query(`SET LOCAL ROLE ${role}`) + await expect(fixtureClient.query(sql)).rejects.toThrow(/permission denied/i) + } finally { + await fixtureClient.query('ROLLBACK TO SAVEPOINT snapshot_access_attempt').catch(() => {}) + await fixtureClient.query('RELEASE SAVEPOINT snapshot_access_attempt').catch(() => {}) + } +} + +beforeAll(async () => { + fixtureClient = await getClient() + await fixtureClient.query('BEGIN') + + const existing = await fixtureClient.query<{ relation: string | null }>( + `SELECT to_regclass($1)::text AS relation`, + [SNAPSHOT], + ) + if (existing.rows[0]?.relation !== null) { + throw new Error( + `Refusing to create the pg-real fixture because ${SNAPSHOT} already exists in this database`, + ) + } + + await fixtureClient.query(` + CREATE TABLE ${SNAPSHOT} ( + id uuid, + remaining_amount numeric, + total numeric, + paid_amount numeric, + deduction_total numeric, + status text, + snapshot_at timestamptz + ); + GRANT ALL PRIVILEGES ON TABLE ${SNAPSHOT} TO anon, authenticated, service_role; + `) + + const migrationSql = await readFile( + new URL( + '../../supabase/migrations/20260825170000_lock_down_invoice_backfill_snapshot.sql', + import.meta.url, + ), + 'utf8', + ) + await fixtureClient.query(migrationSql) +}) + +afterAll(async () => { + if (fixtureClient) { + await fixtureClient.query('ROLLBACK').catch(() => {}) + fixtureClient.release() + } +}) + +describe('invoice backfill snapshot lockdown (pg)', () => { + it('enables RLS and removes browser-role privileges', async () => { + const flags = await fixtureClient.query<{ + relrowsecurity: boolean + anon_access: boolean + authenticated_access: boolean + service_write_access: boolean + }>(` + SELECT + c.relrowsecurity, + ( + has_table_privilege('anon', c.oid, 'SELECT') + OR has_table_privilege('anon', c.oid, 'INSERT') + OR has_table_privilege('anon', c.oid, 'UPDATE') + OR has_table_privilege('anon', c.oid, 'DELETE') + ) AS anon_access, + ( + has_table_privilege('authenticated', c.oid, 'SELECT') + OR has_table_privilege('authenticated', c.oid, 'INSERT') + OR has_table_privilege('authenticated', c.oid, 'UPDATE') + OR has_table_privilege('authenticated', c.oid, 'DELETE') + ) AS authenticated_access, + ( + has_table_privilege('service_role', c.oid, 'INSERT') + OR has_table_privilege('service_role', c.oid, 'UPDATE') + OR has_table_privilege('service_role', c.oid, 'DELETE') + OR has_table_privilege('service_role', c.oid, 'TRUNCATE') + ) AS service_write_access + FROM pg_catalog.pg_class c + WHERE c.oid = '${SNAPSHOT}'::regclass + `) + + expect(flags.rows).toEqual([ + { + relrowsecurity: true, + anon_access: false, + authenticated_access: false, + service_write_access: false, + }, + ]) + }) + + it('explicitly denies every browser-role CRUD operation', async () => { + for (const role of ['anon', 'authenticated'] as const) { + await expectSnapshotDenied(role, `SELECT * FROM ${SNAPSHOT} LIMIT 1`) + await expectSnapshotDenied(role, `INSERT INTO ${SNAPSHOT} (status) VALUES ('test')`) + await expectSnapshotDenied(role, `UPDATE ${SNAPSHOT} SET status = status WHERE false`) + await expectSnapshotDenied(role, `DELETE FROM ${SNAPSHOT} WHERE false`) + } + }) + + it('retains privileged access for an authorized retention review', async () => { + await fixtureClient.query('SAVEPOINT service_role_access') + try { + await fixtureClient.query('SET LOCAL ROLE service_role') + const result = await fixtureClient.query(`SELECT count(*)::int AS count FROM ${SNAPSHOT}`) + expect(result.rows[0]?.count).toBe(0) + } finally { + await fixtureClient.query('ROLLBACK TO SAVEPOINT service_role_access') + await fixtureClient.query('RELEASE SAVEPOINT service_role_access') + } + }) + + it('leaves no public table without RLS', async () => { + const result = await fixtureClient.query<{ relname: string }>(` + SELECT c.relname + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' + AND c.relkind IN ('r', 'p') + AND NOT c.relrowsecurity + ORDER BY c.relname + `) + expect(result.rows).toEqual([]) + }) + + it('exposes no temporary backfill, repair, or snapshot table to browser roles', async () => { + const result = await fixtureClient.query<{ relname: string; role_name: string }>(` + SELECT c.relname, role_name + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + CROSS JOIN (VALUES ('anon'), ('authenticated')) AS roles(role_name) + WHERE n.nspname = 'public' + AND c.relkind IN ('r', 'p') + AND c.relname ~ '(^_|backfill|repair|snapshot)' + AND ( + has_table_privilege(role_name, c.oid, 'SELECT') + OR has_table_privilege(role_name, c.oid, 'INSERT') + OR has_table_privilege(role_name, c.oid, 'UPDATE') + OR has_table_privilege(role_name, c.oid, 'DELETE') + ) + ORDER BY c.relname, role_name + `) + expect(result.rows).toEqual([]) + }) +}) + +describe('public-schema RLS invariant (pg)', () => { + it('holds independently of the incident fixture connection', async () => { + const result = await getPool().query<{ relname: string }>(` + SELECT c.relname + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' + AND c.relkind IN ('r', 'p') + AND NOT c.relrowsecurity + ORDER BY c.relname + `) + expect(result.rows).toEqual([]) + }) +})