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:
1 parent
a19d7a4cbf
commit
f4b581872f
7 files changed
+337
-9
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user