fix: enable RLS with deny-all policy on all public tables

🟢 open · #145 · Open-Legal-Products/mike ← bmersereau/mike · opened 2mo ago by bmersereau · +331 across 5 files · ↗ on GitHub

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 to backend/schema.sql that, for every public base table, runs ALTER TABLE ... ENABLE ROW LEVEL SECURITY and creates a deny_client_access_<tbl> policy with USING (false) WITH CHECK (false) for anon, authenticated.
  • Ships the same block as backend/migrations/20260516_enable_rls_deny_all.sql so existing deployments can apply it incrementally.
  • Installs an event trigger (enforce_rls_on_public_tables) so any future CREATE TABLE in the public schema 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 tsc build passes locally.
  • Schema block is idempotent (re-runs are no-ops via if not exists guard and idempotent enable row level security).
  • Rebased cleanly onto main (conflict in schema.sql resolved: kept new REVOKE lines for courtlistener_* 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 eslint and next build failures on this branch reproduce identically on origin/main - pre-existing tooling debt, not regressions from this PR.
  • npm audit reports 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.

⬇ Download capture-pull-145.md