Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 $$;
44 changes: 44 additions & 0 deletions backend/tests/integration/rls.test.ts
Original file line number Diff line number Diff line change
@@ -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<Array<{ table: string }>>`
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<Array<{ bypasses: boolean }>>`
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)
})
})
34 changes: 34 additions & 0 deletions docs/architecture-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading