fix: enable RLS with deny-all policy on all public tables#145
fix: enable RLS with deny-all policy on all public tables#145bmersereau wants to merge 4 commits into
Conversation
- 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.
|
Updated based on self-review (commit ea8a44b). Five changes:
Rollback migration —
Event trigger —
Suggested staging-side verification step added to the rollout list
Pre-existing items not addressed in this PR (intentional, out of scope):
|
- Add RLS to all public tables with deny-all default policy and auto-enable event trigger for future tables. PostgREST anon role can no longer read any data. Prisma service-role bypasses RLS (PR willchen96#145) - Wrap runLLMStream() in Promise.race with 180s configurable timeout; sends SSE error event on timeout and closes connection (PR willchen96#112) - Cap download-zip document_ids array at 50 to prevent memory exhaustion from unbounded batch downloads (PR willchen96#111) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tegration - CHANGELOG: add security hardening and feature entries for PRs willchen96#158, willchen96#81, willchen96#76, willchen96#79, willchen96#145, willchen96#112, willchen96#111, willchen96#110, willchen96#155, willchen96#157, willchen96#59 - ROADMAP: mark 12 new items as completed - CLAUDE.md: add sanitize.ts, streamTimeout.ts, credits.ts to lib index, update test count to 40 - README: update API endpoints table (chat pagination, workflow export), security row (HKDF, RLS, prompt defense), encryption row Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Chapter: 15 - Database defense in depth. Plain-English map: Enable Row Level Security fallback policies that deny browser/client roles by default on public tables. Why it matters: The backend uses a service role, but mistakes happen. If a future grant or client path exposes a table, the database should still default to no access. Principle: Least privilege by default, with explicit access instead of accidental access. Precedent borrowed: Upstream PR willchen96#145. Upstream base: willchen96/mike@d39f580. Original local commit: faa098c.
- 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.
ea8a44b to
9d567ca
Compare
bmersereau
left a comment
There was a problem hiding this comment.
PR Review: fix: enable RLS with deny-all policy on all public tables
Summary
Adds RLS as a deny-all second wall over the existing REVOKE block, installs an event trigger to auto-protect future tables, ships paired up/down migrations, and adds a verify script. The approach is sound and well-documented. One migration correctness issue and a few nits.
Risk Assessment
- Risk Level: Low
- Blast Radius: SQL-only; backend is unaffected (service role bypasses RLS). No application code changes.
- Rollback Plan:
psql -f backend/migrations/20260516_enable_rls_deny_all.down.sql— paired down script is included.
Branch Health
- ✅ Fresh — rebased on current
origin/maintip (01dfcfe) - ✅ 3 commits, all scoped to issue #144
- ✅ Conventional commit format
Review by Category
Security
- [severity:praise] Dynamic SQL uses
format('%I', tbl_name)throughout — correctly prevents SQL injection via identifier quoting. - [severity:praise]
SECURITY DEFINERsetsset search_path = public— prevents search-path hijacking. - [severity:praise]
for all to anon, authenticated using (false) with check (false)— blocks both reads and writes. Explicitwith check (false)correctly closes the write wall.
Correctness
-
[severity:major] Down migration over-reverts pre-existing RLS
mainalready hasALTER TABLE public.user_api_keys ENABLE ROW LEVEL SECURITY(and likewise for both CourtListener tables) before this PR. The down script iterates all public base tables and callsdisable row level securityon each — so rolling back this migration leaves those three tables worse than their pre-migration state (RLS disabled, REVOKE still in place).The comment says "this rollback only removes the second wall, not the first" — but for those three tables it also removes the RLS wall that existed before this migration ran.
Fix options:
- Document it explicitly: "tables that had RLS enabled before this migration are not restored to enabled state on rollback; re-enable manually."
- Skip those tables in the down script by recording pre-existing state.
-
[severity:minor] verify-rls.sql doesn't validate the event trigger
The verify script confirms existing tables are protected but doesn't check that
enforce_rls_on_public_tablesis installed. ACREATE TABLE public._rls_probe_<random>→ check policies →DROP TABLEround-trip would make it a complete harness. -
[severity:nit]
relrowsecurity is falseIS FALSEis the NULL-safe form for nullable booleans.pg_class.relrowsecurityisNOT NULL, so= falseorNOT c.relrowsecurityis more idiomatic.
PostgreSQL Checklist
- ✅ Idempotent up migration
- ✅ Paired down migration
- ✅ No user input in dynamic SQL — identifiers sourced from
information_schema/pg_class - ✅
search_pathlocked on SECURITY DEFINER function - ℹ️ Migration uses
information_schema.tables; event trigger usespg_classdirectly — minor inconsistency, no functional impact at migration-time scale.
Documentation
- [severity:nit] CONTRIBUTING.md documents the event trigger as installed by the migration file, but doesn't mention it's also embedded in
schema.sqlfor fresh installs.
Test Coverage
verify-rls.sqlis a solid manual harness. No CI step runs it automatically — worth adding.
Issue Lifecycle
- ✅
Closes #144present
Questions
- Confirmed architecture decision: deny-all for
authenticatedis intentional and PR #130's fine-grained per-user policies won't be merged alongside this? - Any plans to wire
verify-rls.sqlinto CI?
Verdict
- Request changes — the major issue (down migration disables pre-existing RLS on
user_api_keysand the two CourtListener tables) should be addressed or explicitly documented before merge. All other items are minor/nit.
- 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)
Re-review: All previous issues addressed ✅All items from the first review have been resolved. Quick pass on the updated diff: Fixed
Remaining (nits, no re-review needed)
VerdictApprove with nits — ready to merge. Both remaining items are one-liners and don't need another round. |
|
Any timeline on wiring |
Summary
Adds Row Level Security as a defense-in-depth second wall against accidental
GRANTstatements on application tables.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.backend/migrations/20260516_enable_rls_deny_all.sqlso existing deployments can apply it incrementally.enforce_rls_on_public_tables) so any futureCREATE TABLEin thepublicschema automatically gets the same deny-all treatment.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
mainalready enables RLS (with no policies) on 3 tables:user_api_keyscourtlistener_citation_indexcourtlistener_opinion_cluster_indexNo 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:authenticatedaccessIn Postgres, permissive policies combine with OR, so if both land, PR #130's allow-policies would silently nullify this PR's deny-all for
authenticatedusers. 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
backend/schema.sqlbackend/migrations/20260516_enable_rls_deny_all.sqlbackend/migrations/20260516_enable_rls_deny_all.down.sqlWhy this matters
The current authorization model is a single
REVOKE ALL ... FROM anon, authenticatedblock. One accidentalGRANT SELECT ON public.documents TO authenticatedin a future migration, hotfix, or Supabase dashboard click undoes it for that table with zero pushback. RLS with ausing (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()usesSUPABASE_SECRET_KEY) is unaffected.Test plan
tscbuild passes locally.if not existsguard and idempotentenable row level security).schema.sqlresolved: kept newREVOKElines forcourtlistener_*tables from main alongside the RLS block from this branch).from('documents').select('*')returns no rows — confirming the second wall.Notes for reviewers
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.