// ScopedDB wraps a pgx pool so every sqlc statement runs inside a // transaction that declares the acting user via the LOCAL // `app.user_id` GUC — the one thing the RLS policies // (database/schema/schema.sql) need to filter media rows per user. // // Fail-loud lives in the ENGINE, not here: a context with no identity // simply declares nothing, and any RLS-governed statement (media_items) // then raises on the unset GUC — while ungoverned tables (auth lookups, // settings, sessions) keep working, as they have no policy. That is the // plan-C property: the database is the one door everything crosses; the // decorator is only the identity declarer. // // SEMANTICS PRESERVED: every sqlc call was already a single // auto-committed statement; one transaction per call keeps per-call // atomicity identical. pgx v5 rows are bound to their transaction, so // the transaction ends when the ROWS end: commit-on-Close for :many // results, commit-on-Scan for :one results, plain commit for Exec. // // Rollout plan: ~/.opencode/plan/server-rls-library-visibility.md. package database import ( "context" "fmt" "log" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "bookhoard/internal/rls" ) // Compile-pin: ScopedDB is a drop-in DBTX for database.New. var _ DBTX = (*ScopedDB)(nil) // ScopedDB implements DBTX over a pool, declaring the acting user for // the RLS policies on every statement. Fallback (test harness ONLY — // see NewSeedDB) supplies an identity when the context carries none. type ScopedDB struct { Pool *pgxpool.Pool Fallback *pgtype.UUID } // NewScopedDB is the constructor used at the single Queries // construction site (cmd/server/main.go). func NewScopedDB(pool *pgxpool.Pool) *ScopedDB { return &ScopedDB{Pool: pool} } // NewSeedDB returns a ScopedDB for TEST-HARNESS seeding/verification: // when the context carries no identity it falls back to // rls.SeedIdentity, so direct media reads/writes from helpers work // without impersonating a real user. Never use in product wiring. func NewSeedDB(pool *pgxpool.Pool) *ScopedDB { seed := rls.SeedIdentity return &ScopedDB{Pool: pool, Fallback: &seed} } const zeroUUID = "00000000-0000-0000-0000-000000000000" // begin opens the per-statement transaction. If the context carries a // user identity, the GUC is declared (LOCAL); if not, NOTHING is // declared and the ENGINE decides: RLS-governed tables (media_items) // raise on the unset GUC — fail-loud at the database, the layer plan C // exists to enforce at — while RLS-ungoverned tables (users, settings, // sessions, auth lookups pre-bridge) simply work, as they have no // policy to evaluate. The decorator therefore never refuses: refusing // here would re-create a convention gate in code, one more door future // paths could forget — the engine is the one door everything crosses. func (s *ScopedDB) begin(ctx context.Context) (pgx.Tx, error) { tx, err := s.Pool.Begin(ctx) if err != nil { return nil, err } uid, ok := rls.FromContext(ctx) if (!ok || !uid.Valid || uid.String() == zeroUUID) && s.Fallback != nil { uid, ok = *s.Fallback, true } if ok && uid.Valid && uid.String() != zeroUUID { if err := rls.DeclareUser(ctx, tx, uid); err != nil { _ = tx.Rollback(ctx) return nil, fmt.Errorf("rls: declare user: %w", err) } } return tx, nil } func (s *ScopedDB) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) { tx, err := s.begin(ctx) if err != nil { return pgconn.CommandTag{}, err } // Rollback after a successful Commit is a no-op (ErrTxClosed ignored): // this defer only acts on the error paths. defer func() { _ = tx.Rollback(ctx) }() tag, err := tx.Exec(ctx, sql, args...) if err != nil { return pgconn.CommandTag{}, err } return tag, tx.Commit(ctx) } func (s *ScopedDB) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) { tx, err := s.begin(ctx) if err != nil { return nil, err } rows, err := tx.Query(ctx, sql, args...) if err != nil { _ = tx.Rollback(ctx) return nil, err } return &scopedRows{Rows: rows, tx: tx, ctx: ctx}, nil } // scopedRows commits the transaction when the rows close — committing // earlier would kill the rows mid-read. sqlc's :many path fully // consumes rows then Close()s. pgx v5 Rows.Close returns nothing, so a // commit error is logged, not propagated (a commit failure on a // read-only transaction is log-worthy only). type scopedRows struct { pgx.Rows tx pgx.Tx ctx context.Context } func (r *scopedRows) Close() { r.Rows.Close() if err := r.tx.Commit(r.ctx); err != nil { log.Printf("rls: tx commit after rows: %v", err) } } // scopedRow terminates the transaction on EVERY Scan path for sqlc's // :one results (pgx.Row is just { Scan(dest ...) error }). type scopedRow struct { row pgx.Row tx pgx.Tx ctx context.Context } func (r scopedRow) Scan(dest ...any) error { err := r.row.Scan(dest...) if err == nil { return r.tx.Commit(r.ctx) } _ = r.tx.Rollback(r.ctx) // best effort; the tx ends either way return err } type errRow struct{ err error } func (r errRow) Scan(_ ...any) error { return r.err } func (s *ScopedDB) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row { tx, err := s.begin(ctx) if err != nil { return errRow{err} } return scopedRow{row: tx.QueryRow(ctx, sql, args...), tx: tx, ctx: ctx} }