diff --git a/cmd/server/tests/audiobook_multifile_test.go b/cmd/server/tests/audiobook_multifile_test.go index bfb629b9..67247d78 100644 --- a/cmd/server/tests/audiobook_multifile_test.go +++ b/cmd/server/tests/audiobook_multifile_test.go @@ -289,7 +289,12 @@ func TestAudiobookTrackEndpoints(t *testing.T) { resp, err := client.Do(req) require.NoError(t, err) _ = 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 diff --git a/cmd/server/tests/filter_builder_test.go b/cmd/server/tests/filter_builder_test.go index 4d34946d..002d5c34 100644 --- a/cmd/server/tests/filter_builder_test.go +++ b/cmd/server/tests/filter_builder_test.go @@ -267,11 +267,9 @@ func TestFilterComposedSemantics(t *testing.T) { assert.Len(t, got, 4, "mime_type is") // Series full membership: the alternate_info series matches too. - ctx := context.Background() - _, err := setup.DBPool.Exec(ctx, + seedExec(t, setup.DBPool, `UPDATE media_items SET alternate_info = '{"alternate_series":"Quill Chronicles"}'::jsonb WHERE title = $1`, "Sem Fen") - require.NoError(t, err) got = titles(qfAll(qfRule("series", "contains", "Quill Chron"))) assert.Equal(t, []string{"Sem Fen"}, got, "series matches alternate_info membership") 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, // reviewed Fen, noted on Tide ("the catharsis scene"), highlighted // 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())`, tideID, aID, stoneID, aID) - require.NoError(t, err) - _, 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,1.0, now())`, tideID, bID) - require.NoError(t, err) - _, err = setup.DBPool.Exec(ctx, + seedExec(t, setup.DBPool, `INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,8)`, tideID, aID) - require.NoError(t, err) - _, err = setup.DBPool.Exec(ctx, + seedExec(t, setup.DBPool, `INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,3)`, tideID, bID) - require.NoError(t, err) - _, err = setup.DBPool.Exec(ctx, + seedExec(t, setup.DBPool, `INSERT INTO media_reviews (media_item_id, user_id, review) VALUES ($1,$2,'splendid')`, fenID, aID) - require.NoError(t, err) - _, err = setup.DBPool.Exec(ctx, + seedExec(t, setup.DBPool, `INSERT INTO media_notes (media_item_id, user_id, content) VALUES ($1,$2,'remember the catharsis scene')`, tideID, aID) - require.NoError(t, err) - _, err = setup.DBPool.Exec(ctx, + seedExec(t, setup.DBPool, `INSERT INTO media_highlights (media_item_id, user_id, selection_text) VALUES ($1,$2,'the dark forest road')`, stoneID, aID) - require.NoError(t, err) - _, err = setup.DBPool.Exec(ctx, + seedExec(t, setup.DBPool, `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) - require.NoError(t, err) 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`, aID, fmt.Sprintf("Test Builder Coll %d", time.Now().UnixNano())).Scan(&collID) require.NoError(t, err) 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) - require.NoError(t, err) titlesFor := func(token string, qf string) []string { 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. userA := getRegularUserID(t, setup.DB) 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`, pgtype.UUID{Bytes: [16]byte(userA), Valid: true}, fmt.Sprintf("Test Builder Sug Coll %d", time.Now().UnixNano())).Scan(&collID) require.NoError(t, err) t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) }) 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) - _, err = setup.DBPool.Exec(ctx, + seedExec(t, setup.DBPool, `INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1, $2)`, collID, tideID) - require.NoError(t, err) status, results = suggest("collection", " ", "") require.Equal(t, http.StatusOK, status) @@ -641,10 +629,9 @@ func TestFilterFamilyLens(t *testing.T) { }) 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)`, hiddenID, pgtype.UUID{Bytes: [16]byte(userID), Valid: true}) - require.NoError(t, err) search := func(params url.Values) (int, []map[string]interface{}) { return qfSearch(t, setup, setup.RegularToken, params) diff --git a/cmd/server/tests/sync_integration_test.go b/cmd/server/tests/sync_integration_test.go index c4550cfc..69ad7693 100644 --- a/cmd/server/tests/sync_integration_test.go +++ b/cmd/server/tests/sync_integration_test.go @@ -3,6 +3,7 @@ package main import ( "bookhoard/internal/config" "bookhoard/internal/database" + "bookhoard/internal/rls" "context" "net/http" "net/http/httptest" @@ -10,6 +11,7 @@ import ( "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "github.com/stretchr/testify/assert" @@ -26,11 +28,16 @@ func setupSyncTestDB(t *testing.T) *database.Queries { dbConfig, err := pgxpool.ParseConfig(dbURL) require.NoError(t, err, "Failed to parse database URL") 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) require.NoError(t, err, "Failed to connect to test database") - db := database.New(dbPool) + db := database.New(database.NewSeedDB(dbPool)) t.Cleanup(func() { // Scoped wipes ONLY: reading_history now carries real captured // sessions for the shared dev accounts, so a table-wide DELETE diff --git a/cmd/server/tests/test_helpers_test.go b/cmd/server/tests/test_helpers_test.go index dc47f292..ed4db9c3 100644 --- a/cmd/server/tests/test_helpers_test.go +++ b/cmd/server/tests/test_helpers_test.go @@ -329,6 +329,26 @@ func seedCtx() context.Context { 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 func uuidToPGType(u uuid.UUID) pgtype.UUID { 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()) require.NoError(t, err, "Failed to parse database URL") 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) require.NoError(t, err, "Failed to connect to test database") diff --git a/docs/contract_pins.md b/docs/contract_pins.md index bebef111..8a786d76 100644 --- a/docs/contract_pins.md +++ b/docs/contract_pins.md @@ -432,3 +432,14 @@ library_name`. partial (layout=dropdown on the same endpoint, HX-Request) instead of the retired innerHTML renderer in web/src/search.ts — same 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. diff --git a/internal/handlers/conflicts.go b/internal/handlers/conflicts.go index 73548a79..be5fe1bb 100644 --- a/internal/handlers/conflicts.go +++ b/internal/handlers/conflicts.go @@ -80,7 +80,7 @@ func (h *ConflictHandler) GetConflictsData(c *echo.Context) ([]ConflictDetailRes status = "unresolved" } - ctx := context.Background() + ctx := c.Request().Context() var conflicts interface{} var err error @@ -263,13 +263,13 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error { } 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 } } 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 } } @@ -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. // chapter_progress keeps its stored value instead of being overwritten // 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{ MediaItemID: mediaItemID, UserID: userID, @@ -361,12 +361,11 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI req.TotalPages = &tp } - _, err := h.progressSvc.SaveProgress(context.Background(), req) + _, err := h.progressSvc.SaveProgress(ctx, req) return err } -func (h *ConflictHandler) applyAnnotationResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerData map[string]interface{}, conflictType string) error { - ctx := context.Background() +func (h *ConflictHandler) applyAnnotationResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, winnerData map[string]interface{}, conflictType string) error { dedupKey, _ := winnerData["dedup_key"].(string) if dedupKey == "" { @@ -702,7 +701,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error { 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{ ConflictID: conflictIDStr, Status: "error", diff --git a/internal/middleware/device_auth.go b/internal/middleware/device_auth.go index 3b6ea0cb..a08937ea 100644 --- a/internal/middleware/device_auth.go +++ b/internal/middleware/device_auth.go @@ -2,6 +2,7 @@ package middleware import ( "bookhoard/internal/database" + "bookhoard/internal/rls" "context" "net/http" "strconv" @@ -183,6 +184,13 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF c.Set("device_ctx", ctx) 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) } }