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:
1 parent
922b92a8b6
commit
385a5d9dba
4 files changed
+36
-8
No files matched your search
@@ -8,6 +8,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"bookhoard/internal/rls"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
)
|
)
|
||||||
@@ -127,6 +129,11 @@ func (h *Handler) ScanLibrary(c *echo.Context) error {
|
|||||||
|
|
||||||
jobID := uuid.New().String()
|
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{
|
job := &services.Job{
|
||||||
ID: jobID,
|
ID: jobID,
|
||||||
Type: services.JobTypeScan,
|
Type: services.JobTypeScan,
|
||||||
@@ -140,7 +147,7 @@ func (h *Handler) ScanLibrary(c *echo.Context) error {
|
|||||||
"trigger": "admin",
|
"trigger": "admin",
|
||||||
},
|
},
|
||||||
Status: services.JobStatusPending,
|
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 {
|
if err := h.worker.EnqueueJob(job); err != nil {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
|
|
||||||
jwt "github.com/golang-jwt/jwt/v5"
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"bookhoard/internal/rls"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/labstack/echo/v5"
|
"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}
|
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 {
|
if err != nil {
|
||||||
return map[string]interface{}{"error": "failed to fetch initial state"}
|
return map[string]interface{}{"error": "failed to fetch initial state"}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -427,7 +427,15 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
|||||||
// startup scans for disk I/O.
|
// startup scans for disk I/O.
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(30 * time.Second)
|
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
|
// Register progress routes with actual handler
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ import (
|
|||||||
epub "github.com/ArcadiaLin/go-epub"
|
epub "github.com/ArcadiaLin/go-epub"
|
||||||
"github.com/fsnotify/fsnotify"
|
"github.com/fsnotify/fsnotify"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"bookhoard/internal/rls"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/nwaples/rardecode"
|
"github.com/nwaples/rardecode"
|
||||||
@@ -621,17 +623,23 @@ func (s *MediaScanner) enqueueLibraryScan(rootFolder, trigger string) {
|
|||||||
folderPaths[i] = f.FolderPath
|
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 := ""
|
adminIDStr := ""
|
||||||
if libRow.CreatedByAdminID.Valid {
|
adminUUID := libRow.CreatedByAdminID
|
||||||
adminIDStr = uuid.UUID(libRow.CreatedByAdminID.Bytes).String()
|
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")
|
fmt.Printf("[MTIME-POLL] Library has no owner, falling back to first admin\n")
|
||||||
fallbackAdmin, err := s.db.GetFirstAdmin(context.Background())
|
fallbackAdmin, err := s.db.GetFirstAdmin(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[MTIME-POLL] Warning: no admin found in database, skipping scan\n")
|
fmt.Printf("[MTIME-POLL] Warning: no admin found in database, skipping scan\n")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
adminUUID = fallbackAdmin
|
||||||
adminIDStr = uuid.UUID(fallbackAdmin.Bytes).String()
|
adminIDStr = uuid.UUID(fallbackAdmin.Bytes).String()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,7 +658,7 @@ func (s *MediaScanner) enqueueLibraryScan(rootFolder, trigger string) {
|
|||||||
Type: JobTypeScan,
|
Type: JobTypeScan,
|
||||||
Status: JobStatusPending,
|
Status: JobStatusPending,
|
||||||
UserID: adminIDStr,
|
UserID: adminIDStr,
|
||||||
Context: context.Background(),
|
Context: rls.WithUser(context.Background(), adminUUID),
|
||||||
Params: map[string]any{
|
Params: map[string]any{
|
||||||
"library_id": libraryIDStr,
|
"library_id": libraryIDStr,
|
||||||
"folders": folderPaths,
|
"folders": folderPaths,
|
||||||
@@ -4771,7 +4779,7 @@ func (s *MediaScanner) performInitialScan(ctx context.Context) {
|
|||||||
Type: JobTypeScan,
|
Type: JobTypeScan,
|
||||||
Status: JobStatusPending,
|
Status: JobStatusPending,
|
||||||
UserID: adminIDStr,
|
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{
|
Params: map[string]any{
|
||||||
"library_id": libraryIDStr,
|
"library_id": libraryIDStr,
|
||||||
"folders": folderPaths,
|
"folders": folderPaths,
|
||||||
|
|||||||
Reference in new issue
Block a user