From e36c4c1e0ca8690dc690cc0a2ea6d16f545a7008 Mon Sep 17 00:00:00 2001 From: Mathias Date: Thu, 1 Oct 2026 16:36:50 +0200 Subject: [PATCH] feat(store): migration 017 -- private RLS-scoped uploaded_transcripts (ADR-033) Separate table, not a user_id on the shared transcripts (ADR-021). ENABLE + FORCE ROW LEVEL SECURITY with the migration-003 policy, user_id NOT NULL ON DELETE CASCADE, typed error_kind, per-row serving endpoint, JSONB segments. CHECK constraints: done => content, failed => error_kind. Up and down are one commit: golang-migrate pairs them. Mutation-checked: dropping FORCE, the policy, or the cascade each turn a test red. FORCE is only observable structurally -- the app role is not the table owner, so no behavioural test can see it. Co-Authored-By: Claude Sonnet 5.5 --- .../017_uploaded_transcripts.down.sql | 3 ++ .../017_uploaded_transcripts.up.sql | 48 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 internal/adapters/store/migrations/017_uploaded_transcripts.down.sql create mode 100644 internal/adapters/store/migrations/017_uploaded_transcripts.up.sql diff --git a/internal/adapters/store/migrations/017_uploaded_transcripts.down.sql b/internal/adapters/store/migrations/017_uploaded_transcripts.down.sql new file mode 100644 index 0000000..8040680 --- /dev/null +++ b/internal/adapters/store/migrations/017_uploaded_transcripts.down.sql @@ -0,0 +1,3 @@ +-- Drops the private uploaded-transcript store and every transcript in it. There +-- is no revert of the data: the original audio was never kept (ADR-033). +DROP TABLE uploaded_transcripts; diff --git a/internal/adapters/store/migrations/017_uploaded_transcripts.up.sql b/internal/adapters/store/migrations/017_uploaded_transcripts.up.sql new file mode 100644 index 0000000..bdd311a --- /dev/null +++ b/internal/adapters/store/migrations/017_uploaded_transcripts.up.sql @@ -0,0 +1,48 @@ +-- Migration 017: uploaded_transcripts — PRIVATE transcripts of audio a user +-- uploaded themselves (ADR-033, tapir#33). +-- +-- This is deliberately NOT the shared `transcripts` table (migration 015, +-- ADR-021). That table is non-RLS on the explicit grounds that it holds only +-- public caption content and nothing user-identifying. A recorded meeting is the +-- opposite: private third-party content owned by exactly one user. Reusing +-- `transcripts` — or adding a user_id to it with a partial policy — would put a +-- private row in the one table the isolation test asserts is shared, and would +-- pass CI green. A separate table keeps ADR-021's exception singular. +-- +-- Row-level security follows migration 003 exactly: ENABLE + FORCE (the app +-- connects as the table owner, which bypasses RLS unless forced), and a policy +-- keyed off the per-request GUC tapir.current_user_id. With no GUC set the +-- predicate is NULL and the table is deny-all. +-- +-- The original audio is never stored (ADR-033): this row is all that is kept. +-- segments holds the timestamped cues; re-transcribing 90 minutes costs ~7 +-- minutes, so discarding the timestamps would be irreversible. +CREATE TABLE uploaded_transcripts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'queued' + CHECK (status IN ('queued', 'transcribing', 'done', 'failed')), + content TEXT, + segments JSONB, + endpoint TEXT, -- which backend served it (iguana | berget); a quality fact + audio_seconds DOUBLE PRECISION, + error_kind TEXT, -- typed, never a provider's error string (ADR-033) + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + finished_at TIMESTAMPTZ, + CONSTRAINT uploaded_transcripts_done_has_content + CHECK (status <> 'done' OR content IS NOT NULL), + CONSTRAINT uploaded_transcripts_failed_has_error_kind + CHECK (status <> 'failed' OR error_kind IS NOT NULL) +); + +CREATE INDEX idx_uploaded_transcripts_user ON uploaded_transcripts (user_id, created_at DESC); + +ALTER TABLE uploaded_transcripts ENABLE ROW LEVEL SECURITY; +ALTER TABLE uploaded_transcripts FORCE ROW LEVEL SECURITY; +CREATE POLICY uploaded_transcripts_isolation ON uploaded_transcripts + FOR ALL + USING (user_id = current_setting('tapir.current_user_id', true)::uuid); + +COMMENT ON TABLE uploaded_transcripts IS + 'PRIVATE per-user transcripts of user-supplied audio. RLS-scoped (ADR-012). Never merge into the shared transcripts table (ADR-021, ADR-033).';