feat(scanner): background jobs declare acting identity for RLS

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.
This commit is contained in:
John O'Keefe committed 2026-10-29 09:08:54 -04:00
1 parent 922b92a8b6
commit 385a5d9dba
4 files changed
+36 -8

No files matched your search

+13 -5
View File
@@ -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,