Files
bookhoard/internal/database/scoped.go
T
John O'Keefe f4b581872f wip(rls): ScopedDB + rls package + schema policies + echo bridge [pin compiling state]
Phase 3 of the RLS plan, pinned mid-flight because a parallel writer
was actively editing cmd/server/tests/rls_visibility_test.go (A14):
the file flip-flopped between two implementations and gained a compile
error after the previous commit. This snapshot makes the whole tree
compile; functional verification (two-boot gate, invariant green,
full suite) lands in the next commit(s).

- internal/rls: typed ctx keys — user identity (bridged from echo by
  the router) and an INTERNAL marker whose semantics grant nothing:
  no GUC is declared, so policy'd tables still raise at the engine;
  only RLS-ungoverned tables (system_settings, users counts) work for
  user-less startup paths (settings registry Load, CountAdmins).
- internal/database/scoped.go: DBTX decorator — per-statement tx,
  commit-on-Close (:many) / commit-on-Scan (:one), error paths always
  terminate the tx, refusal before SQL when no identity and not
  internal.
- database/schema/schema.sql: idempotent policy block (DROP IF EXISTS
  + CREATE — schema.sql re-runs every boot): ENABLE + FORCE, read
  policy exactly mirroring COALESCE(is_visible,true), permissive
  per-command write stand-ins (FOR ALL would OR into SELECT).
- router.go: RLS bridge at BOTH c.Set("user") sites (JWT + device
  auth) — echo-local storage never reaches the stdlib ctx.
- main.go + test harness: database.New over ScopedDB at both wiring
  sites; startup reads use rls.WithInternal.
2026-10-29 08:11:20 -04:00

155 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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}
}