feat(store): shared, video-keyed transcript persistence (ADR-021)
Reshape the dead per-user transcripts table (PK videos.id, user_id, RLS-FORCEd — never read or written by app code) into the shared public caption store ADR-021 specifies: keyed by (provider, provider_video_id), no user_id, NOT RLS-scoped. Migration 015 (reversible). Add ports.TranscriptStore + Store.GetTranscript/SaveTranscript via the raw pool (no withUser): public content, shared across users by construction. SaveTranscript persists only terminal outcomes (captions/none) and refuses SourceRateLimited so a transient 429 can never be stored as a false permanent absence (ADR-014). Flip the isolation proof: transcripts leaves the RLS-scoped set; TestTranscriptsTableIsSharedNotRLS asserts it is the SINGLE non-RLS surface (writable/readable with no user scope, no user_id column, RLS off on it alone, still on every user-owned table) — the proof the public-content classification was applied exactly here and leaked nowhere. appPool made idempotent so two tests can build it. Adjust the 010/011/014 up-down migration tests for the new HEAD. account.go: user deletion no longer strips shared transcripts. Reconcile data-model.md + CLAUDE.md. Wiring the engine to read-stored-first is the next commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,9 +22,11 @@ import (
|
||||
// (no GUC set → zero rows) proves the enforcement path is live, not bypassed.
|
||||
|
||||
// userIsolatedTables are the tables that carry a user_id and whose policy keys
|
||||
// directly off the tapir.current_user_id GUC.
|
||||
// directly off the tapir.current_user_id GUC. transcripts is deliberately ABSENT
|
||||
// — ADR-021 made it shared public content (non-RLS); TestTranscriptsTableIsSharedNotRLS
|
||||
// proves that is the only place the isolation boundary moved.
|
||||
var userIsolatedTables = []string{
|
||||
"users", "videos", "transcripts", "summaries", "summary_actions", "login_events", "video_connections",
|
||||
"users", "videos", "summaries", "summary_actions", "login_events", "video_connections",
|
||||
}
|
||||
|
||||
// allIsolatedTables adds sink_deliveries, whose ownership is derived from its
|
||||
@@ -38,9 +40,10 @@ type seeded struct {
|
||||
summaryID string
|
||||
}
|
||||
|
||||
// seedUser inserts one full chain (user → video → transcript → summary →
|
||||
// action → delivery) as the superuser pool, which bypasses RLS so both users'
|
||||
// data lands regardless of the GUC.
|
||||
// seedUser inserts one full chain (user → video → summary → action → delivery)
|
||||
// as the superuser pool, which bypasses RLS so both users' data lands regardless
|
||||
// of the GUC. Transcripts are NOT seeded here: they are shared, non-RLS public
|
||||
// content (ADR-021), so they have no place in a per-user isolation chain.
|
||||
func seedUser(t *testing.T, p *pgxpool.Pool, userID string) seeded {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
@@ -54,11 +57,6 @@ func seedUser(t *testing.T, p *pgxpool.Pool, userID string) seeded {
|
||||
VALUES ($1, 'youtube', $2, 'title') RETURNING id`,
|
||||
userID, "vid-"+userID).Scan(&videoID))
|
||||
|
||||
_, err = p.Exec(ctx,
|
||||
`INSERT INTO transcripts (video_id, user_id, source, content)
|
||||
VALUES ($1, $2, 'captions', 'words')`, videoID, userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
var summaryID string
|
||||
require.NoError(t, p.QueryRow(ctx,
|
||||
`INSERT INTO summaries (user_id, video_id, summary) VALUES ($1, $2, 'sum')
|
||||
@@ -92,9 +90,16 @@ func appPool(t *testing.T, super *pgxpool.Pool) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
// Idempotent across test runs (schema/role persist for the TestMain PG).
|
||||
_, _ = super.Exec(ctx, `DROP ROLE IF EXISTS app`)
|
||||
_, err := super.Exec(ctx, `CREATE ROLE app LOGIN PASSWORD 'app'`)
|
||||
// Idempotent across tests AND runs: the role persists for the TestMain PG and
|
||||
// owns granted privileges, so a plain DROP ROLE fails once any GRANT exists
|
||||
// (and more than one test now builds an app pool). Create only if absent; the
|
||||
// GRANTs below are themselves idempotent.
|
||||
_, err := super.Exec(ctx,
|
||||
`DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'app') THEN
|
||||
CREATE ROLE app LOGIN PASSWORD 'app';
|
||||
END IF;
|
||||
END $$`)
|
||||
require.NoError(t, err)
|
||||
_, err = super.Exec(ctx, `GRANT USAGE ON SCHEMA public TO app`)
|
||||
require.NoError(t, err)
|
||||
@@ -186,7 +191,6 @@ func TestRLSEnforcesPerUserIsolation(t *testing.T) {
|
||||
{"update users", `UPDATE users SET display_name = 'hacked' WHERE id = $1`, b.userID},
|
||||
{"update videos", `UPDATE videos SET title = 'hacked' WHERE user_id = $1`, b.userID},
|
||||
{"queue videos summarize", `UPDATE videos SET summarize_requested = TRUE WHERE id = $1`, b.videoID},
|
||||
{"update transcripts", `UPDATE transcripts SET content = 'hacked' WHERE user_id = $1`, b.userID},
|
||||
{"update summaries", `UPDATE summaries SET summary = 'hacked' WHERE user_id = $1`, b.userID},
|
||||
{"update summary_actions", `UPDATE summary_actions SET action = 'skipped' WHERE user_id = $1`, b.userID},
|
||||
{"update login_events", `UPDATE login_events SET seen_at = NOW() WHERE user_id = $1`, b.userID},
|
||||
@@ -235,3 +239,55 @@ func TestRLSEnforcesPerUserIsolation(t *testing.T) {
|
||||
|
||||
_ = a // a's ids are seeded for the symmetric read assertions above
|
||||
}
|
||||
|
||||
// TestTranscriptsTableIsSharedNotRLS is the ADR-021 isolation proof: transcripts
|
||||
// is the ONE shared, non-RLS surface, and the public-content classification
|
||||
// leaked to nothing else. It is the inverse of TestRLSEnforcesPerUserIsolation —
|
||||
// where that asserts deny-all on every user-owned table, this asserts transcripts
|
||||
// is readable and writable with no user scope at all, holds no user_id, and is
|
||||
// the single table with row-level security switched off.
|
||||
func TestTranscriptsTableIsSharedNotRLS(t *testing.T) {
|
||||
newStore(t)
|
||||
super := rawPool(t)
|
||||
resetDB(t, super)
|
||||
app := appPool(t, super)
|
||||
ctx := context.Background()
|
||||
|
||||
// 1. Shared + non-RLS: with NO GUC set, the app role both writes and reads a
|
||||
// transcript. On an RLS table this would be deny-all (zero rows), exactly as
|
||||
// the main isolation test asserts for every user-owned table.
|
||||
_, err := app.Exec(ctx,
|
||||
`INSERT INTO transcripts (provider, provider_video_id, source, content)
|
||||
VALUES ('youtube', 'shared-vid', 'captions', 'public words')`)
|
||||
require.NoError(t, err, "app role must write shared transcript content with no user scope")
|
||||
require.Equal(t, 1, scopedCount(t, app, "", "transcripts"),
|
||||
"transcripts must be readable with NO user scope — it is shared, non-RLS (ADR-021)")
|
||||
|
||||
// 2. No user_id column: the table holds only public caption content + the
|
||||
// video's public id, nothing user-identifying.
|
||||
var hasUserID bool
|
||||
require.NoError(t, super.QueryRow(ctx,
|
||||
`SELECT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'transcripts' AND column_name = 'user_id')`).Scan(&hasUserID))
|
||||
require.False(t, hasUserID, "transcripts must carry no user_id (ADR-021 public content)")
|
||||
|
||||
// 3. The boundary is EXACTLY here: every user-owned table still has row-level
|
||||
// security enabled; transcripts alone has it off. This is the proof the
|
||||
// non-RLS classification was applied to transcripts and leaked nowhere else.
|
||||
for _, table := range allIsolatedTables {
|
||||
require.True(t, rlsEnabled(t, super, table),
|
||||
"%s must still enforce row-level security — isolation must not have regressed", table)
|
||||
}
|
||||
require.False(t, rlsEnabled(t, super, "transcripts"),
|
||||
"transcripts must be the single table with row-level security OFF (the one shared surface)")
|
||||
}
|
||||
|
||||
// rlsEnabled reports whether a public table has ROW LEVEL SECURITY enabled.
|
||||
func rlsEnabled(t *testing.T, p *pgxpool.Pool, table string) bool {
|
||||
t.Helper()
|
||||
var enabled bool
|
||||
require.NoError(t, p.QueryRow(context.Background(),
|
||||
`SELECT relrowsecurity FROM pg_class
|
||||
WHERE relname = $1 AND relnamespace = 'public'::regnamespace`, table).Scan(&enabled))
|
||||
return enabled
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user