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 + ) + );