feat(scanner): scanner entrypoints + generic jobs carry RLS identity; SeedDB harness fallback

The full suite enumerated the remaining triage class: tests drive the
scanner directly (ScanFolders(context.Background())) and read seeded
media via Queries with bare contexts — every governed read raised.

- MediaScanner.ScanFolders self-declares its configured admin
  (SetAdminID — set by main AND the test helpers): a scan is server
  maintenance, product-correct regardless of caller.
- ScopedDB gains a Fallback identity, exposed as NewSeedDB for the
  TEST HARNESS ONLY: setup.DB falls back to rls.SeedIdentity when the
  context carries none, so direct seeding/verification helpers work
  uniformly instead of per-site context threading. rls.SeedIdentity is
  the shared constant (no library_visibility rows => full visibility).
- generic job creation (jobs.go) stamps the requesting admin into the
  job Context, same as the scan endpoint.

The sync queue processor stays identity-free: sync_queue is not an
RLS-governed table.
This commit is contained in:
John O'Keefe committed 2026-10-29 09:27:04 -04:00
1 parent 385a5d9dba
commit bb2cc26e9d
9 files changed
+67 -30

No files matched your search

+9 -9
View File
@@ -318,15 +318,15 @@ func createRegularUserOnce(t *testing.T, db *database.Queries) UserTestData {
} }
} }
// seedCtx is the context harness-level media seeding runs under. The // seedCtx is the context harness-level media seeding runs under. It
// seed identity is a valid UUID with NO library_visibility rows, so // carries rls.SeedIdentity — a valid UUID with NO library_visibility
// RLS grants it full visibility — needed because sqlc's :one inserts // rows, so RLS grants it full visibility (needed because sqlc :one
// use RETURNING, which requires SELECT visibility of the new row. // inserts use RETURNING, which requires SELECT visibility of the new
// Product code never uses this; requests carry real personas. // row). setup.DB additionally falls back to this identity for calls
var seedIdentity = pgtype.UUID{Bytes: [16]byte(uuid.MustParse("00000000-0000-0000-0000-000000000002")), Valid: true} // that pass a bare context. Product code never uses this; requests
// carry real personas.
func seedCtx() context.Context { func seedCtx() context.Context {
return rls.WithUser(context.Background(), seedIdentity) return rls.WithUser(context.Background(), rls.SeedIdentity)
} }
// uuidToPGType converts uuid.UUID to pgtype.UUID // uuidToPGType converts uuid.UUID to pgtype.UUID
@@ -516,7 +516,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
// writes everything. Real personas only exist at the HTTP layer. // 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(database.NewScopedDB(dbPool)) seedDB := database.New(database.NewSeedDB(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)
+18 -2
View File
@@ -26,6 +26,7 @@ import (
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"bookhoard/internal/rls" "bookhoard/internal/rls"
@@ -35,9 +36,11 @@ import (
var _ DBTX = (*ScopedDB)(nil) var _ DBTX = (*ScopedDB)(nil)
// ScopedDB implements DBTX over a pool, declaring the acting user for // ScopedDB implements DBTX over a pool, declaring the acting user for
// the RLS policies on every statement. // the RLS policies on every statement. Fallback (test harness ONLY —
// see NewSeedDB) supplies an identity when the context carries none.
type ScopedDB struct { type ScopedDB struct {
Pool *pgxpool.Pool Pool *pgxpool.Pool
Fallback *pgtype.UUID
} }
// NewScopedDB is the constructor used at the single Queries // NewScopedDB is the constructor used at the single Queries
@@ -46,6 +49,15 @@ func NewScopedDB(pool *pgxpool.Pool) *ScopedDB {
return &ScopedDB{Pool: pool} return &ScopedDB{Pool: pool}
} }
// NewSeedDB returns a ScopedDB for TEST-HARNESS seeding/verification:
// when the context carries no identity it falls back to
// rls.SeedIdentity, so direct media reads/writes from helpers work
// without impersonating a real user. Never use in product wiring.
func NewSeedDB(pool *pgxpool.Pool) *ScopedDB {
seed := rls.SeedIdentity
return &ScopedDB{Pool: pool, Fallback: &seed}
}
const zeroUUID = "00000000-0000-0000-0000-000000000000" const zeroUUID = "00000000-0000-0000-0000-000000000000"
// begin opens the per-statement transaction. If the context carries a // begin opens the per-statement transaction. If the context carries a
@@ -62,7 +74,11 @@ func (s *ScopedDB) begin(ctx context.Context) (pgx.Tx, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if uid, ok := rls.FromContext(ctx); ok && uid.Valid && uid.String() != zeroUUID { uid, ok := rls.FromContext(ctx)
if (!ok || !uid.Valid || uid.String() == zeroUUID) && s.Fallback != nil {
uid, ok = *s.Fallback, true
}
if ok && uid.Valid && uid.String() != zeroUUID {
if err := rls.DeclareUser(ctx, tx, uid); err != nil { if err := rls.DeclareUser(ctx, tx, uid); err != nil {
_ = tx.Rollback(ctx) _ = tx.Rollback(ctx)
return nil, fmt.Errorf("rls: declare user: %w", err) return nil, fmt.Errorf("rls: declare user: %w", err)
+3 -4
View File
@@ -3,7 +3,6 @@ package handlers
import ( import (
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/utils" "bookhoard/internal/utils"
"context"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
@@ -102,7 +101,7 @@ func (h *AnalyticsHandler) GetReadingStats(c *echo.Context) error {
// "today") never appear. // "today") never appear.
endTime = endTime.AddDate(0, 0, 1).Add(-time.Nanosecond) endTime = endTime.AddDate(0, 0, 1).Add(-time.Nanosecond)
ctx := context.Background() ctx := c.Request().Context()
history, err := h.db.GetUserReadingHistory(ctx, database.GetUserReadingHistoryParams{ history, err := h.db.GetUserReadingHistory(ctx, database.GetUserReadingHistoryParams{
UserID: user.ID, UserID: user.ID,
@@ -220,7 +219,7 @@ func (h *AnalyticsHandler) calculateMostActiveDay(history []database.GetUserRead
func (h *AnalyticsHandler) GetDeviceUsage(c *echo.Context) error { func (h *AnalyticsHandler) GetDeviceUsage(c *echo.Context) error {
user := c.Get("user").(database.Users) user := c.Get("user").(database.Users)
ctx := context.Background() ctx := c.Request().Context()
usage, err := h.db.GetUserDeviceUsage(ctx, user.ID) usage, err := h.db.GetUserDeviceUsage(ctx, user.ID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) { if err != nil && !errors.Is(err, pgx.ErrNoRows) {
@@ -268,7 +267,7 @@ func (h *AnalyticsHandler) GetPopularBooks(c *echo.Context) error {
limitInt = parsedLimit limitInt = parsedLimit
} }
ctx := context.Background() ctx := c.Request().Context()
books, err := h.db.GetPopularBooks(ctx, database.GetPopularBooksParams{ books, err := h.db.GetPopularBooks(ctx, database.GetPopularBooksParams{
UserID: user.ID, UserID: user.ID,
+12 -12
View File
@@ -175,7 +175,7 @@ func (h *ConflictHandler) GetConflict(c *echo.Context) error {
} }
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true} conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID) conflict, err := h.db.GetSyncConflict(c.Request().Context(), conflictUUID)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusNotFound, "conflict not found") return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
@@ -187,7 +187,7 @@ func (h *ConflictHandler) GetConflict(c *echo.Context) error {
return echo.NewHTTPError(http.StatusForbidden, "access denied") return echo.NewHTTPError(http.StatusForbidden, "access denied")
} }
mediaItem, err := h.db.GetMediaItem(context.Background(), conflict.MediaItemID) mediaItem, err := h.db.GetMediaItem(c.Request().Context(), conflict.MediaItemID)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get media item") return echo.NewHTTPError(http.StatusInternalServerError, "failed to get media item")
} }
@@ -223,7 +223,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
} }
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true} conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID) conflict, err := h.db.GetSyncConflict(c.Request().Context(), conflictUUID)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusNotFound, "conflict not found") return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
@@ -283,7 +283,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
resolutionDataJSON, _ := json.Marshal(resolutionData) resolutionDataJSON, _ := json.Marshal(resolutionData)
_, err = h.db.ResolveSyncConflict(context.Background(), database.ResolveSyncConflictParams{ _, err = h.db.ResolveSyncConflict(c.Request().Context(), database.ResolveSyncConflictParams{
ID: conflictUUID, ID: conflictUUID,
ResolutionStatus: pgtype.Text{String: "user_resolved", Valid: true}, ResolutionStatus: pgtype.Text{String: "user_resolved", Valid: true},
ResolutionData: resolutionDataJSON, ResolutionData: resolutionDataJSON,
@@ -530,7 +530,7 @@ func (h *ConflictHandler) DeleteConflict(c *echo.Context) error {
} }
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true} conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID) conflict, err := h.db.GetSyncConflict(c.Request().Context(), conflictUUID)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusNotFound, "conflict not found") return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
@@ -542,7 +542,7 @@ func (h *ConflictHandler) DeleteConflict(c *echo.Context) error {
return echo.NewHTTPError(http.StatusForbidden, "access denied") return echo.NewHTTPError(http.StatusForbidden, "access denied")
} }
if err := h.db.DeleteSyncConflict(context.Background(), conflictUUID); err != nil { if err := h.db.DeleteSyncConflict(c.Request().Context(), conflictUUID); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to delete conflict") return echo.NewHTTPError(http.StatusInternalServerError, "failed to delete conflict")
} }
@@ -552,7 +552,7 @@ func (h *ConflictHandler) DeleteConflict(c *echo.Context) error {
func (h *ConflictHandler) DismissAllResolved(c *echo.Context) error { func (h *ConflictHandler) DismissAllResolved(c *echo.Context) error {
user := c.Get("user").(database.Users) user := c.Get("user").(database.Users)
conflicts, err := h.db.ListConflictsByUser(context.Background(), user.ID) conflicts, err := h.db.ListConflictsByUser(c.Request().Context(), user.ID)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to list conflicts") return echo.NewHTTPError(http.StatusInternalServerError, "failed to list conflicts")
} }
@@ -560,7 +560,7 @@ func (h *ConflictHandler) DismissAllResolved(c *echo.Context) error {
deleted := 0 deleted := 0
for _, conflict := range conflicts { for _, conflict := range conflicts {
if conflict.ResolutionStatus.String == "user_resolved" || conflict.ResolutionStatus.String == "bulk_resolved" { if conflict.ResolutionStatus.String == "user_resolved" || conflict.ResolutionStatus.String == "bulk_resolved" {
if err := h.db.DeleteSyncConflict(context.Background(), conflict.ID); err == nil { if err := h.db.DeleteSyncConflict(c.Request().Context(), conflict.ID); err == nil {
deleted++ deleted++
} }
} }
@@ -620,7 +620,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
} }
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true} conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID) conflict, err := h.db.GetSyncConflict(c.Request().Context(), conflictUUID)
if err != nil { if err != nil {
results = append(results, ConflictResult{ results = append(results, ConflictResult{
ConflictID: conflictIDStr, ConflictID: conflictIDStr,
@@ -719,7 +719,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
} }
resolutionDataJSON, _ := json.Marshal(resolutionData) resolutionDataJSON, _ := json.Marshal(resolutionData)
_, err = h.db.ResolveSyncConflict(context.Background(), database.ResolveSyncConflictParams{ _, err = h.db.ResolveSyncConflict(c.Request().Context(), database.ResolveSyncConflictParams{
ID: conflictUUID, ID: conflictUUID,
ResolutionStatus: pgtype.Text{String: "bulk_resolved", Valid: true}, ResolutionStatus: pgtype.Text{String: "bulk_resolved", Valid: true},
ResolutionData: resolutionDataJSON, ResolutionData: resolutionDataJSON,
@@ -817,7 +817,7 @@ func (h *ConflictHandler) BulkDismissConflicts(c *echo.Context) error {
} }
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true} conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID) conflict, err := h.db.GetSyncConflict(c.Request().Context(), conflictUUID)
if err != nil { if err != nil {
results = append(results, ConflictResult{ results = append(results, ConflictResult{
ConflictID: conflictIDStr, ConflictID: conflictIDStr,
@@ -838,7 +838,7 @@ func (h *ConflictHandler) BulkDismissConflicts(c *echo.Context) error {
continue continue
} }
if err := h.db.DeleteSyncConflict(context.Background(), conflictUUID); err != nil { if err := h.db.DeleteSyncConflict(c.Request().Context(), conflictUUID); err != nil {
results = append(results, ConflictResult{ results = append(results, ConflictResult{
ConflictID: conflictIDStr, ConflictID: conflictIDStr,
Status: "error", Status: "error",
+8
View File
@@ -1,12 +1,15 @@
package handlers package handlers
import ( import (
"context"
"net/http" "net/http"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"github.com/jackc/pgx/v5/pgtype"
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/rls"
"bookhoard/internal/services" "bookhoard/internal/services"
) )
@@ -49,12 +52,17 @@ func (h *JobsHandler) CreateJob(c *echo.Context) error {
// Add database to params // Add database to params
req.Params["db"] = h.db req.Params["db"] = h.db
// RLS plan §6: the job's Context carries the requesting admin's
// identity — governed reads inside the job would raise otherwise.
userUUID, _ := uuid.Parse(c.Get("user_id").(string))
// Create job // Create job
job := &services.Job{ job := &services.Job{
ID: uuid.New().String(), ID: uuid.New().String(),
Type: jobType, Type: jobType,
Params: req.Params, Params: req.Params,
Status: services.JobStatusPending, Status: services.JobStatusPending,
Context: rls.WithUser(context.Background(), pgtype.UUID{Bytes: userUUID, Valid: true}),
} }
// Enqueue job // Enqueue job
+1 -2
View File
@@ -4,7 +4,6 @@ import (
"bookhoard/internal/database" "bookhoard/internal/database"
wsync "bookhoard/internal/sync" wsync "bookhoard/internal/sync"
"bookhoard/internal/utils" "bookhoard/internal/utils"
"context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"strconv" "strconv"
@@ -46,7 +45,7 @@ func (h *Handler) GetProgressHistory(c *echo.Context) error {
} }
} }
history, err := h.db.GetReadingHistory(context.Background(), database.GetReadingHistoryParams{ history, err := h.db.GetReadingHistory(c.Request().Context(), database.GetReadingHistoryParams{
UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true}, UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true}, MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
Limit: int32(limit), Limit: int32(limit),
+1 -1
View File
@@ -213,7 +213,7 @@ func (h *WSHandler) getInitialState(userID string) map[string]interface{} {
progressMap := make(map[string]map[string]interface{}) progressMap := make(map[string]map[string]interface{})
for _, item := range mediaItems { for _, item := range mediaItems {
progress, err := h.db.GetUniversalProgress(context.Background(), database.GetUniversalProgressParams{ progress, err := h.db.GetUniversalProgress(rls.WithUser(context.Background(), pgUserID), database.GetUniversalProgressParams{
MediaItemID: item.ID, MediaItemID: item.ID,
UserID: pgUserID, UserID: pgUserID,
}) })
+8
View File
@@ -15,6 +15,7 @@ package rls
import ( import (
"context" "context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
@@ -44,6 +45,13 @@ func WithUser(ctx context.Context, uid pgtype.UUID) context.Context {
return context.WithValue(ctx, CtxKeyUserID, uid) return context.WithValue(ctx, CtxKeyUserID, uid)
} }
// SeedIdentity is the harness seeding identity (plan §5): a valid UUID
// with NO library_visibility rows, so RLS grants it full visibility.
// It exists so test seeding and direct DB verification can read and
// write governed tables without impersonating a real user. Product
// code never runs as it.
var SeedIdentity = pgtype.UUID{Bytes: [16]byte(uuid.MustParse("00000000-0000-0000-0000-000000000002")), Valid: true}
// DeclareUser sets the RLS identity GUC inside an ALREADY-OPEN // DeclareUser sets the RLS identity GUC inside an ALREADY-OPEN
// transaction. The `true` argument is is_local: transaction-scoped, // transaction. The `true` argument is is_local: transaction-scoped,
// evaporating at COMMIT/ROLLBACK — NEVER session-scoped on a pooled // evaporating at COMMIT/ROLLBACK — NEVER session-scoped on a pooled
+7
View File
@@ -675,6 +675,13 @@ func (s *MediaScanner) enqueueLibraryScan(rootFolder, trigger string) {
} }
func (s *MediaScanner) ScanFolders(ctx context.Context) error { func (s *MediaScanner) ScanFolders(ctx context.Context) error {
// RLS plan §6: a scan is server maintenance over library media —
// self-declare the scanner's configured admin identity so internal
// reads work regardless of the caller's context (worker jobs carry
// one; test/direct callers may not). No-op when unconfigured.
if s.adminID.Valid {
ctx = rls.WithUser(ctx, s.adminID)
}
// Cancellation is checked at every stage boundary and inside the // Cancellation is checked at every stage boundary and inside the
// walk callback: the in-flight file finishes, then the scan aborts // walk callback: the in-flight file finishes, then the scan aborts
// and reports context.Canceled (processJob classifies that as a // and reports context.Canceled (processJob classifies that as a