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.
81 lines
3.2 KiB
Go
81 lines
3.2 KiB
Go
// 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
|
|
}
|