Files
tapir/internal/adapters/store/rls_test.go
T
mathiasandClaude Opus 4.8 0c9531a9b8 feat(store): video_connections table + RLS + connection store methods
Add the video_connections table (data-model VIDEO_CONNECTION) with FORCE
row-level security keyed off tapir.current_user_id, identical to migration
003's per-user isolation pattern — connections are user-owned data and must
be isolated at the DB layer, not only by application WHERE clauses.

Store methods (UpsertConnection / ConnectionsForUser / DeleteConnection) all
route through withUser so RLS scopes every access. UpsertConnection is
idempotent on (user_id, provider). The OAuth refresh token never lives here;
token_ref is the opaque SecretStore reference.

Extend the RLS isolation proof to cover video_connections: seeded per user,
included in the deny-all + scoped-read assertions, and added to the
cross-user write-invisibility and survivor checks.

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

223 lines
9.3 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},
{"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")
_ = a // a's ids are seeded for the symmetric read assertions above
}