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
+8
-3
@@ -7,6 +7,7 @@ import (
|
||||
"bookhoard/internal/handlers"
|
||||
"bookhoard/internal/middleware"
|
||||
ratelimit "bookhoard/internal/middleware"
|
||||
"bookhoard/internal/rls"
|
||||
"bookhoard/internal/router"
|
||||
"bookhoard/internal/services"
|
||||
"bookhoard/internal/sync"
|
||||
@@ -41,7 +42,7 @@ func main() {
|
||||
}
|
||||
defer dbPool.Close()
|
||||
|
||||
queries := database.New(dbPool)
|
||||
queries := database.New(database.NewScopedDB(dbPool))
|
||||
|
||||
// Initialize database schema
|
||||
log.Println("🔧 Ensuring database schema is initialized...")
|
||||
@@ -53,8 +54,10 @@ func main() {
|
||||
|
||||
// Load tunable settings from the DB into the registry. All values fall back
|
||||
// to compiled defaults if a row is missing, so this never blocks startup.
|
||||
// INTERNAL context: startup has no end-user identity; system_settings
|
||||
// is not RLS-governed, so the statement runs without the GUC (plan §5).
|
||||
registry := database.NewSettingsRegistry(queries)
|
||||
if err := registry.Load(ctx); err != nil {
|
||||
if err := registry.Load(rls.WithInternal(ctx)); err != nil {
|
||||
log.Printf("⚠️ Could not load system settings (using defaults): %v", err)
|
||||
}
|
||||
|
||||
@@ -65,7 +68,9 @@ func main() {
|
||||
if registry.RegistrationEnabled() {
|
||||
return true
|
||||
}
|
||||
count, err := queries.CountAdmins(context.Background())
|
||||
// INTERNAL: a "how many admins exist" read is not user-scoped;
|
||||
// users is not RLS-governed (plan §5).
|
||||
count, err := queries.CountAdmins(rls.WithInternal(context.Background()))
|
||||
if err != nil {
|
||||
return true // fail open, matching setupstatus
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package tests
|
||||
package main
|
||||
|
||||
// TestRlsInvariant — the PERMANENT black-box auditor for per-user
|
||||
// rls_visibility_test.go — the PERMANENT black-box auditor for per-user
|
||||
// library visibility (the "family lens").
|
||||
//
|
||||
// The one sentence it asserts, in the user's language:
|
||||
@@ -93,11 +93,12 @@ func TestRlsInvariant(t *testing.T) {
|
||||
|
||||
// Hide libH from the REGULAR user — the same row the admin
|
||||
// visibility UI writes (harness idiom: SetLibraryVisibility).
|
||||
require.NoError(t, setup.DB.SetLibraryVisibility(ctx, database.SetLibraryVisibilityParams{
|
||||
_, err := setup.DB.SetLibraryVisibility(ctx, database.SetLibraryVisibilityParams{
|
||||
UserID: pgtype.UUID{Bytes: [16]byte(regularID), Valid: true},
|
||||
LibraryID: libH.ID,
|
||||
IsVisible: false,
|
||||
}), "hide libH from regular user")
|
||||
})
|
||||
require.NoError(t, err, "hide libH from regular user")
|
||||
|
||||
// OPDS device owned by the REGULAR user (OPDS identity = device owner).
|
||||
opdsToken := "dev_" + uuid.New().String()
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"bookhoard/internal/handlers"
|
||||
"bookhoard/internal/middleware"
|
||||
ratelimit "bookhoard/internal/middleware"
|
||||
"bookhoard/internal/rls"
|
||||
"bookhoard/internal/router"
|
||||
"bookhoard/internal/services"
|
||||
wsync "bookhoard/internal/sync"
|
||||
@@ -494,7 +495,12 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
dbPool, err := pgxpool.NewWithConfig(context.Background(), dbConfig)
|
||||
require.NoError(t, err, "Failed to connect to test database")
|
||||
|
||||
queries := database.New(dbPool)
|
||||
// ScopedDB: the test server must behave EXACTLY like the production
|
||||
// wiring (main.go) — every statement through Queries declares the
|
||||
// acting user for RLS. Helper seeding that must bypass user scoping
|
||||
// uses direct pool writes (permissive write policies) or
|
||||
// rls.WithInternal for non-policy'd tables.
|
||||
queries := database.New(database.NewScopedDB(dbPool))
|
||||
|
||||
// Wire the tunable settings registry exactly like main.go: Load
|
||||
// populates the cache (missing rows fall back to compiled defaults)
|
||||
@@ -503,7 +509,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
// initialized") — tests that write settings through the API (the
|
||||
// only sanctioned path) need this wired.
|
||||
settingsRegistry := database.NewSettingsRegistry(queries)
|
||||
if err := settingsRegistry.Load(context.Background()); err != nil {
|
||||
if err := settingsRegistry.Load(rls.WithInternal(context.Background())); err != nil {
|
||||
t.Logf("settings registry Load failed (defaults apply): %v", err)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user