diff --git a/backend/prisma/migrations/20260923160000_lock_down_supabase_data_api/migration.sql b/backend/prisma/migrations/20260923160000_lock_down_supabase_data_api/migration.sql new file mode 100644 index 0000000..f757d1d --- /dev/null +++ b/backend/prisma/migrations/20260923160000_lock_down_supabase_data_api/migration.sql @@ -0,0 +1,49 @@ +-- Lock the database down against Supabase's auto-generated REST API. +-- +-- Supabase publishes every table in `public` through PostgREST (the "Data +-- API") and grants the `anon` and `authenticated` roles full access to new +-- tables. The anon key is public by design. On 2026-09-23 every table here — +-- User (password hashes), RefreshToken, Transaction (the append-only ledger) +-- — had RLS off and SELECT/INSERT granted to anon: anyone with the project's +-- public key could have read or written them without going through our API. +-- Fakturly never uses the Data API; the app connects to Postgres directly. +-- See ADR 54. + +-- 1. Row Level Security on every table, with NO policies: deny for any role +-- without BYPASSRLS. The app's own role is unaffected: on Supabase it is +-- `postgres` (BYPASSRLS), elsewhere the table owner, and owners bypass RLS +-- unless FORCE is set. Runs everywhere, so local and CI databases behave +-- the same as production; tests/integration/rls.test.ts keeps new tables +-- from skipping it. +DO $$ +DECLARE + t record; +BEGIN + FOR t IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP + EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', t.tablename); + END LOOP; +END $$; + +-- 2. Take the grants away, now and for tables created later. Only where the +-- Supabase roles exist — a plain Postgres (local Docker, CI) has neither. +-- service_role is left alone: its key is secret and the dashboard uses it. +DO $$ +DECLARE + r text; +BEGIN + FOREACH r IN ARRAY ARRAY['anon', 'authenticated'] LOOP + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = r) THEN + EXECUTE format('REVOKE ALL ON ALL TABLES IN SCHEMA public FROM %I', r); + EXECUTE format('REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM %I', r); + EXECUTE format('REVOKE ALL ON ALL FUNCTIONS IN SCHEMA public FROM %I', r); + + -- Supabase's default privileges are defined for the `postgres` role, + -- which is the role migrations run as there. + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'postgres') THEN + EXECUTE format('ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM %I', r); + EXECUTE format('ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON SEQUENCES FROM %I', r); + EXECUTE format('ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM %I', r); + END IF; + END IF; + END LOOP; +END $$; diff --git a/backend/tests/integration/rls.test.ts b/backend/tests/integration/rls.test.ts new file mode 100644 index 0000000..015548d --- /dev/null +++ b/backend/tests/integration/rls.test.ts @@ -0,0 +1,44 @@ +// rls.test.ts — every table has Row Level Security on. +// +// Supabase exposes `public` over its REST API to anyone with the public +// anon key. The lock-down migration (ADR 54) enables RLS on every table that +// existed then; a table added by a later migration would not get it. This +// test is what makes the next migration author add +// ALTER TABLE "NewTable" ENABLE ROW LEVEL SECURITY; +// instead of quietly reopening the hole. + +import { describe, test, expect } from 'bun:test' +import { prisma } from '../../src/lib/prisma.ts' + +describe('row level security', () => { + test('is enabled on every table in the public schema', async () => { + const withoutRls = await prisma.$queryRaw>` + SELECT c.relname AS "table" + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' + AND c.relkind = 'r' + AND NOT c.relrowsecurity + ORDER BY 1 + ` + + expect(withoutRls.map((row) => row.table)).toEqual([]) + }) + + test('does not get in the way of the app itself', async () => { + // RLS with no policies denies everyone who does not bypass it. The app + // must be one who does (table owner, or BYPASSRLS on Supabase) — if not, + // every query would silently return zero rows. + const users = await prisma.user.count() + expect(users).toBeGreaterThanOrEqual(0) + + const [role] = await prisma.$queryRaw>` + SELECT (r.rolbypassrls OR r.rolsuper + OR EXISTS (SELECT 1 FROM pg_tables t + WHERE t.schemaname = 'public' AND t.tablename = 'User' + AND t.tableowner = current_user)) AS bypasses + FROM pg_roles r WHERE r.rolname = current_user + ` + expect(role?.bypasses).toBe(true) + }) +}) diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md index 1cc1ec4..04df9cf 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -1086,6 +1086,40 @@ database activity. The daily cron jobs are that activity; `/health` is not. --- +## 54. Supabase's Data API is locked out: RLS everywhere, no grants to anon + +**Context:** Supabase publishes every table in `public` through its REST +"Data API" (PostgREST) and, by default, grants the `anon` and `authenticated` +roles full access to new tables. The anon key is public by design — Supabase +expects it in browser code, with Row Level Security as the protection. + +Checked right after the first migration on 2026-09-23: all 12 tables had RLS +off and `anon` could SELECT and INSERT. With the project's anon key, anyone +could have read `User` (password hashes) and `RefreshToken`, or appended rows +to `Transaction` — the append-only ledger (ADR 2) that is only written through the +service layer — without touching our API. Fakturly never uses the Data API. + +**Decision:** migration `20260923160000_lock_down_supabase_data_api`: + +- RLS enabled on every table, **no policies** — deny for any role that does + not bypass RLS. The app connects as `postgres` (BYPASSRLS) on Supabase and + as the table owner elsewhere, so it is unaffected. +- `REVOKE ALL` on tables, sequences and functions from `anon` and + `authenticated`, plus `ALTER DEFAULT PRIVILEGES` so future tables start + closed. Guarded by the roles existing, so plain Postgres (local, CI) skips it. +- `tests/integration/rls.test.ts` fails if any table lacks RLS, so the next + migration cannot quietly reopen this. Shown to fail before the migration + (listing all 12 tables) and pass after. + +**Verified on Supabase:** every table `rls = true`, anon/authenticated +privileges `false`; a table created afterwards (inside a rolled-back +transaction) is not granted to either role; the app still reads its data. + +**Also recommended, outside the code:** switch the Data API off in the +Supabase dashboard. Two locks, either one sufficient. + +--- + ## Open decisions | Question | Status |