Files
tapir/internal/adapters/store/rls_test.go
T
mathiasandClaude Opus 4.8 bdbdce7de1 feat(store): summarization-mode methods + all-videos read
Add the store surface for summarization mode:
- SetAutoSummarize / GetAutoSummarize — per-user auto/manual toggle; an absent
  user reads as manual (the safe default).
- RequestSummarize — queue one video (summarize_requested=TRUE); ErrNotFound
  when the video is absent or not owned (RLS hides another user's row).
- RequestedVideoIDs / ClearSummarizeRequested — the run-loop side: load the
  queued set per pass (mirrors SeenVideoIDs), clear after summarizing.
- ListVideos / GetVideoRow — drive from the videos table LEFT JOIN summaries so
  discovered-but-unsummarized videos appear with empty summary fields. SummaryRow
  gains additive Summarized + SummarizeRequested fields; the summary-only reads
  are untouched.

RLS proof: rls_test.go gains a cross-user "queue B's video" write asserting it
touches zero rows (store-level scoping can't prove this — the test pool is a
superuser that bypasses RLS, same caveat documented there).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:26:52 +02:00

229 lines
9.6 KiB
Go

package store_test
import (
"context"
"fmt"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
)
// This is the isolation proof for ADR-012: per-user isolation is enforced by the
// database (migration 003 RLS policies), not merely by application WHERE clauses.
//
// CRITICAL: embedded-postgres's default user (postgres) is a SUPERUSER, which
// BYPASSES RLS regardless of FORCE ROW LEVEL SECURITY. A test that ran scoped
// queries as postgres would be fake-green — it would pass even with the policies
// removed. So this test creates a dedicated NON-SUPERUSER, non-BYPASSRLS role
// ("app", mirroring the production table-owner role tapir which FORCE subjects to
// RLS) and runs every scoped query as that role. The deny-all sanity check below
// (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.
var userIsolatedTables = []string{
"users", "videos", "transcripts", "summaries", "summary_actions", "video_connections",
}
// allIsolatedTables adds sink_deliveries, whose ownership is derived from its
// summary (no user_id column of its own).
var allIsolatedTables = append(append([]string{}, userIsolatedTables...), "sink_deliveries")
// seeded captures the DB-generated ids for one user's row chain.
type seeded struct {
userID string
videoID string // videos.id (UUID), reused as summaries.video_id
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.
func seedUser(t *testing.T, p *pgxpool.Pool, userID string) seeded {
t.Helper()
ctx := context.Background()
_, err := p.Exec(ctx, `INSERT INTO users (id) VALUES ($1)`, userID)
require.NoError(t, err)
var videoID string
require.NoError(t, p.QueryRow(ctx,
`INSERT INTO videos (user_id, provider, provider_video_id, title)
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')
RETURNING id`, userID, videoID).Scan(&summaryID))
_, err = p.Exec(ctx,
`INSERT INTO summary_actions (user_id, video_id, action)
VALUES ($1, $2, 'watched')`, userID, videoID)
require.NoError(t, err)
_, err = p.Exec(ctx,
`INSERT INTO sink_deliveries (summary_id, sink, status)
VALUES ($1, 'store', 'delivered')`, summaryID)
require.NoError(t, err)
_, err = p.Exec(ctx,
`INSERT INTO video_connections (user_id, provider, token_ref, status)
VALUES ($1, 'youtube', $2, 'active')`, userID, "youtube/"+userID+"/refresh_token")
require.NoError(t, err)
return seeded{userID: userID, videoID: videoID, summaryID: summaryID}
}
// appPool creates a non-superuser role with DML grants and returns a pool
// connected AS that role, so RLS is actually enforced for it.
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'`)
require.NoError(t, err)
_, err = super.Exec(ctx, `GRANT USAGE ON SCHEMA public TO app`)
require.NoError(t, err)
_, err = super.Exec(ctx,
`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app`)
require.NoError(t, err)
appDSN := strings.Replace(dsn, "postgres:postgres@", "app:app@", 1)
p, err := pgxpool.New(ctx, appDSN)
require.NoError(t, err)
t.Cleanup(p.Close)
// Sanity: the app role must NOT be a superuser / must not bypass RLS, else
// this whole test is theatre.
var isSuper bool
require.NoError(t, p.QueryRow(ctx,
`SELECT rolsuper FROM pg_roles WHERE rolname = current_user`).Scan(&isSuper))
require.False(t, isSuper, "app role must be non-superuser or RLS is bypassed")
return p
}
// scopedCount counts rows in table as the app role, optionally scoped to a user
// via the transaction-local GUC. An empty scope sets no GUC (deny-all path).
func scopedCount(t *testing.T, p *pgxpool.Pool, scope, table string) int {
t.Helper()
ctx := context.Background()
tx, err := p.Begin(ctx)
require.NoError(t, err)
defer tx.Rollback(ctx) //nolint:errcheck
if scope != "" {
_, err = tx.Exec(ctx, `SELECT set_config('tapir.current_user_id', $1, true)`, scope)
require.NoError(t, err)
}
var n int
require.NoError(t, tx.QueryRow(ctx, `SELECT count(*) FROM `+table).Scan(&n))
return n
}
// scopedRowsAffected runs a write as the app role scoped to scope and returns the
// rows affected, so we can assert a cross-user write touches zero rows.
func scopedRowsAffected(t *testing.T, p *pgxpool.Pool, scope, sql string, args ...any) int64 {
t.Helper()
ctx := context.Background()
tx, err := p.Begin(ctx)
require.NoError(t, err)
defer tx.Rollback(ctx) //nolint:errcheck
_, err = tx.Exec(ctx, `SELECT set_config('tapir.current_user_id', $1, true)`, scope)
require.NoError(t, err)
ct, err := tx.Exec(ctx, sql, args...)
require.NoError(t, err) // RLS hides the rows; it is NOT a permission error
require.NoError(t, tx.Commit(ctx))
return ct.RowsAffected()
}
func TestRLSEnforcesPerUserIsolation(t *testing.T) {
newStore(t) // apply migrations (incl. 003 RLS) as superuser
super := rawPool(t)
resetDB(t, super)
a := seedUser(t, super, userA)
b := seedUser(t, super, userB)
app := appPool(t, super)
// 1. Deny-all: with NO GUC set, every isolated table returns zero rows. This
// proves RLS is actually ON (a bypassed/superuser path would see all rows).
for _, table := range allIsolatedTables {
require.Equal(t, 0, scopedCount(t, app, "", table),
"unset tapir.current_user_id must yield deny-all on %s", table)
}
// 2. Scoped reads: A sees exactly its own one row per table; likewise B. A
// seeing B's row (or vice versa) would mean isolation is broken.
for _, table := range allIsolatedTables {
require.Equal(t, 1, scopedCount(t, app, userA, table),
"user A scoped read must see exactly its own row in %s", table)
require.Equal(t, 1, scopedCount(t, app, userB, table),
"user B scoped read must see exactly its own row in %s", table)
}
// 3. Cross-user writes are invisible: scoped to A, an UPDATE/DELETE aimed at
// B's rows affects zero rows (RLS hides them from the write, too).
writes := []struct {
name string
sql string
arg any // identifies B's row(s)
}{
{"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 sink_deliveries", `UPDATE sink_deliveries SET status = 'hacked' WHERE summary_id = $1`, b.summaryID},
{"update video_connections", `UPDATE video_connections SET token_ref = 'hacked' WHERE user_id = $1`, b.userID},
{"delete summaries", `DELETE FROM summaries WHERE user_id = $1`, b.userID},
{"delete summary_actions", `DELETE FROM summary_actions WHERE user_id = $1`, b.userID},
{"delete sink_deliveries", `DELETE FROM sink_deliveries WHERE summary_id = $1`, b.summaryID},
{"delete video_connections", `DELETE FROM video_connections WHERE user_id = $1`, b.userID},
}
for _, w := range writes {
require.Equal(t, int64(0), scopedRowsAffected(t, app, userA, w.sql, w.arg),
"user A scoped %s must touch zero of user B's rows", w.name)
}
// 4. B's rows survived unchanged (the writes above neither modified nor
// deleted them), verified via the superuser pool which bypasses RLS.
ctx := context.Background()
var bSummary string
require.NoError(t, super.QueryRow(ctx,
`SELECT summary FROM summaries WHERE user_id = $1`, b.userID).Scan(&bSummary))
require.Equal(t, "sum", bSummary, "B's summary must be untouched by A's writes")
var bSummaries, bActions, bDeliveries, bConnections int
require.NoError(t, super.QueryRow(ctx,
`SELECT count(*) FROM summaries WHERE user_id = $1`, b.userID).Scan(&bSummaries))
require.NoError(t, super.QueryRow(ctx,
`SELECT count(*) FROM summary_actions WHERE user_id = $1`, b.userID).Scan(&bActions))
require.NoError(t, super.QueryRow(ctx,
fmt.Sprintf(`SELECT count(*) FROM sink_deliveries WHERE summary_id = '%s'`, b.summaryID)).Scan(&bDeliveries))
require.NoError(t, super.QueryRow(ctx,
`SELECT count(*) FROM video_connections WHERE user_id = $1 AND token_ref <> 'hacked'`, b.userID).Scan(&bConnections))
require.Equal(t, 1, bSummaries, "A's DELETE must not have removed B's summary")
require.Equal(t, 1, bActions, "A's DELETE must not have removed B's action")
require.Equal(t, 1, bDeliveries, "A's DELETE must not have removed B's delivery")
require.Equal(t, 1, bConnections, "A's writes must not have touched B's connection")
var bRequested bool
require.NoError(t, super.QueryRow(ctx,
`SELECT summarize_requested FROM videos WHERE user_id = $1`, b.userID).Scan(&bRequested))
require.False(t, bRequested, "A scoped must not have queued B's video for summarization")
_ = a // a's ids are seeded for the symmetric read assertions above
}