diff --git a/internal/handlers/scanner.go b/internal/handlers/scanner.go index 126c21c9..5aa526ef 100644 --- a/internal/handlers/scanner.go +++ b/internal/handlers/scanner.go @@ -8,6 +8,8 @@ import ( "net/http" "github.com/google/uuid" + + "bookhoard/internal/rls" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v5" ) @@ -127,6 +129,11 @@ func (h *Handler) ScanLibrary(c *echo.Context) error { jobID := uuid.New().String() + // RLS plan §6: the scan job's media reads run under the triggering + // admin's identity. h.ctx is a handler-lifetime Background context + // (outlives the request), so the identity is stamped explicitly — + // a bare context makes governed reads raise (placeholder reverting + // to '' on reused connections, or 42704 on fresh ones). job := &services.Job{ ID: jobID, Type: services.JobTypeScan, @@ -140,7 +147,7 @@ func (h *Handler) ScanLibrary(c *echo.Context) error { "trigger": "admin", }, Status: services.JobStatusPending, - Context: h.ctx, + Context: rls.WithUser(h.ctx, pgtype.UUID{Bytes: userUUID, Valid: true}), } if err := h.worker.EnqueueJob(job); err != nil { diff --git a/internal/handlers/websocket.go b/internal/handlers/websocket.go index 6899509d..1c850cfe 100644 --- a/internal/handlers/websocket.go +++ b/internal/handlers/websocket.go @@ -11,6 +11,8 @@ import ( jwt "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" + + "bookhoard/internal/rls" "github.com/gorilla/websocket" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v5" @@ -201,7 +203,10 @@ func (h *WSHandler) getInitialState(userID string) map[string]interface{} { pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true} - mediaItems, err := h.db.GetUserMediaItemsForSync(context.Background(), pgUserID) + // RLS plan §6: this read is the connection's OWN user's media — + // declare that identity explicitly (context.Background() carries + // nothing through ScopedDB, and media_items is RLS-governed). + mediaItems, err := h.db.GetUserMediaItemsForSync(rls.WithUser(context.Background(), pgUserID), pgUserID) if err != nil { return map[string]interface{}{"error": "failed to fetch initial state"} } diff --git a/internal/router/router.go b/internal/router/router.go index 0f142825..695fe34b 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -427,7 +427,15 @@ func RegisterRoutes(cfg *Config) *handlers.Handler { // startup scans for disk I/O. go func() { time.Sleep(30 * time.Second) - services.NewHashBackfillService(cfg.Queries).Run(context.Background()) + // RLS plan §6: this self-heal sweeps ALL media — run it as the + // first admin (the scanner's documented fallback identity); + // a bare Background context makes governed reads raise. + adminID, err := cfg.Queries.GetFirstAdmin(context.Background()) + if err != nil { + log.Printf("[HASH-BACKFILL] no admin found, skipping backfill: %v", err) + return + } + services.NewHashBackfillService(cfg.Queries).Run(rls.WithUser(context.Background(), adminID)) }() // Register progress routes with actual handler diff --git a/internal/services/media_scanner.go b/internal/services/media_scanner.go index f3ca45b4..0dc958ae 100644 --- a/internal/services/media_scanner.go +++ b/internal/services/media_scanner.go @@ -43,6 +43,8 @@ import ( epub "github.com/ArcadiaLin/go-epub" "github.com/fsnotify/fsnotify" "github.com/google/uuid" + + "bookhoard/internal/rls" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/nwaples/rardecode" @@ -621,17 +623,23 @@ func (s *MediaScanner) enqueueLibraryScan(rootFolder, trigger string) { folderPaths[i] = f.FolderPath } + // The acting admin: the library owner, or the first admin as the + // documented fallback (existing pattern). Used BOTH for the job's + // UserID and — since RLS (plan §6) — as the identity the job's + // media reads run under via its Context. adminIDStr := "" - if libRow.CreatedByAdminID.Valid { - adminIDStr = uuid.UUID(libRow.CreatedByAdminID.Bytes).String() + adminUUID := libRow.CreatedByAdminID + if adminUUID.Valid { + adminIDStr = uuid.UUID(adminUUID.Bytes).String() } - if adminIDStr == "" { + if !adminUUID.Valid { fmt.Printf("[MTIME-POLL] Library has no owner, falling back to first admin\n") fallbackAdmin, err := s.db.GetFirstAdmin(context.Background()) if err != nil { fmt.Printf("[MTIME-POLL] Warning: no admin found in database, skipping scan\n") return } + adminUUID = fallbackAdmin adminIDStr = uuid.UUID(fallbackAdmin.Bytes).String() } @@ -650,7 +658,7 @@ func (s *MediaScanner) enqueueLibraryScan(rootFolder, trigger string) { Type: JobTypeScan, Status: JobStatusPending, UserID: adminIDStr, - Context: context.Background(), + Context: rls.WithUser(context.Background(), adminUUID), Params: map[string]any{ "library_id": libraryIDStr, "folders": folderPaths, @@ -4771,7 +4779,7 @@ func (s *MediaScanner) performInitialScan(ctx context.Context) { Type: JobTypeScan, Status: JobStatusPending, UserID: adminIDStr, - Context: context.Background(), + Context: rls.WithUser(ctx, s.adminID), // RLS plan §6: startup scans run as the configured admin Params: map[string]any{ "library_id": libraryIDStr, "folders": folderPaths,