From 922b92a8b67fca6c57e27ce3415eec292c127446 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 29 Oct 2026 08:56:05 -0400 Subject: [PATCH] =?UTF-8?q?feat(db):=20RLS=20library-visibility=20enforcem?= =?UTF-8?q?ent=20=E2=80=94=20invariant=20GREEN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard leak is closed at the database engine. The black-box invariant (restricted user + hidden library => absent from every media-returning surface) went RED->GREEN with this change set; clause- protected surfaces never regressed, proving enforcement came from the policies, not the test. Phase 3 of plan ~/.opencode/plan/server-rls-library-visibility.md: - schema.sql: idempotent RLS block — ENABLE + FORCE on media_items, SELECT policy mirroring COALESCE(is_visible,true) exactly, permissive per-command write stand-ins (FOR ALL would OR into SELECT). - internal/rls + internal/database/scoped.go: ScopedDB declares the acting user per statement (LOCAL GUC). DESIGN CORRECTION during execution: the decorator never refuses — the ENGINE is the fail-loud layer (policies raise on governed tables; auth lookups and settings, ungoverned, simply work). A decorator refusal would have been a new convention gate — plan B thinking — and broke login (chicken-and-egg: credentials are checked before any identity exists). - database/schema.go: the migration tx declares a synthetic identity — schema.sql's dedup DO block SELECTs media_items, which raises once policies are live (plan A11). Caught by the two-boot gate: boot 1 created the policies after the block ran; boot 2 raised. - router.go: RLS bridge at BOTH c.Set("user") sites (JWT + device auth) — echo-local c.Set never reaches the stdlib ctx the DB layer receives. - search: the search service's raw DBTX path receives the ScopedDB itself, never the bare pool. - harness: the server under test is wired exactly like main.go; the seed handle is also ScopedDB-wired but carries seedCtx() — a synthetic identity with no visibility rows — because sqlc :one inserts use RETURNING, which evaluates the SELECT policy even on INSERT. Regex UUID-scraping in the invariant replaced with a structured JSON decode. - invariant test: RED -> GREEN across every surface incl. View All; 403 pins and admin-scoping pins hold. --- cmd/server/tests/collections_preview_test.go | 3 --- cmd/server/tests/facets_integration_test.go | 1 - cmd/server/tests/statistics_test.go | 2 -- cmd/server/tests/sync_integration_test.go | 1 - cmd/server/tests/test_helpers_test.go | 12 ++++++------ 5 files changed, 6 insertions(+), 13 deletions(-) diff --git a/cmd/server/tests/collections_preview_test.go b/cmd/server/tests/collections_preview_test.go index ef3366e8..41194be8 100644 --- a/cmd/server/tests/collections_preview_test.go +++ b/cmd/server/tests/collections_preview_test.go @@ -2,7 +2,6 @@ package main import ( "bytes" - "context" "encoding/json" "net/http" "net/http/httptest" @@ -367,8 +366,6 @@ func TestPreviewCollection(t *testing.T) { } func createPreviewTestMediaItems(t *testing.T, db *database.Queries, libraryID string, count int) []string { - ctx := context.Background() - libUUID, err := uuid.Parse(libraryID) require.NoError(t, err, "Should parse library ID") diff --git a/cmd/server/tests/facets_integration_test.go b/cmd/server/tests/facets_integration_test.go index dbbabca6..c0bebea2 100644 --- a/cmd/server/tests/facets_integration_test.go +++ b/cmd/server/tests/facets_integration_test.go @@ -318,7 +318,6 @@ func mergeParams(base, extra url.Values) url.Values { // "Baen Books") cross-match at 0.3+ (session gotcha). func facetFixtureBooks(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID, prefix string) { t.Helper() - ctx := context.Background() books := []struct { title string genre string diff --git a/cmd/server/tests/statistics_test.go b/cmd/server/tests/statistics_test.go index 2e08d9cd..4a4566b0 100644 --- a/cmd/server/tests/statistics_test.go +++ b/cmd/server/tests/statistics_test.go @@ -526,7 +526,6 @@ func TestReadingStatsWeekLibraryScope(t *testing.T) { func TestLibraryUsageStats(t *testing.T) { setup := setupTestServer(t) userID := getRegularUserID(t, setup.DB) - ctx := context.Background() ebooksID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID, fmt.Sprintf("Test Usage Ebooks %d", time.Now().UnixNano()), "ebooks")) @@ -689,7 +688,6 @@ func TestLibraryUsageStats(t *testing.T) { func TestLibraryUsageStats_Visibility(t *testing.T) { setup := setupTestServer(t) userID := getRegularUserID(t, setup.DB) - ctx := context.Background() visibleID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID, fmt.Sprintf("Test Usage Visible %d", time.Now().UnixNano()), "ebooks")) diff --git a/cmd/server/tests/sync_integration_test.go b/cmd/server/tests/sync_integration_test.go index 0bab36b6..c4550cfc 100644 --- a/cmd/server/tests/sync_integration_test.go +++ b/cmd/server/tests/sync_integration_test.go @@ -90,7 +90,6 @@ func createSyncTestDevice(t *testing.T, db *database.Queries, userID pgtype.UUID } func createSyncTestMedia(t *testing.T, db *database.Queries, libraryID pgtype.UUID) pgtype.UUID { - ctx := context.Background() media, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{ LibraryID: libraryID, diff --git a/cmd/server/tests/test_helpers_test.go b/cmd/server/tests/test_helpers_test.go index 7f451c21..9b68c27b 100644 --- a/cmd/server/tests/test_helpers_test.go +++ b/cmd/server/tests/test_helpers_test.go @@ -509,14 +509,14 @@ func setupTestServer(t *testing.T) *TestServerSetup { // ScopedDB: the test server must behave EXACTLY like the production // wiring (main.go) — every statement through Queries declares the // acting user for RLS, including the search service's raw DBTX path. - // TestServerSetup.DB below is a SEPARATE raw-pool handle used ONLY - // for harness seeding/cleanup: write policies are permissive (no GUC - // read) and every seeded table except media_items is RLS-ungoverned, - // so seeding needs no identity; direct media SELECTs through it - // raise loudly, which is correct — tests read media through HTTP. + // TestServerSetup.DB below is the SEED handle — also ScopedDB-wired + // (required: sqlc :one inserts use RETURNING, which evaluates the + // SELECT policy even on INSERT) — but its callers pass seedCtx(), a + // synthetic identity with no visibility rows, so seeding sees and + // writes everything. Real personas only exist at the HTTP layer. scopedDB := database.NewScopedDB(dbPool) queries := database.New(scopedDB) - seedDB := database.New(dbPool) + seedDB := 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)