From 6775e5f53d36b2d4839af462f2c84c9320da1d72 Mon Sep 17 00:00:00 2001 From: Mathias Date: Wed, 3 Jun 2026 15:15:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(store):=20migration=20003=20=E2=80=94=20en?= =?UTF-8?q?force=20per-user=20isolation=20via=20forced=20RLS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable AND FORCE row-level security on every user-owned table (users, videos, transcripts, summaries, summary_actions, sink_deliveries) per ADR-012. Each policy keys off the per-request GUC tapir.current_user_id; an unset GUC yields NULL → deny-all (the safe default). FORCE is load-bearing: the app connects as the table owner (tapir), and owners bypass RLS unless forced. Without FORCE the policies are dead for the prod user. sink_deliveries has no user_id; its policy derives ownership from the summary it belongs to via EXISTS against the GUC, so it is self-contained rather than silently depending on summaries' own RLS being applied to a subquery. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../store/migrations/003_rls.down.sql | 23 +++++++ .../adapters/store/migrations/003_rls.up.sql | 68 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 internal/adapters/store/migrations/003_rls.down.sql create mode 100644 internal/adapters/store/migrations/003_rls.up.sql diff --git a/internal/adapters/store/migrations/003_rls.down.sql b/internal/adapters/store/migrations/003_rls.down.sql new file mode 100644 index 0000000..87cdf0f --- /dev/null +++ b/internal/adapters/store/migrations/003_rls.down.sql @@ -0,0 +1,23 @@ +DROP POLICY IF EXISTS sink_deliveries_isolation ON sink_deliveries; +ALTER TABLE sink_deliveries NO FORCE ROW LEVEL SECURITY; +ALTER TABLE sink_deliveries DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS summary_actions_isolation ON summary_actions; +ALTER TABLE summary_actions NO FORCE ROW LEVEL SECURITY; +ALTER TABLE summary_actions DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS summaries_isolation ON summaries; +ALTER TABLE summaries NO FORCE ROW LEVEL SECURITY; +ALTER TABLE summaries DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS transcripts_isolation ON transcripts; +ALTER TABLE transcripts NO FORCE ROW LEVEL SECURITY; +ALTER TABLE transcripts DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS videos_isolation ON videos; +ALTER TABLE videos NO FORCE ROW LEVEL SECURITY; +ALTER TABLE videos DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS users_isolation ON users; +ALTER TABLE users NO FORCE ROW LEVEL SECURITY; +ALTER TABLE users DISABLE ROW LEVEL SECURITY; diff --git a/internal/adapters/store/migrations/003_rls.up.sql b/internal/adapters/store/migrations/003_rls.up.sql new file mode 100644 index 0000000..6135fc6 --- /dev/null +++ b/internal/adapters/store/migrations/003_rls.up.sql @@ -0,0 +1,68 @@ +-- Migration 003: enforce per-user isolation at the DB layer via row-level +-- security (ADR-012, data-model.md "Isolation invariant"). Stage 1 ships +-- multi-user WITH this enforcement; it is the proof that user A cannot read or +-- write user B's rows even if application-level WHERE clauses are wrong. +-- +-- How it works: +-- * Every policy keys off the per-request GUC tapir.current_user_id, set by the +-- store's withUser helper via set_config('tapir.current_user_id', $1, true) +-- (transaction-local — auto-reset on commit/rollback, never leaks across a +-- pooled connection's requests). +-- * current_setting('tapir.current_user_id', true) uses missing_ok = true: an +-- UNSET GUC yields NULL, so the predicate is NULL → no rows match → deny-all. +-- That is the safe default and is asserted in rls_test.go. +-- * FORCE ROW LEVEL SECURITY: the app connects as the table OWNER (tapir), and +-- owners BYPASS RLS unless forced. Without FORCE the policies below are dead +-- for the production user. FORCE makes the owner subject to them. (A superuser +-- DSN still bypasses RLS regardless — the test connects as a non-superuser, +-- non-BYPASSRLS role so the enforcement is real, not theatre.) + +-- users: the row's own id IS the user_id for this table. +ALTER TABLE users ENABLE ROW LEVEL SECURITY; +ALTER TABLE users FORCE ROW LEVEL SECURITY; +CREATE POLICY users_isolation ON users + FOR ALL + USING (id = current_setting('tapir.current_user_id', true)::uuid); + +ALTER TABLE videos ENABLE ROW LEVEL SECURITY; +ALTER TABLE videos FORCE ROW LEVEL SECURITY; +CREATE POLICY videos_isolation ON videos + FOR ALL + USING (user_id = current_setting('tapir.current_user_id', true)::uuid); + +ALTER TABLE transcripts ENABLE ROW LEVEL SECURITY; +ALTER TABLE transcripts FORCE ROW LEVEL SECURITY; +CREATE POLICY transcripts_isolation ON transcripts + FOR ALL + USING (user_id = current_setting('tapir.current_user_id', true)::uuid); + +ALTER TABLE summaries ENABLE ROW LEVEL SECURITY; +ALTER TABLE summaries FORCE ROW LEVEL SECURITY; +CREATE POLICY summaries_isolation ON summaries + FOR ALL + USING (user_id = current_setting('tapir.current_user_id', true)::uuid); + +ALTER TABLE summary_actions ENABLE ROW LEVEL SECURITY; +ALTER TABLE summary_actions FORCE ROW LEVEL SECURITY; +CREATE POLICY summary_actions_isolation ON summary_actions + FOR ALL + USING (user_id = current_setting('tapir.current_user_id', true)::uuid); + +-- sink_deliveries has NO user_id of its own; ownership is derived from the +-- summary it belongs to. We key the policy directly off the GUC via EXISTS +-- (rather than `summary_id IN (SELECT id FROM summaries)`) so it is self-contained +-- and does not silently depend on summaries' own RLS being applied to the +-- subquery. The WITH CHECK clause (defaulting to USING under FOR ALL) means a +-- delivery row can only be inserted/updated when its summary is owned by the +-- current user. +ALTER TABLE sink_deliveries ENABLE ROW LEVEL SECURITY; +ALTER TABLE sink_deliveries FORCE ROW LEVEL SECURITY; +CREATE POLICY sink_deliveries_isolation ON sink_deliveries + FOR ALL + USING ( + EXISTS ( + SELECT 1 FROM summaries s + WHERE s.id = sink_deliveries.summary_id + AND s.user_id = current_setting('tapir.current_user_id', true)::uuid + ) + );