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.
56 lines
2.2 KiB
Go
56 lines
2.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
|
|
|
|
// CtxKeyUserID is the context key the auth middleware bridge sets and
|
|
// ScopedDB reads. Typed key — no string-key collisions.
|
|
const CtxKeyUserID ctxKey = iota
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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
|
|
}
|