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>
69 lines
3.3 KiB
SQL
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
|
|
)
|
|
);
|