test(visibility): full suite green under RLS — sync paths, device-auth bridge, seed pool

Phase 5 (plan §7): the entire integration suite passes with the RLS
policies live. The suite was the triage enumerator the plan predicted:

- device_auth middleware: the THIRD identity bridge — KOReader/Kobo
  device-authenticated requests read media as the DEVICE OWNER
  (plan A9 called this path out; the flip was 404s on sync metadata).
- conflicts/analytics/progress handlers: detached
  context.Background() reads threaded to the request context (the
  requesting user's identity) — 12+ sites; resolve helpers take ctx.
- scanner: ScanFolders self-declares its configured admin identity
  (server maintenance, product-correct for any caller); generic job
  creation stamps the requesting admin.
- harness: setup.DBPool and the sync-test pool declare
  rls.SeedIdentity at SESSION scope via BeforeAcquire — the pool never
  serves a second identity, so no A3 bleed; setup.DB falls back to the
  seed identity (NewSeedDB) for ungoverned-context calls; filter tests
  use seedExec for raw governed-table statements.
- contract pin WIDENED (docs/contract_pins.md 2026-10-29): under RLS,
  item-scoped gates may answer 404 (row invisible — no existence leak)
  as well as 403 (Go-level checks on listings keep strict 403).
- TestWorker_JobStatusTracking — the pre-existing red from the Phase 0
  baseline — passes with the harness seed work; no product change.
This commit is contained in:
John O'Keefe committed 2026-10-29 10:14:08 -04:00
1 parent bb2cc26e9d
commit 4d697f5fcf
7 files changed
+84 -38

No files matched your search

+6 -1
View File
@@ -289,7 +289,12 @@ func TestAudiobookTrackEndpoints(t *testing.T) {
resp, err := client.Do(req) resp, err := client.Do(req)
require.NoError(t, err) require.NoError(t, err)
_ = resp.Body.Close() _ = resp.Body.Close()
require.Equal(t, http.StatusForbidden, resp.StatusCode, "%s must be gated", path) // PIN WIDENED for RLS (docs/contract_pins.md 2026-10-29): the
// handler's own Go-level gate still answers 403, but under
// engine-level visibility the hidden row is simply ABSENT —
// 404 is equally gated (no existence leak). Both accepted.
require.True(t, resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusNotFound,
"%s must be gated (403 or 404), got %d", path, resp.StatusCode)
} }
// Restore visibility so harness cleanup ordering never sees a // Restore visibility so harness cleanup ordering never sees a
+15 -28
View File
@@ -267,11 +267,9 @@ func TestFilterComposedSemantics(t *testing.T) {
assert.Len(t, got, 4, "mime_type is") assert.Len(t, got, 4, "mime_type is")
// Series full membership: the alternate_info series matches too. // Series full membership: the alternate_info series matches too.
ctx := context.Background() seedExec(t, setup.DBPool,
_, err := setup.DBPool.Exec(ctx,
`UPDATE media_items SET alternate_info = '{"alternate_series":"Quill Chronicles"}'::jsonb `UPDATE media_items SET alternate_info = '{"alternate_series":"Quill Chronicles"}'::jsonb
WHERE title = $1`, "Sem Fen") WHERE title = $1`, "Sem Fen")
require.NoError(t, err)
got = titles(qfAll(qfRule("series", "contains", "Quill Chron"))) got = titles(qfAll(qfRule("series", "contains", "Quill Chron")))
assert.Equal(t, []string{"Sem Fen"}, got, "series matches alternate_info membership") assert.Equal(t, []string{"Sem Fen"}, got, "series matches alternate_info membership")
got = titles(qfAll(qfRule("series", "is", "Quill Chronicles"))) got = titles(qfAll(qfRule("series", "is", "Quill Chronicles")))
@@ -327,43 +325,34 @@ func TestFilterUserRelativeFields(t *testing.T) {
// Reader A: reading Tide (50%), finished Stone, rated Tide 8, // Reader A: reading Tide (50%), finished Stone, rated Tide 8,
// reviewed Fen, noted on Tide ("the catharsis scene"), highlighted // reviewed Fen, noted on Tide ("the catharsis scene"), highlighted
// Stone, time on Wick (2h), collection holding Tide. // Stone, time on Wick (2h), collection holding Tide.
_, err := setup.DBPool.Exec(ctx, seedExec(t, setup.DBPool,
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1,$2,0.5, now()), ($3,$4,1.0, now())`, `INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1,$2,0.5, now()), ($3,$4,1.0, now())`,
tideID, aID, stoneID, aID) tideID, aID, stoneID, aID)
require.NoError(t, err) seedExec(t, setup.DBPool,
_, err = setup.DBPool.Exec(ctx,
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1,$2,1.0, now())`, `INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1,$2,1.0, now())`,
tideID, bID) tideID, bID)
require.NoError(t, err) seedExec(t, setup.DBPool,
_, err = setup.DBPool.Exec(ctx,
`INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,8)`, tideID, aID) `INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,8)`, tideID, aID)
require.NoError(t, err) seedExec(t, setup.DBPool,
_, err = setup.DBPool.Exec(ctx,
`INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,3)`, tideID, bID) `INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,3)`, tideID, bID)
require.NoError(t, err) seedExec(t, setup.DBPool,
_, err = setup.DBPool.Exec(ctx,
`INSERT INTO media_reviews (media_item_id, user_id, review) VALUES ($1,$2,'splendid')`, fenID, aID) `INSERT INTO media_reviews (media_item_id, user_id, review) VALUES ($1,$2,'splendid')`, fenID, aID)
require.NoError(t, err) seedExec(t, setup.DBPool,
_, err = setup.DBPool.Exec(ctx,
`INSERT INTO media_notes (media_item_id, user_id, content) VALUES ($1,$2,'remember the catharsis scene')`, tideID, aID) `INSERT INTO media_notes (media_item_id, user_id, content) VALUES ($1,$2,'remember the catharsis scene')`, tideID, aID)
require.NoError(t, err) seedExec(t, setup.DBPool,
_, err = setup.DBPool.Exec(ctx,
`INSERT INTO media_highlights (media_item_id, user_id, selection_text) VALUES ($1,$2,'the dark forest road')`, stoneID, aID) `INSERT INTO media_highlights (media_item_id, user_id, selection_text) VALUES ($1,$2,'the dark forest road')`, stoneID, aID)
require.NoError(t, err) seedExec(t, setup.DBPool,
_, err = setup.DBPool.Exec(ctx,
`INSERT INTO reading_history (user_id, media_item_id, time_spent_seconds, pages_read) VALUES ($1,$2,7200,120), ($3,$4,600,30)`, `INSERT INTO reading_history (user_id, media_item_id, time_spent_seconds, pages_read) VALUES ($1,$2,7200,120), ($3,$4,600,30)`,
aID, wickID, aID, tideID) aID, wickID, aID, tideID)
require.NoError(t, err)
var collID pgtype.UUID var collID pgtype.UUID
err = setup.DBPool.QueryRow(ctx, err := setup.DBPool.QueryRow(context.Background(),
`INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`, `INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`,
aID, fmt.Sprintf("Test Builder Coll %d", time.Now().UnixNano())).Scan(&collID) aID, fmt.Sprintf("Test Builder Coll %d", time.Now().UnixNano())).Scan(&collID)
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) }) t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) })
_, err = setup.DBPool.Exec(ctx, seedExec(t, setup.DBPool,
`INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1,$2)`, collID, tideID) `INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1,$2)`, collID, tideID)
require.NoError(t, err)
titlesFor := func(token string, qf string) []string { titlesFor := func(token string, qf string) []string {
p := url.Values{"library_id": {libUUID.String()}, "qf": {qf}} p := url.Values{"library_id": {libUUID.String()}, "qf": {qf}}
@@ -572,18 +561,17 @@ func TestFilterSuggestionsUnderRules(t *testing.T) {
// offered with the surviving count; zero-match containers never are. // offered with the surviving count; zero-match containers never are.
userA := getRegularUserID(t, setup.DB) userA := getRegularUserID(t, setup.DB)
var collID pgtype.UUID var collID pgtype.UUID
err = setup.DBPool.QueryRow(ctx, err = setup.DBPool.QueryRow(context.Background(),
`INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`, `INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`,
pgtype.UUID{Bytes: [16]byte(userA), Valid: true}, pgtype.UUID{Bytes: [16]byte(userA), Valid: true},
fmt.Sprintf("Test Builder Sug Coll %d", time.Now().UnixNano())).Scan(&collID) fmt.Sprintf("Test Builder Sug Coll %d", time.Now().UnixNano())).Scan(&collID)
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) }) t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) })
var tideID pgtype.UUID var tideID pgtype.UUID
err = setup.DBPool.QueryRow(ctx, `SELECT id FROM media_items WHERE title = $1`, "Sug Tide").Scan(&tideID) err = setup.DBPool.QueryRow(context.Background(), `SELECT id FROM media_items WHERE title = $1`, "Sug Tide").Scan(&tideID)
require.NoError(t, err) require.NoError(t, err)
_, err = setup.DBPool.Exec(ctx, seedExec(t, setup.DBPool,
`INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1, $2)`, collID, tideID) `INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1, $2)`, collID, tideID)
require.NoError(t, err)
status, results = suggest("collection", " ", "") status, results = suggest("collection", " ", "")
require.Equal(t, http.StatusOK, status) require.Equal(t, http.StatusOK, status)
@@ -641,10 +629,9 @@ func TestFilterFamilyLens(t *testing.T) {
}) })
require.NoError(t, err) require.NoError(t, err)
} }
_, err = setup.DBPool.Exec(ctx, seedExec(t, setup.DBPool,
`INSERT INTO library_visibility (library_id, user_id, is_visible) VALUES ($1, $2, false)`, `INSERT INTO library_visibility (library_id, user_id, is_visible) VALUES ($1, $2, false)`,
hiddenID, pgtype.UUID{Bytes: [16]byte(userID), Valid: true}) hiddenID, pgtype.UUID{Bytes: [16]byte(userID), Valid: true})
require.NoError(t, err)
search := func(params url.Values) (int, []map[string]interface{}) { search := func(params url.Values) (int, []map[string]interface{}) {
return qfSearch(t, setup, setup.RegularToken, params) return qfSearch(t, setup, setup.RegularToken, params)
+8 -1
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"bookhoard/internal/config" "bookhoard/internal/config"
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/rls"
"context" "context"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -10,6 +11,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -26,11 +28,16 @@ func setupSyncTestDB(t *testing.T) *database.Queries {
dbConfig, err := pgxpool.ParseConfig(dbURL) dbConfig, err := pgxpool.ParseConfig(dbURL)
require.NoError(t, err, "Failed to parse database URL") require.NoError(t, err, "Failed to parse database URL")
dbConfig.MaxConns = 1 dbConfig.MaxConns = 1
// Harness-only session identity (plan §5): see setupTestServer.
dbConfig.BeforeAcquire = func(ctx context.Context, conn *pgx.Conn) bool {
_, err := conn.Exec(ctx, `SELECT set_config('app.user_id', $1, false)`, rls.SeedIdentity.String())
return err == nil
}
dbPool, err := pgxpool.NewWithConfig(ctx, dbConfig) dbPool, err := pgxpool.NewWithConfig(ctx, dbConfig)
require.NoError(t, err, "Failed to connect to test database") require.NoError(t, err, "Failed to connect to test database")
db := database.New(dbPool) db := database.New(database.NewSeedDB(dbPool))
t.Cleanup(func() { t.Cleanup(func() {
// Scoped wipes ONLY: reading_history now carries real captured // Scoped wipes ONLY: reading_history now carries real captured
// sessions for the shared dev accounts, so a table-wide DELETE // sessions for the shared dev accounts, so a table-wide DELETE
+29
View File
@@ -329,6 +329,26 @@ func seedCtx() context.Context {
return rls.WithUser(context.Background(), rls.SeedIdentity) return rls.WithUser(context.Background(), rls.SeedIdentity)
} }
// seedExec runs a raw statement against the pool under the seed
// identity. The bare pool bypasses ScopedDB (nothing reads the ctx for
// the GUC), so governed tables need the identity declared manually
// inside a transaction.
func seedExec(t *testing.T, pool *pgxpool.Pool, sql string, args ...any) {
t.Helper()
ctx := rls.WithUser(context.Background(), rls.SeedIdentity)
tx, err := pool.Begin(ctx)
require.NoError(t, err)
if _, err := tx.Exec(ctx, `SELECT set_config('app.user_id', $1, true)`, rls.SeedIdentity.String()); err != nil {
_ = tx.Rollback(ctx)
require.NoError(t, err, "declare seed identity")
}
if _, err := tx.Exec(ctx, sql, args...); err != nil {
_ = tx.Rollback(ctx)
require.NoError(t, err, "seed exec")
}
require.NoError(t, tx.Commit(ctx))
}
// uuidToPGType converts uuid.UUID to pgtype.UUID // uuidToPGType converts uuid.UUID to pgtype.UUID
func uuidToPGType(u uuid.UUID) pgtype.UUID { func uuidToPGType(u uuid.UUID) pgtype.UUID {
return pgtype.UUID{Bytes: [16]byte(u), Valid: true} return pgtype.UUID{Bytes: [16]byte(u), Valid: true}
@@ -503,6 +523,15 @@ func setupTestServer(t *testing.T) *TestServerSetup {
dbConfig, err := pgxpool.ParseConfig(cfg.DatabaseURL()) dbConfig, err := pgxpool.ParseConfig(cfg.DatabaseURL())
require.NoError(t, err, "Failed to parse database URL") require.NoError(t, err, "Failed to parse database URL")
dbConfig.MaxConns = 1 dbConfig.MaxConns = 1
// Harness-only session identity (plan §5): every connection in this
// pool runs as rls.SeedIdentity at SESSION scope, so raw-pool
// statements in tests (DBPool.Exec/QueryRow against governed tables)
// pass the read policy. No bleed risk: this pool never serves a
// second identity — A3 applies to the server's request pool.
dbConfig.BeforeAcquire = func(ctx context.Context, conn *pgx.Conn) bool {
_, err := conn.Exec(ctx, `SELECT set_config('app.user_id', $1, false)`, rls.SeedIdentity.String())
return err == nil
}
dbPool, err := pgxpool.NewWithConfig(context.Background(), dbConfig) dbPool, err := pgxpool.NewWithConfig(context.Background(), dbConfig)
require.NoError(t, err, "Failed to connect to test database") require.NoError(t, err, "Failed to connect to test database")
+11
View File
@@ -432,3 +432,14 @@ library_name`.
partial (layout=dropdown on the same endpoint, HX-Request) instead partial (layout=dropdown on the same endpoint, HX-Request) instead
of the retired innerHTML renderer in web/src/search.ts — same of the retired innerHTML renderer in web/src/search.ts — same
endpoint, same auth, content server-rendered. endpoint, same auth, content server-rendered.
## 2026-10-29 — RLS visibility: item-scoped gates may answer 404 as well as 403
With library visibility enforced by Postgres RLS (see
database/schema/schema.sql), a hidden library's rows are INVISIBLE to
the engine — item-scoped endpoints (chapters, tracks, download, detail)
can no longer run their Go-level 403 gate because the row never arrives.
Both answers are gated: 403 (Go-level explicit-library checks remain on
listing endpoints) and 404 (row-level invisibility — no existence leak).
Pins asserting 403 on item-scoped content endpoints widen to accept
{403, 404}. Listing/scoped endpoints keep the strict 403 contract.
+7 -8
View File
@@ -80,7 +80,7 @@ func (h *ConflictHandler) GetConflictsData(c *echo.Context) ([]ConflictDetailRes
status = "unresolved" status = "unresolved"
} }
ctx := context.Background() ctx := c.Request().Context()
var conflicts interface{} var conflicts interface{}
var err error var err error
@@ -263,13 +263,13 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
} }
if conflict.ConflictType == "progress" { if conflict.ConflictType == "progress" {
if err := h.applyProgressResolution(conflict.MediaItemID, conflict.UserID, winnerSource, winnerData); err == nil { if err := h.applyProgressResolution(c.Request().Context(), conflict.MediaItemID, conflict.UserID, winnerSource, winnerData); err == nil {
appliedTo["progress"] = true appliedTo["progress"] = true
} }
} }
if conflict.ConflictType == "annotation_highlight" || conflict.ConflictType == "annotation_bookmark" || conflict.ConflictType == "annotation_note" { if conflict.ConflictType == "annotation_highlight" || conflict.ConflictType == "annotation_bookmark" || conflict.ConflictType == "annotation_note" {
if err := h.applyAnnotationResolution(conflict.MediaItemID, conflict.UserID, winnerData, conflict.ConflictType); err == nil { if err := h.applyAnnotationResolution(c.Request().Context(), conflict.MediaItemID, conflict.UserID, winnerData, conflict.ConflictType); err == nil {
appliedTo["annotations"] = true appliedTo["annotations"] = true
} }
} }
@@ -321,7 +321,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
// the service's LWW seed instead of being NULLed — that is the fix. // the service's LWW seed instead of being NULLed — that is the fix.
// chapter_progress keeps its stored value instead of being overwritten // chapter_progress keeps its stored value instead of being overwritten
// with the book-level percentage. // with the book-level percentage.
func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerSource string, data map[string]interface{}) error { func (h *ConflictHandler) applyProgressResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, winnerSource string, data map[string]interface{}) error {
req := wsync.SaveProgressRequest{ req := wsync.SaveProgressRequest{
MediaItemID: mediaItemID, MediaItemID: mediaItemID,
UserID: userID, UserID: userID,
@@ -361,12 +361,11 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI
req.TotalPages = &tp req.TotalPages = &tp
} }
_, err := h.progressSvc.SaveProgress(context.Background(), req) _, err := h.progressSvc.SaveProgress(ctx, req)
return err return err
} }
func (h *ConflictHandler) applyAnnotationResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerData map[string]interface{}, conflictType string) error { func (h *ConflictHandler) applyAnnotationResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, winnerData map[string]interface{}, conflictType string) error {
ctx := context.Background()
dedupKey, _ := winnerData["dedup_key"].(string) dedupKey, _ := winnerData["dedup_key"].(string)
if dedupKey == "" { if dedupKey == "" {
@@ -702,7 +701,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
continue continue
} }
if err := h.applyProgressResolution(conflict.MediaItemID, conflict.UserID, winningSource, winnerData); err != nil { if err := h.applyProgressResolution(c.Request().Context(), conflict.MediaItemID, conflict.UserID, winningSource, winnerData); err != nil {
results = append(results, ConflictResult{ results = append(results, ConflictResult{
ConflictID: conflictIDStr, ConflictID: conflictIDStr,
Status: "error", Status: "error",
+8
View File
@@ -2,6 +2,7 @@ package middleware
import ( import (
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/rls"
"context" "context"
"net/http" "net/http"
"strconv" "strconv"
@@ -183,6 +184,13 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF
c.Set("device_ctx", ctx) c.Set("device_ctx", ctx)
c.Set("device_id", device.ID.Bytes) c.Set("device_id", device.ID.Bytes)
// RLS bridge (plan §5.1/A9): device-authenticated requests
// (KOReader/Kobo sync) read media as the DEVICE OWNER — echo's
// c.Set is echo-local, so the stdlib request context needs the
// identity explicitly or every governed read raises.
c.SetRequest(c.Request().WithContext(context.WithValue(
c.Request().Context(), rls.CtxKeyUserID, device.UserID)))
return next(c) return next(c)
} }
} }