feat(db): RLS library-visibility enforcement — invariant GREEN

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.
This commit is contained in:
John O'Keefe committed 2026-10-29 08:56:05 -04:00
1 parent 8c3d919aa0
commit 922b92a8b6
5 files changed
+6 -13

No files matched your search

@@ -2,7 +2,6 @@ package main
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "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 { func createPreviewTestMediaItems(t *testing.T, db *database.Queries, libraryID string, count int) []string {
ctx := context.Background()
libUUID, err := uuid.Parse(libraryID) libUUID, err := uuid.Parse(libraryID)
require.NoError(t, err, "Should parse library ID") require.NoError(t, err, "Should parse library ID")
@@ -318,7 +318,6 @@ func mergeParams(base, extra url.Values) url.Values {
// "Baen Books") cross-match at 0.3+ (session gotcha). // "Baen Books") cross-match at 0.3+ (session gotcha).
func facetFixtureBooks(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID, prefix string) { func facetFixtureBooks(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID, prefix string) {
t.Helper() t.Helper()
ctx := context.Background()
books := []struct { books := []struct {
title string title string
genre string genre string
-2
View File
@@ -526,7 +526,6 @@ func TestReadingStatsWeekLibraryScope(t *testing.T) {
func TestLibraryUsageStats(t *testing.T) { func TestLibraryUsageStats(t *testing.T) {
setup := setupTestServer(t) setup := setupTestServer(t)
userID := getRegularUserID(t, setup.DB) userID := getRegularUserID(t, setup.DB)
ctx := context.Background()
ebooksID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID, ebooksID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID,
fmt.Sprintf("Test Usage Ebooks %d", time.Now().UnixNano()), "ebooks")) 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) { func TestLibraryUsageStats_Visibility(t *testing.T) {
setup := setupTestServer(t) setup := setupTestServer(t)
userID := getRegularUserID(t, setup.DB) userID := getRegularUserID(t, setup.DB)
ctx := context.Background()
visibleID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID, visibleID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID,
fmt.Sprintf("Test Usage Visible %d", time.Now().UnixNano()), "ebooks")) fmt.Sprintf("Test Usage Visible %d", time.Now().UnixNano()), "ebooks"))
@@ -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 { func createSyncTestMedia(t *testing.T, db *database.Queries, libraryID pgtype.UUID) pgtype.UUID {
ctx := context.Background()
media, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{ media, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: libraryID, LibraryID: libraryID,
+6 -6
View File
@@ -509,14 +509,14 @@ func setupTestServer(t *testing.T) *TestServerSetup {
// ScopedDB: the test server must behave EXACTLY like the production // ScopedDB: the test server must behave EXACTLY like the production
// wiring (main.go) — every statement through Queries declares the // wiring (main.go) — every statement through Queries declares the
// acting user for RLS, including the search service's raw DBTX path. // acting user for RLS, including the search service's raw DBTX path.
// TestServerSetup.DB below is a SEPARATE raw-pool handle used ONLY // TestServerSetup.DB below is the SEED handle — also ScopedDB-wired
// for harness seeding/cleanup: write policies are permissive (no GUC // (required: sqlc :one inserts use RETURNING, which evaluates the
// read) and every seeded table except media_items is RLS-ungoverned, // SELECT policy even on INSERT) — but its callers pass seedCtx(), a
// so seeding needs no identity; direct media SELECTs through it // synthetic identity with no visibility rows, so seeding sees and
// raise loudly, which is correct — tests read media through HTTP. // writes everything. Real personas only exist at the HTTP layer.
scopedDB := database.NewScopedDB(dbPool) scopedDB := database.NewScopedDB(dbPool)
queries := database.New(scopedDB) queries := database.New(scopedDB)
seedDB := database.New(dbPool) seedDB := database.New(database.NewScopedDB(dbPool))
// Wire the tunable settings registry exactly like main.go: Load // Wire the tunable settings registry exactly like main.go: Load
// populates the cache (missing rows fall back to compiled defaults) // populates the cache (missing rows fall back to compiled defaults)