The full suite enumerated the remaining triage class: tests drive the scanner directly (ScanFolders(context.Background())) and read seeded media via Queries with bare contexts — every governed read raised. - MediaScanner.ScanFolders self-declares its configured admin (SetAdminID — set by main AND the test helpers): a scan is server maintenance, product-correct regardless of caller. - ScopedDB gains a Fallback identity, exposed as NewSeedDB for the TEST HARNESS ONLY: setup.DB falls back to rls.SeedIdentity when the context carries none, so direct seeding/verification helpers work uniformly instead of per-site context threading. rls.SeedIdentity is the shared constant (no library_visibility rows => full visibility). - generic job creation (jobs.go) stamps the requesting admin into the job Context, same as the scan endpoint. The sync queue processor stays identity-free: sync_queue is not an RLS-governed table.
164 lines
5.3 KiB
Go
164 lines
5.3 KiB
Go
// 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}
|
|
}
|