diff --git a/cmd/server/tests/test_helpers_test.go b/cmd/server/tests/test_helpers_test.go index 9b68c27b..dc47f292 100644 --- a/cmd/server/tests/test_helpers_test.go +++ b/cmd/server/tests/test_helpers_test.go @@ -318,15 +318,15 @@ func createRegularUserOnce(t *testing.T, db *database.Queries) UserTestData { } } -// seedCtx is the context harness-level media seeding runs under. The -// seed identity is a valid UUID with NO library_visibility rows, so -// RLS grants it full visibility — needed because sqlc's :one inserts -// use RETURNING, which requires SELECT visibility of the new row. -// Product code never uses this; requests carry real personas. -var seedIdentity = pgtype.UUID{Bytes: [16]byte(uuid.MustParse("00000000-0000-0000-0000-000000000002")), Valid: true} - +// seedCtx is the context harness-level media seeding runs under. It +// carries rls.SeedIdentity — a valid UUID with NO library_visibility +// rows, so RLS grants it full visibility (needed because sqlc :one +// inserts use RETURNING, which requires SELECT visibility of the new +// row). setup.DB additionally falls back to this identity for calls +// that pass a bare context. Product code never uses this; requests +// carry real personas. func seedCtx() context.Context { - return rls.WithUser(context.Background(), seedIdentity) + return rls.WithUser(context.Background(), rls.SeedIdentity) } // 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. scopedDB := database.NewScopedDB(dbPool) 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 // populates the cache (missing rows fall back to compiled defaults) diff --git a/internal/database/scoped.go b/internal/database/scoped.go index d06496e0..08e3456c 100644 --- a/internal/database/scoped.go +++ b/internal/database/scoped.go @@ -26,6 +26,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "bookhoard/internal/rls" @@ -35,9 +36,11 @@ import ( var _ DBTX = (*ScopedDB)(nil) // 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 { - Pool *pgxpool.Pool + Pool *pgxpool.Pool + Fallback *pgtype.UUID } // NewScopedDB is the constructor used at the single Queries @@ -46,6 +49,15 @@ func NewScopedDB(pool *pgxpool.Pool) *ScopedDB { 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" // 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 { 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 { _ = tx.Rollback(ctx) return nil, fmt.Errorf("rls: declare user: %w", err) diff --git a/internal/handlers/analytics.go b/internal/handlers/analytics.go index 365fa0dd..5dbb2d4d 100644 --- a/internal/handlers/analytics.go +++ b/internal/handlers/analytics.go @@ -3,7 +3,6 @@ package handlers import ( "bookhoard/internal/database" "bookhoard/internal/utils" - "context" "errors" "fmt" "net/http" @@ -102,7 +101,7 @@ func (h *AnalyticsHandler) GetReadingStats(c *echo.Context) error { // "today") never appear. endTime = endTime.AddDate(0, 0, 1).Add(-time.Nanosecond) - ctx := context.Background() + ctx := c.Request().Context() history, err := h.db.GetUserReadingHistory(ctx, database.GetUserReadingHistoryParams{ UserID: user.ID, @@ -220,7 +219,7 @@ func (h *AnalyticsHandler) calculateMostActiveDay(history []database.GetUserRead func (h *AnalyticsHandler) GetDeviceUsage(c *echo.Context) error { user := c.Get("user").(database.Users) - ctx := context.Background() + ctx := c.Request().Context() usage, err := h.db.GetUserDeviceUsage(ctx, user.ID) if err != nil && !errors.Is(err, pgx.ErrNoRows) { @@ -268,7 +267,7 @@ func (h *AnalyticsHandler) GetPopularBooks(c *echo.Context) error { limitInt = parsedLimit } - ctx := context.Background() + ctx := c.Request().Context() books, err := h.db.GetPopularBooks(ctx, database.GetPopularBooksParams{ UserID: user.ID, diff --git a/internal/handlers/conflicts.go b/internal/handlers/conflicts.go index 4c97b6dd..73548a79 100644 --- a/internal/handlers/conflicts.go +++ b/internal/handlers/conflicts.go @@ -175,7 +175,7 @@ func (h *ConflictHandler) GetConflict(c *echo.Context) error { } 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 errors.Is(err, pgx.ErrNoRows) { 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") } - mediaItem, err := h.db.GetMediaItem(context.Background(), conflict.MediaItemID) + mediaItem, err := h.db.GetMediaItem(c.Request().Context(), conflict.MediaItemID) if err != nil { 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} - conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID) + conflict, err := h.db.GetSyncConflict(c.Request().Context(), conflictUUID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return echo.NewHTTPError(http.StatusNotFound, "conflict not found") @@ -283,7 +283,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error { resolutionDataJSON, _ := json.Marshal(resolutionData) - _, err = h.db.ResolveSyncConflict(context.Background(), database.ResolveSyncConflictParams{ + _, err = h.db.ResolveSyncConflict(c.Request().Context(), database.ResolveSyncConflictParams{ ID: conflictUUID, ResolutionStatus: pgtype.Text{String: "user_resolved", Valid: true}, ResolutionData: resolutionDataJSON, @@ -530,7 +530,7 @@ func (h *ConflictHandler) DeleteConflict(c *echo.Context) error { } 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 errors.Is(err, pgx.ErrNoRows) { 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") } - 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") } @@ -552,7 +552,7 @@ func (h *ConflictHandler) DeleteConflict(c *echo.Context) error { func (h *ConflictHandler) DismissAllResolved(c *echo.Context) error { 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 { return echo.NewHTTPError(http.StatusInternalServerError, "failed to list conflicts") } @@ -560,7 +560,7 @@ func (h *ConflictHandler) DismissAllResolved(c *echo.Context) error { deleted := 0 for _, conflict := range conflicts { 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++ } } @@ -620,7 +620,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error { } 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 { results = append(results, ConflictResult{ ConflictID: conflictIDStr, @@ -719,7 +719,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error { } resolutionDataJSON, _ := json.Marshal(resolutionData) - _, err = h.db.ResolveSyncConflict(context.Background(), database.ResolveSyncConflictParams{ + _, err = h.db.ResolveSyncConflict(c.Request().Context(), database.ResolveSyncConflictParams{ ID: conflictUUID, ResolutionStatus: pgtype.Text{String: "bulk_resolved", Valid: true}, ResolutionData: resolutionDataJSON, @@ -817,7 +817,7 @@ func (h *ConflictHandler) BulkDismissConflicts(c *echo.Context) error { } 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 { results = append(results, ConflictResult{ ConflictID: conflictIDStr, @@ -838,7 +838,7 @@ func (h *ConflictHandler) BulkDismissConflicts(c *echo.Context) error { 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{ ConflictID: conflictIDStr, Status: "error", diff --git a/internal/handlers/jobs.go b/internal/handlers/jobs.go index a512bbd4..5f8a14b8 100644 --- a/internal/handlers/jobs.go +++ b/internal/handlers/jobs.go @@ -1,12 +1,15 @@ package handlers import ( + "context" "net/http" "github.com/google/uuid" "github.com/labstack/echo/v5" + "github.com/jackc/pgx/v5/pgtype" "bookhoard/internal/database" + "bookhoard/internal/rls" "bookhoard/internal/services" ) @@ -49,12 +52,17 @@ func (h *JobsHandler) CreateJob(c *echo.Context) error { // Add database to params 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 job := &services.Job{ - ID: uuid.New().String(), - Type: jobType, - Params: req.Params, - Status: services.JobStatusPending, + ID: uuid.New().String(), + Type: jobType, + Params: req.Params, + Status: services.JobStatusPending, + Context: rls.WithUser(context.Background(), pgtype.UUID{Bytes: userUUID, Valid: true}), } // Enqueue job diff --git a/internal/handlers/progress.go b/internal/handlers/progress.go index 10667ed6..dfef32dd 100644 --- a/internal/handlers/progress.go +++ b/internal/handlers/progress.go @@ -4,7 +4,6 @@ import ( "bookhoard/internal/database" wsync "bookhoard/internal/sync" "bookhoard/internal/utils" - "context" "encoding/json" "net/http" "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}, MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true}, Limit: int32(limit), diff --git a/internal/handlers/websocket.go b/internal/handlers/websocket.go index 1c850cfe..b39c862f 100644 --- a/internal/handlers/websocket.go +++ b/internal/handlers/websocket.go @@ -213,7 +213,7 @@ func (h *WSHandler) getInitialState(userID string) map[string]interface{} { progressMap := make(map[string]map[string]interface{}) 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, UserID: pgUserID, }) diff --git a/internal/rls/rls.go b/internal/rls/rls.go index d63a7bbd..72e914e4 100644 --- a/internal/rls/rls.go +++ b/internal/rls/rls.go @@ -15,6 +15,7 @@ package rls import ( "context" + "github.com/google/uuid" "github.com/jackc/pgx/v5" "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) } +// 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 // transaction. The `true` argument is is_local: transaction-scoped, // evaporating at COMMIT/ROLLBACK — NEVER session-scoped on a pooled diff --git a/internal/services/media_scanner.go b/internal/services/media_scanner.go index 0dc958ae..62eb3600 100644 --- a/internal/services/media_scanner.go +++ b/internal/services/media_scanner.go @@ -675,6 +675,13 @@ func (s *MediaScanner) enqueueLibraryScan(rootFolder, trigger string) { } 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 // walk callback: the in-flight file finishes, then the scan aborts // and reports context.Canceled (processJob classifies that as a