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