From 385a5d9dba5c465a11a7bb024270a35bcea0d47f Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 29 Oct 2026 09:08:54 -0400 Subject: [PATCH] feat(scanner): background jobs declare acting identity for RLS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of the RLS plan: detached contexts (context.Background()) that reach media_items now carry identities — with the policies live, a bare context made governed reads raise (fresh connections: 42704 unrecognized parameter; reused pool connections: 22P02 empty-string cast after the LOCAL GUC reverted — the plan's A2 signature). - scanner jobs (API-triggered, mtime-poll, initial scan): the job's Context now carries the acting admin's identity — the library owner, first-admin fallback (the job's UserID already encoded this trust decision; the Context just needed to carry it). performInitialScan uses the scanner's configured admin. - websocket initial-sync read (GetUserMediaItemsForSync): runs as the connection's own user. - hash backfill: runs as first admin (server self-heal over all media). Verified on the dev stack: forced rescan of the 1199-file Audiobooks library completed with 0 errors under RLS (previously every SHA lookup and create raised); hash-backfill refusals gone. The sync queue processor needs no identity — sync_queue is not an RLS-governed table. Known remaining detached reads (conflicts/analytics handlers using context.Background()) are HTTP-path triage, enumerated by the full suite next. --- internal/handlers/scanner.go | 9 ++++++++- internal/handlers/websocket.go | 7 ++++++- internal/router/router.go | 10 +++++++++- internal/services/media_scanner.go | 18 +++++++++++++----- 4 files changed, 36 insertions(+), 8 deletions(-) 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,