// 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, twice: a query whose context carries neither a user // identity nor the internal marker is refused HERE, and the policies // themselves raise on an unset/empty GUC. The internal marker // (rls.WithInternal) declares "not a user" — it sets NO GUC, so // policy'd tables (media_items) still raise at the engine; only // RLS-ungoverned tables (system_settings, users counts) work, which // user-less startup paths need. A code path that forgets the auth // middleware bridge breaks visibly; it never silently sees all rows. // // 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/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. type ScopedDB struct { Pool *pgxpool.Pool } // 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} } const zeroUUID = "00000000-0000-0000-0000-000000000000" // begin opens the per-statement transaction. Identity semantics: // - user context → declares app.user_id (LOCAL) — RLS filters rows // - internal ctx → NO GUC declared. Grants nothing: policy'd tables // still raise at the engine (FORCE applies to the owner); only // RLS-ungoverned tables (system_settings, users, …) are workable — // which is exactly what user-less startup paths need. // - neither → refused HERE (fail-loud before SQL is even sent) func (s *ScopedDB) begin(ctx context.Context) (pgx.Tx, error) { if !rls.IsInternal(ctx) { uid, ok := rls.FromContext(ctx) if !ok || !uid.Valid || uid.String() == zeroUUID { return nil, fmt.Errorf("rls: no authenticated user in context — query refused " + "(route through the auth middleware bridge, or declare an internal " + "identity via rls.WithUser; see the RLS plan §5–6)") } tx, err := s.Pool.Begin(ctx) if err != nil { return nil, err } if err := rls.DeclareUser(ctx, tx, uid); err != nil { _ = tx.Rollback(ctx) return nil, fmt.Errorf("rls: declare user: %w", err) } return tx, nil } return s.Pool.Begin(ctx) } 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} }