Files
tapir/internal/adapters/store/migrations/003_rls.up.sql
T
mathiasandClaude Opus 4.8 6775e5f53d feat(store): migration 003 — enforce per-user isolation via forced RLS
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) <noreply@anthropic.com>
2026-06-03 15:15:44 +02:00

69 lines
3.3 KiB
SQL

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