From 1ce0e348620faa35565513a9dbe46a1e23c7598a Mon Sep 17 00:00:00 2001 From: Beau Mersereau Date: Sat, 16 May 2026 07:41:38 -0700 Subject: [PATCH 1/4] test: add RLS verification script for #144 --- backend/scripts/verify-rls.sql | 57 ++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 backend/scripts/verify-rls.sql diff --git a/backend/scripts/verify-rls.sql b/backend/scripts/verify-rls.sql new file mode 100644 index 000000000..22c5e8e1b --- /dev/null +++ b/backend/scripts/verify-rls.sql @@ -0,0 +1,57 @@ +-- Verification script for issue #144. +-- Asserts every public base table has Row Level Security enabled AND a +-- deny-all policy for the anon and authenticated roles. Run with: +-- psql -v ON_ERROR_STOP=1 -f backend/scripts/verify-rls.sql $DATABASE_URL +-- The script exits non-zero on any assertion failure. + +do $$ +declare + missing_rls record; + missing_policy record; + failure_count int := 0; +begin + -- 1. Every public base table must have RLS enabled (relrowsecurity = true). + for missing_rls in + select t.table_name + from information_schema.tables t + join pg_class c on c.relname = t.table_name + join pg_namespace n on n.oid = c.relnamespace + where t.table_schema = 'public' + and t.table_type = 'BASE TABLE' + and n.nspname = 'public' + and c.relrowsecurity is false + order by t.table_name + loop + raise warning 'RLS not enabled on public.%', missing_rls.table_name; + failure_count := failure_count + 1; + end loop; + + -- 2. Every public base table must have at least one policy that applies + -- to anon and authenticated and denies access (USING (false)). + for missing_policy in + select t.table_name + from information_schema.tables t + where t.table_schema = 'public' + and t.table_type = 'BASE TABLE' + and not exists ( + select 1 + from pg_policies p + where p.schemaname = 'public' + and p.tablename = t.table_name + and 'anon' = any(p.roles) + and 'authenticated' = any(p.roles) + and p.qual = 'false' + ) + order by t.table_name + loop + raise warning 'No deny-all policy for anon+authenticated on public.%', + missing_policy.table_name; + failure_count := failure_count + 1; + end loop; + + if failure_count > 0 then + raise exception 'verify-rls: % assertion(s) failed', failure_count; + end if; + + raise notice 'verify-rls: all public base tables have RLS enabled and a deny-all policy.'; +end$$; From a2b3808a483516fa09dc716e8523128b6f0b4902 Mon Sep 17 00:00:00 2001 From: Beau Mersereau Date: Sat, 16 May 2026 07:42:35 -0700 Subject: [PATCH 2/4] fix: enable RLS with deny-all policy on all public tables (#144) --- .../20260516_enable_rls_deny_all.sql | 36 ++++++++++++++++ backend/schema.sql | 41 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 backend/migrations/20260516_enable_rls_deny_all.sql diff --git a/backend/migrations/20260516_enable_rls_deny_all.sql b/backend/migrations/20260516_enable_rls_deny_all.sql new file mode 100644 index 000000000..8ac2f576f --- /dev/null +++ b/backend/migrations/20260516_enable_rls_deny_all.sql @@ -0,0 +1,36 @@ +-- Migration: enable RLS + deny-all policy on every public base table. +-- Issue: #144 — defense-in-depth second wall against accidental GRANTs. +-- +-- The service role bypasses RLS, so the backend (createServerSupabase) +-- continues to function unchanged. Direct PostgREST access by anon and +-- authenticated roles is blocked by both the existing REVOKE statements +-- in schema.sql and the policy created below. +-- +-- Idempotent — safe to re-run. + +do $$ +declare + tbl text; + policy_name text; +begin + for tbl in + select table_name + from information_schema.tables + where table_schema = 'public' + and table_type = 'BASE TABLE' + loop + execute format('alter table public.%I enable row level security', tbl); + policy_name := 'deny_client_access_' || tbl; + if not exists ( + select 1 from pg_policies + where schemaname = 'public' + and tablename = tbl + and policyname = policy_name + ) then + execute format( + 'create policy %I on public.%I for all to anon, authenticated using (false) with check (false)', + policy_name, tbl + ); + end if; + end loop; +end$$; diff --git a/backend/schema.sql b/backend/schema.sql index d13baaa20..c05c58194 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -431,3 +431,44 @@ revoke all on public.tabular_review_chat_messages from anon, authenticated; revoke all on public.user_api_keys from anon, authenticated; revoke all on public.courtlistener_citation_index from anon, authenticated; revoke all on public.courtlistener_opinion_cluster_index from anon, authenticated; + +-- --------------------------------------------------------------------------- +-- Row Level Security (defense-in-depth second wall) +-- --------------------------------------------------------------------------- +-- +-- The REVOKE block above is the first wall. RLS with a deny-all policy is +-- the second: if any future migration accidentally GRANTs a privilege to +-- anon or authenticated (a common AI-agent "fix" for empty-result queries), +-- this policy still blocks the row. +-- +-- The service role bypasses RLS, so the backend (which uses +-- SUPABASE_SECRET_KEY via createServerSupabase) is unaffected. +-- +-- Idempotent — safe to re-run on existing deployments. + +do $$ +declare + tbl text; + policy_name text; +begin + for tbl in + select table_name + from information_schema.tables + where table_schema = 'public' + and table_type = 'BASE TABLE' + loop + execute format('alter table public.%I enable row level security', tbl); + policy_name := 'deny_client_access_' || tbl; + if not exists ( + select 1 from pg_policies + where schemaname = 'public' + and tablename = tbl + and policyname = policy_name + ) then + execute format( + 'create policy %I on public.%I for all to anon, authenticated using (false) with check (false)', + policy_name, tbl + ); + end if; + end loop; +end$$; From 9d567ca43f73eebfbd0d2e61c120189e78198d04 Mon Sep 17 00:00:00 2001 From: Beau Mersereau Date: Sat, 16 May 2026 08:00:53 -0700 Subject: [PATCH 3/4] fix: address PR #145 review feedback (#144) - Add event trigger enforce_rls_on_public_tables so any future CREATE TABLE in public automatically gets RLS + deny-all policy. SECURITY DEFINER, covers both regular and partitioned tables (relkind 'r','p'). - Add 20260516_enable_rls_deny_all.down.sql rollback that drops the policy, function, event trigger, and disables RLS. - Tighten verify-rls.sql: also assert with_check (write wall), accept both 'false' and '(false)' for Postgres-rendering tolerance, cleaner pg_class join through pg_namespace. - Document the convention in CONTRIBUTING.md: Database Migrations section with rollback expectation, RLS policy expectation, and verify-rls command. --- CONTRIBUTING.md | 21 +++++++ .../20260516_enable_rls_deny_all.down.sql | 28 +++++++++ .../20260516_enable_rls_deny_all.sql | 55 ++++++++++++++++- backend/schema.sql | 61 +++++++++++++++++++ backend/scripts/verify-rls.sql | 28 ++++++--- 5 files changed, 182 insertions(+), 11 deletions(-) create mode 100644 backend/migrations/20260516_enable_rls_deny_all.down.sql diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5fbd2ed51..8b0402717 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,3 +39,24 @@ Frontend: ```bash npm run build --prefix frontend ``` + +## Database Migrations + +The schema lives in two places: + +- `backend/schema.sql` — used for fresh installs of a new Supabase database. +- `backend/migrations/YYYYMMDD_.sql` — incremental scripts applied to existing deployments. Each migration should ship with a paired `.down.sql` rollback. + +### Row Level Security + +Every new table in the `public` schema must have RLS enabled with a deny-all policy for the `anon` and `authenticated` roles. An event trigger (`enforce_rls_on_public_tables`) installed by `20260516_enable_rls_deny_all.sql` does this automatically for any `CREATE TABLE` in `public`, so in normal flow you do not need to repeat the policy. If you disable the trigger temporarily, restore it before merging. + +The service role bypasses RLS, so the backend (`createServerSupabase()` via `SUPABASE_SECRET_KEY`) is unaffected; only direct PostgREST access by `anon` / `authenticated` is blocked. Do not grant direct table privileges to those roles — all application data access goes through the backend. + +After a migration touching the schema, verify: + +```bash +psql -v ON_ERROR_STOP=1 -f backend/scripts/verify-rls.sql "$DATABASE_URL" +``` + +This exits non-zero if any `public` base table is missing RLS or a deny-all policy. diff --git a/backend/migrations/20260516_enable_rls_deny_all.down.sql b/backend/migrations/20260516_enable_rls_deny_all.down.sql new file mode 100644 index 000000000..d6f15f023 --- /dev/null +++ b/backend/migrations/20260516_enable_rls_deny_all.down.sql @@ -0,0 +1,28 @@ +-- Rollback for 20260516_enable_rls_deny_all.sql (issue #144). +-- +-- Drops the deny_client_access_ policy and disables RLS on every public +-- base table. The REVOKE statements in schema.sql remain in force so client +-- roles still cannot reach these tables — this rollback only removes the +-- second wall, not the first. +-- +-- Idempotent — safe to re-run. + +drop event trigger if exists enforce_rls_on_public_tables; +drop function if exists public.enforce_rls_on_public_tables(); + +do $$ +declare + tbl text; + policy_name text; +begin + for tbl in + select table_name + from information_schema.tables + where table_schema = 'public' + and table_type = 'BASE TABLE' + loop + policy_name := 'deny_client_access_' || tbl; + execute format('drop policy if exists %I on public.%I', policy_name, tbl); + execute format('alter table public.%I disable row level security', tbl); + end loop; +end$$; diff --git a/backend/migrations/20260516_enable_rls_deny_all.sql b/backend/migrations/20260516_enable_rls_deny_all.sql index 8ac2f576f..632383669 100644 --- a/backend/migrations/20260516_enable_rls_deny_all.sql +++ b/backend/migrations/20260516_enable_rls_deny_all.sql @@ -1,4 +1,5 @@ --- Migration: enable RLS + deny-all policy on every public base table. +-- Migration: enable RLS + deny-all policy on every public base table, and +-- install an event trigger that does the same for any future public table. -- Issue: #144 — defense-in-depth second wall against accidental GRANTs. -- -- The service role bypasses RLS, so the backend (createServerSupabase) @@ -6,7 +7,8 @@ -- authenticated roles is blocked by both the existing REVOKE statements -- in schema.sql and the policy created below. -- --- Idempotent — safe to re-run. +-- Idempotent — safe to re-run. A matching DOWN script lives at +-- 20260516_enable_rls_deny_all.down.sql. do $$ declare @@ -34,3 +36,52 @@ begin end if; end loop; end$$; + +-- Auto-enforcement for future tables. See schema.sql for the longer +-- explanation; in short: any new public table automatically receives the +-- same deny-all treatment, eliminating the "developer forgot to add RLS" +-- foot-gun. + +create or replace function public.enforce_rls_on_public_tables() +returns event_trigger +language plpgsql +security definer +set search_path = public +as $$ +declare + obj record; + tbl_name text; + policy_name text; +begin + for obj in + select objid, schema_name + from pg_event_trigger_ddl_commands() + where command_tag = 'CREATE TABLE' and schema_name = 'public' + loop + select c.relname into tbl_name + from pg_class c + where c.oid = obj.objid and c.relkind in ('r', 'p'); + if tbl_name is null then + continue; + end if; + execute format('alter table public.%I enable row level security', tbl_name); + policy_name := 'deny_client_access_' || tbl_name; + if not exists ( + select 1 from pg_policies + where schemaname = 'public' + and tablename = tbl_name + and policyname = policy_name + ) then + execute format( + 'create policy %I on public.%I for all to anon, authenticated using (false) with check (false)', + policy_name, tbl_name + ); + end if; + end loop; +end$$; + +drop event trigger if exists enforce_rls_on_public_tables; +create event trigger enforce_rls_on_public_tables + on ddl_command_end + when tag in ('CREATE TABLE') + execute procedure public.enforce_rls_on_public_tables(); diff --git a/backend/schema.sql b/backend/schema.sql index c05c58194..daa20f812 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -472,3 +472,64 @@ begin end if; end loop; end$$; + +-- --------------------------------------------------------------------------- +-- Auto-enforce RLS + deny-all on any future public table +-- --------------------------------------------------------------------------- +-- +-- Event trigger that fires after CREATE TABLE statements. If the new table +-- lives in the public schema, it gets the same deny-all treatment as the +-- existing tables above. This makes it impossible to add a public table +-- without RLS — eliminating the foot-gun where a future migration adds a +-- table and forgets the security boundary. +-- +-- Filters on schema_name = 'public' so DDL in auth/storage/realtime schemas +-- is unaffected. The function is SECURITY DEFINER so it runs as its owner +-- (postgres) — required because the event trigger fires inside the calling +-- transaction and needs privileges to ALTER + CREATE POLICY on the new table. + +create or replace function public.enforce_rls_on_public_tables() +returns event_trigger +language plpgsql +security definer +set search_path = public +as $$ +declare + obj record; + tbl_name text; + policy_name text; +begin + for obj in + select objid, schema_name + from pg_event_trigger_ddl_commands() + where command_tag = 'CREATE TABLE' and schema_name = 'public' + loop + -- pg_class.relname is the unquoted internal identifier; safe to use + -- as the suffix of the policy name and to %I-quote when re-emitting. + select c.relname into tbl_name + from pg_class c + where c.oid = obj.objid and c.relkind in ('r', 'p'); + if tbl_name is null then + continue; + end if; + execute format('alter table public.%I enable row level security', tbl_name); + policy_name := 'deny_client_access_' || tbl_name; + if not exists ( + select 1 from pg_policies + where schemaname = 'public' + and tablename = tbl_name + and policyname = policy_name + ) then + execute format( + 'create policy %I on public.%I for all to anon, authenticated using (false) with check (false)', + policy_name, tbl_name + ); + end if; + end loop; +end$$; + +drop event trigger if exists enforce_rls_on_public_tables; +create event trigger enforce_rls_on_public_tables + on ddl_command_end + when tag in ('CREATE TABLE') + execute procedure public.enforce_rls_on_public_tables(); diff --git a/backend/scripts/verify-rls.sql b/backend/scripts/verify-rls.sql index 22c5e8e1b..ee1be9513 100644 --- a/backend/scripts/verify-rls.sql +++ b/backend/scripts/verify-rls.sql @@ -1,6 +1,7 @@ -- Verification script for issue #144. -- Asserts every public base table has Row Level Security enabled AND a --- deny-all policy for the anon and authenticated roles. Run with: +-- deny-all policy for the anon and authenticated roles (both read and write +-- walls). Run with: -- psql -v ON_ERROR_STOP=1 -f backend/scripts/verify-rls.sql $DATABASE_URL -- The script exits non-zero on any assertion failure. @@ -11,14 +12,15 @@ declare failure_count int := 0; begin -- 1. Every public base table must have RLS enabled (relrowsecurity = true). + -- Join through pg_namespace so we never accidentally match a same-named + -- table in a different schema. for missing_rls in select t.table_name from information_schema.tables t - join pg_class c on c.relname = t.table_name - join pg_namespace n on n.oid = c.relnamespace + join pg_namespace n on n.nspname = t.table_schema + join pg_class c on c.relname = t.table_name and c.relnamespace = n.oid where t.table_schema = 'public' and t.table_type = 'BASE TABLE' - and n.nspname = 'public' and c.relrowsecurity is false order by t.table_name loop @@ -26,8 +28,15 @@ begin failure_count := failure_count + 1; end loop; - -- 2. Every public base table must have at least one policy that applies - -- to anon and authenticated and denies access (USING (false)). + -- 2. Every public base table must have at least one policy that: + -- - applies to both anon AND authenticated + -- - denies reads (qual = false) + -- - denies writes (with_check = false) + -- Postgres has historically rendered USING (false) as the text 'false'; + -- accept '(false)' as well to be forward-compatible with any future + -- rendering change. with_check is nullable in pg_policies (older policies + -- may not have set it explicitly) — treat null as a failure so the + -- write-wall is always explicit. for missing_policy in select t.table_name from information_schema.tables t @@ -40,11 +49,12 @@ begin and p.tablename = t.table_name and 'anon' = any(p.roles) and 'authenticated' = any(p.roles) - and p.qual = 'false' + and p.qual in ('false', '(false)') + and p.with_check in ('false', '(false)') ) order by t.table_name loop - raise warning 'No deny-all policy for anon+authenticated on public.%', + raise warning 'No deny-all policy (read+write) for anon+authenticated on public.%', missing_policy.table_name; failure_count := failure_count + 1; end loop; @@ -53,5 +63,5 @@ begin raise exception 'verify-rls: % assertion(s) failed', failure_count; end if; - raise notice 'verify-rls: all public base tables have RLS enabled and a deny-all policy.'; + raise notice 'verify-rls: all public base tables have RLS enabled and a deny-all read+write policy.'; end$$; From 6890feadbd0b9202bf5f58a2a1620f7f131312bb Mon Sep 17 00:00:00 2001 From: Beau Mersereau Date: Sat, 13 Jun 2026 08:29:04 -0700 Subject: [PATCH 4/4] fix: address self-review feedback on #145 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Down migration now skips disabling RLS on the three tables that had it before this migration ran (user_api_keys, courtlistener_citation_index, courtlistener_opinion_cluster_index), so rollback does not leave them in a worse state than before - verify-rls.sql: add check 3 — asserts event trigger is installed and enabled (evtenabled = 'O'); update success notice to reflect all three checks; fix relrowsecurity predicate to NOT c.relrowsecurity - CONTRIBUTING.md: clarify event trigger is installed by both the migration file (existing deployments) and schema.sql (fresh installs) --- CONTRIBUTING.md | 2 +- .../20260516_enable_rls_deny_all.down.sql | 23 +++++++++++++++---- backend/scripts/verify-rls.sql | 15 ++++++++++-- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b0402717..a389928f9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,7 +49,7 @@ The schema lives in two places: ### Row Level Security -Every new table in the `public` schema must have RLS enabled with a deny-all policy for the `anon` and `authenticated` roles. An event trigger (`enforce_rls_on_public_tables`) installed by `20260516_enable_rls_deny_all.sql` does this automatically for any `CREATE TABLE` in `public`, so in normal flow you do not need to repeat the policy. If you disable the trigger temporarily, restore it before merging. +Every new table in the `public` schema must have RLS enabled with a deny-all policy for the `anon` and `authenticated` roles. An event trigger (`enforce_rls_on_public_tables`) does this automatically for any `CREATE TABLE` in `public` — it is installed by `backend/migrations/20260516_enable_rls_deny_all.sql` for existing deployments and by `backend/schema.sql` for fresh installs. In normal flow you do not need to add the policy manually. If you disable the trigger temporarily, restore it before merging. The service role bypasses RLS, so the backend (`createServerSupabase()` via `SUPABASE_SECRET_KEY`) is unaffected; only direct PostgREST access by `anon` / `authenticated` is blocked. Do not grant direct table privileges to those roles — all application data access goes through the backend. diff --git a/backend/migrations/20260516_enable_rls_deny_all.down.sql b/backend/migrations/20260516_enable_rls_deny_all.down.sql index d6f15f023..db0257da8 100644 --- a/backend/migrations/20260516_enable_rls_deny_all.down.sql +++ b/backend/migrations/20260516_enable_rls_deny_all.down.sql @@ -1,9 +1,14 @@ -- Rollback for 20260516_enable_rls_deny_all.sql (issue #144). -- -- Drops the deny_client_access_ policy and disables RLS on every public --- base table. The REVOKE statements in schema.sql remain in force so client --- roles still cannot reach these tables — this rollback only removes the --- second wall, not the first. +-- base table that did not have RLS before this migration ran. The REVOKE +-- statements in schema.sql remain in force so client roles still cannot reach +-- these tables — this rollback only removes the second wall, not the first. +-- +-- Tables with pre-existing RLS (enabled in schema.sql before this migration): +-- user_api_keys, courtlistener_citation_index, courtlistener_opinion_cluster_index +-- RLS is left ENABLED on those tables on rollback so they are not left in a +-- worse state than before the migration ran. -- -- Idempotent — safe to re-run. @@ -14,6 +19,14 @@ do $$ declare tbl text; policy_name text; + -- Tables that had RLS enabled before this migration. Skipped when + -- disabling RLS so rollback does not leave them worse than their + -- pre-migration state. + pre_existing_rls text[] := array[ + 'user_api_keys', + 'courtlistener_citation_index', + 'courtlistener_opinion_cluster_index' + ]; begin for tbl in select table_name @@ -23,6 +36,8 @@ begin loop policy_name := 'deny_client_access_' || tbl; execute format('drop policy if exists %I on public.%I', policy_name, tbl); - execute format('alter table public.%I disable row level security', tbl); + if not (tbl = any(pre_existing_rls)) then + execute format('alter table public.%I disable row level security', tbl); + end if; end loop; end$$; diff --git a/backend/scripts/verify-rls.sql b/backend/scripts/verify-rls.sql index ee1be9513..c4f1d0ada 100644 --- a/backend/scripts/verify-rls.sql +++ b/backend/scripts/verify-rls.sql @@ -21,7 +21,7 @@ begin join pg_class c on c.relname = t.table_name and c.relnamespace = n.oid where t.table_schema = 'public' and t.table_type = 'BASE TABLE' - and c.relrowsecurity is false + and not c.relrowsecurity order by t.table_name loop raise warning 'RLS not enabled on public.%', missing_rls.table_name; @@ -59,9 +59,20 @@ begin failure_count := failure_count + 1; end loop; + -- 3. The event trigger must be installed and enabled so future tables are + -- automatically protected. evtenabled = 'O' means ORIGIN (fires normally). + if not exists ( + select 1 from pg_event_triggers + where evtname = 'enforce_rls_on_public_tables' + and evtenabled = 'O' + ) then + raise warning 'Event trigger enforce_rls_on_public_tables is not installed or is disabled'; + failure_count := failure_count + 1; + end if; + if failure_count > 0 then raise exception 'verify-rls: % assertion(s) failed', failure_count; end if; - raise notice 'verify-rls: all public base tables have RLS enabled and a deny-all read+write policy.'; + raise notice 'verify-rls: all public base tables have RLS enabled, a deny-all read+write policy, and the event trigger is active.'; end$$;