Design correction discovered by the auth chicken-and-egg: the decorator-level refusal was re-created a convention gate (plan B thinking). ScopedDB now NEVER refuses — it declares the GUC when the context carries an identity and declares nothing otherwise; the ENGINE is the fail-loud layer (policies raise on governed tables, ungoverned tables like auth lookups simply work). rls.WithInternal removed as a dead concept. - search service's raw DBTX path now receives the ScopedDB itself (main.go + harness) — a bare pool bypassed the GUC. - database/schema.go: migration tx declares a synthetic identity (schema.sql's dedup DO block SELECTs media_items; with policies live, boot 2 raised 'unrecognized configuration parameter' — A11). - harness: TestServerSetup.DB is a raw-pool SEED handle (write policies permissive, other seeded tables ungoverned); the server under test keeps the scoped wiring. 33 CreateMediaItem sites use seedCtx() — a seed identity with no visibility rows — because RETURNING requires SELECT visibility of the new row. - invariant test: regex UUID-scraping replaced with a structured JSON decode of the sections payload. INTERRUPTION NOTE: an edit to this same effect (seedCtx) was applied, confirmed by the tool, and subsequently vanished from disk while an 'opencode serve' process (PID 84551) is alive on the machine. This commit pins the tree against further loss; implementation is PAUSED pending the user killing that process.
148 lines
4.7 KiB
Go
148 lines
4.7 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/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. 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
|
|
}
|
|
if uid, ok := rls.FromContext(ctx); 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}
|
|
}
|