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>
This commit is contained in:
@@ -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
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user