fix: enable RLS with deny-all policy on all public tables
From the PR description
Summary
Adds Row Level Security as a defense-in-depth second wall against accidental GRANT statements on application tables.
- Appends a
DO $$ ... END$$block tobackend/schema.sqlthat, for everypublicbase table, runsALTER TABLE ... ENABLE ROW LEVEL SECURITYand creates adeny_client_access_<tbl>policy withUSING (false) WITH CHECK (false)foranon, authenticated. - Ships the same block as
backend/migrations/20260516_enable_rls_deny_all.sqlso existing deployments can apply it incrementally. - Installs an event trigger (
enforce_rls_on_public_tables) so any futureCREATE TABLEin thepublicschema automatically gets the same deny-all treatment. - Adds
backend/scripts/verify-rls.sql- a psql-runnable assertion script that fails non-zero if any public table is missing RLS or the deny-all policy.
Closes #144
Context: existing RLS on main
main already enables RLS (with no policies) on 3 tables:
| Table | RLS on main |
|---|---|
user_api_keys |
enabled, no policy |
courtlistener_citation_index |
enabled, no policy |
courtlistener_opinion_cluster_index |
enabled, no policy |
No other tables have RLS at all. This PR extends RLS + deny-all to all public tables, filling that gap.
Relation to PR #130
PR #130 ("Disclose database migrations") proposes a different RLS strategy - fine-grained per-user policies for authenticated (owner-only mutations, share-aware SELECTs via SECURITY DEFINER helpers). These two approaches are philosophically incompatible:
| This PR (#145) | PR #130 | |
|---|---|---|
| Philosophy | Backend-only; deny all client roles | Fine-grained per-user RLS |
authenticated access |
Blocked | Owner + share-aware SELECT/mutate |
| Frontend direct queries | Not supported | Supported |
In Postgres, permissive policies combine with OR, so if both land, PR #130's allow-policies would silently nullify this PR's deny-all for authenticated users. A decision is needed on which model to adopt before both are merged.
The current codebase uses no frontend direct queries - all data access goes through the backend via service role - so this PR's deny-all model fits the existing architecture.
Changes
| File | Change |
|---|---|
backend/schema.sql |
Append RLS + deny-all policy block + event trigger |
backend/migrations/20260516_enable_rls_deny_all.sql |
New incremental migration |
backend/migrations/20260516_enable_rls_deny_all.down.sql |
Rollback script |
Why this matters
The current authorization model is a single REVOKE ALL ... FROM anon, authenticated block. One accidental GRANT SELECT ON public.documents TO authenticated in a future migration, hotfix, or Supabase dashboard click undoes it for that table with zero pushback. RLS with a using (false) deny-all policy is a second wall: even if a grant lands, the policy still blocks the row. The event trigger makes it impossible to add a new public table without the same protection.
The service role bypasses RLS, so the backend (createServerSupabase() uses SUPABASE_SECRET_KEY) is unaffected.
Test plan
- Backend
tscbuild passes locally. - Schema block is idempotent (re-runs are no-ops via
if not existsguard and idempotentenable row level security). - Rebased cleanly onto main (conflict in
schema.sqlresolved: kept newREVOKElines forcourtlistener_*tables from main alongside the RLS block from this branch). - Reviewer to apply the migration on a Supabase staging instance and confirm:
- The verify script exits clean.
- The backend continues to serve normal API traffic (service role bypasses RLS).
- A direct PostgREST call from the browser anon client to e.g.
from('documents').select('*')returns no rows - confirming the second wall.
Notes for reviewers
- The frontend
eslintandnext buildfailures on this branch reproduce identically onorigin/main- pre-existing tooling debt, not regressions from this PR. npm auditreports unrelated high/moderate transitive CVEs - out of scope here.
Our analysis
Add deny-all RLS as a second wall on all public tables — read the full analysis →
Think the analysis missed something the PR description covers?
Capture this PR into my fork
Download a Markdown prompt that tells Claude how to port every
commit in this PR into your working tree. Run it via
claude -p < capture-pull-145.md from
inside the repo you want the changes in.