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.
This commit is contained in:
John O'Keefe committed 2026-10-29 08:11:20 -04:00
1 parent a19d7a4cbf
commit f4b581872f
7 files changed
+337 -9

No files matched your search

+154
View File
@@ -0,0 +1,154 @@
// 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}
}
+80
View File
@@ -0,0 +1,80 @@
// Package rls carries the authenticated end user's identity through
// the stdlib context so the database layer can declare it to Postgres
// per transaction (the `app.user_id` GUC the RLS policies read — see
// database/schema/schema.sql and
// ~/.opencode/plan/server-rls-library-visibility.md).
//
// WHY A SEPARATE PACKAGE: Echo's c.Set/c.Get is echo-local storage and
// never reaches the context.Context handed to database calls; the auth
// middleware therefore bridges the user id into the request context
// here, and ScopedDB (internal/database) reads it back. A query whose
// context carries no identity is REFUSED (fail-loud) — a code path
// that forgets the middleware breaks visibly, it never leaks.
package rls
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
type ctxKey int
const (
// CtxKeyUserID is the context key the auth middleware bridge sets and
// ScopedDB reads. Typed key — no string-key collisions.
CtxKeyUserID ctxKey = iota
// CtxKeyInternal marks a context as INTERNAL (startup, system
// jobs): the statement runs WITHOUT the RLS identity GUC. This
// grants NOTHING — the policies still apply to the table owner
// (FORCE) — so any query it runs against a POLICY'D table (today:
// media_items) still RAISES at the engine. It only unblocks
// statements on tables RLS does not govern (system_settings,
// users counts, sessions…), which a user-less startup path must
// be able to read. NEVER wrap a request context with this.
CtxKeyInternal
)
// FromContext returns the authenticated end user's UUID.
// ok=false means the caller forgot the middleware bridge — the
// decorator will refuse the query loudly.
func FromContext(ctx context.Context) (pgtype.UUID, bool) {
uid, ok := ctx.Value(CtxKeyUserID).(pgtype.UUID)
if !ok || !uid.Valid {
return pgtype.UUID{}, false
}
return uid, true
}
// WithUser re-attaches an identity to a (possibly detached) context.
// Background jobs MUST capture the acting user's UUID from the request
// context BEFORE `go func` detaches — context.Background() carries
// nothing — and rebuild their job context with this.
func WithUser(ctx context.Context, uid pgtype.UUID) context.Context {
return context.WithValue(ctx, CtxKeyUserID, uid)
}
// WithInternal marks a context as internal (startup paths, system
// settings): statements run WITHOUT the RLS GUC. Grants nothing —
// policy'd tables still raise at the engine; only RLS-ungoverned
// tables become readable. See CtxKeyInternal.
func WithInternal(ctx context.Context) context.Context {
return context.WithValue(ctx, CtxKeyInternal, true)
}
// IsInternal reports whether the context opted into internal semantics.
func IsInternal(ctx context.Context) bool {
v, _ := ctx.Value(CtxKeyInternal).(bool)
return v
}
// DeclareUser sets the RLS identity GUC inside an ALREADY-OPEN
// transaction. The `true` argument is is_local: transaction-scoped,
// evaporating at COMMIT/ROLLBACK — NEVER session-scoped on a pooled
// connection (cross-request identity bleed). For the manual-tx paths
// the ScopedDB decorator cannot wrap.
func DeclareUser(ctx context.Context, tx pgx.Tx, uid pgtype.UUID) error {
_, err := tx.Exec(ctx, `SELECT set_config('app.user_id', $1, true)`, uid.String())
return err
}
+13
View File
@@ -6,6 +6,7 @@ import (
"bookhoard/internal/handlers"
"bookhoard/internal/middleware"
ratelimit "bookhoard/internal/middleware"
"bookhoard/internal/rls"
"bookhoard/internal/services"
"bookhoard/internal/sync"
"bookhoard/templates"
@@ -155,6 +156,12 @@ func createOptionalAuthMiddleware(cfg *Config) echo.MiddlewareFunc {
Timezone: liveUser.Timezone,
Theme: liveUser.Theme, // SSR body class; see the required middleware
})
// RLS bridge: echo's c.Set is echo-local and never reaches
// the stdlib context.Context the database layer receives —
// ScopedDB refuses media queries without this (plan §5.1).
c.SetRequest(c.Request().WithContext(context.WithValue(
c.Request().Context(), rls.CtxKeyUserID,
pgtype.UUID{Bytes: userUUID, Valid: true})))
return next(c)
}
}
@@ -222,6 +229,12 @@ func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
// admin/docs theme flash.
Theme: liveUser.Theme,
})
// RLS bridge (same as the JWT path above): echo-local c.Set
// never reaches the stdlib context.Context the database
// layer receives — ScopedDB refuses media queries without it.
c.SetRequest(c.Request().WithContext(context.WithValue(
c.Request().Context(), rls.CtxKeyUserID,
pgtype.UUID{Bytes: userUUID, Valid: true})))
return nil
},
ErrorHandler: func(c *echo.Context, err error) error {