feat(scanner): scanner entrypoints + generic jobs carry RLS identity; SeedDB harness fallback
The full suite enumerated the remaining triage class: tests drive the scanner directly (ScanFolders(context.Background())) and read seeded media via Queries with bare contexts — every governed read raised. - MediaScanner.ScanFolders self-declares its configured admin (SetAdminID — set by main AND the test helpers): a scan is server maintenance, product-correct regardless of caller. - ScopedDB gains a Fallback identity, exposed as NewSeedDB for the TEST HARNESS ONLY: setup.DB falls back to rls.SeedIdentity when the context carries none, so direct seeding/verification helpers work uniformly instead of per-site context threading. rls.SeedIdentity is the shared constant (no library_visibility rows => full visibility). - generic job creation (jobs.go) stamps the requesting admin into the job Context, same as the scan endpoint. The sync queue processor stays identity-free: sync_queue is not an RLS-governed table.
This commit is contained in:
1 parent
385a5d9dba
commit
bb2cc26e9d
9 files changed
+67
-30
No files matched your search
@@ -318,15 +318,15 @@ func createRegularUserOnce(t *testing.T, db *database.Queries) UserTestData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedCtx is the context harness-level media seeding runs under. The
|
// seedCtx is the context harness-level media seeding runs under. It
|
||||||
// seed identity is a valid UUID with NO library_visibility rows, so
|
// carries rls.SeedIdentity — a valid UUID with NO library_visibility
|
||||||
// RLS grants it full visibility — needed because sqlc's :one inserts
|
// rows, so RLS grants it full visibility (needed because sqlc :one
|
||||||
// use RETURNING, which requires SELECT visibility of the new row.
|
// inserts use RETURNING, which requires SELECT visibility of the new
|
||||||
// Product code never uses this; requests carry real personas.
|
// row). setup.DB additionally falls back to this identity for calls
|
||||||
var seedIdentity = pgtype.UUID{Bytes: [16]byte(uuid.MustParse("00000000-0000-0000-0000-000000000002")), Valid: true}
|
// that pass a bare context. Product code never uses this; requests
|
||||||
|
// carry real personas.
|
||||||
func seedCtx() context.Context {
|
func seedCtx() context.Context {
|
||||||
return rls.WithUser(context.Background(), seedIdentity)
|
return rls.WithUser(context.Background(), rls.SeedIdentity)
|
||||||
}
|
}
|
||||||
|
|
||||||
// uuidToPGType converts uuid.UUID to pgtype.UUID
|
// 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.
|
// writes everything. Real personas only exist at the HTTP layer.
|
||||||
scopedDB := database.NewScopedDB(dbPool)
|
scopedDB := database.NewScopedDB(dbPool)
|
||||||
queries := database.New(scopedDB)
|
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
|
// Wire the tunable settings registry exactly like main.go: Load
|
||||||
// populates the cache (missing rows fall back to compiled defaults)
|
// populates the cache (missing rows fall back to compiled defaults)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"bookhoard/internal/rls"
|
"bookhoard/internal/rls"
|
||||||
@@ -35,9 +36,11 @@ import (
|
|||||||
var _ DBTX = (*ScopedDB)(nil)
|
var _ DBTX = (*ScopedDB)(nil)
|
||||||
|
|
||||||
// ScopedDB implements DBTX over a pool, declaring the acting user for
|
// 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 {
|
type ScopedDB struct {
|
||||||
Pool *pgxpool.Pool
|
Pool *pgxpool.Pool
|
||||||
|
Fallback *pgtype.UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewScopedDB is the constructor used at the single Queries
|
// NewScopedDB is the constructor used at the single Queries
|
||||||
@@ -46,6 +49,15 @@ func NewScopedDB(pool *pgxpool.Pool) *ScopedDB {
|
|||||||
return &ScopedDB{Pool: pool}
|
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"
|
const zeroUUID = "00000000-0000-0000-0000-000000000000"
|
||||||
|
|
||||||
// begin opens the per-statement transaction. If the context carries a
|
// 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 {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err := rls.DeclareUser(ctx, tx, uid); err != nil {
|
||||||
_ = tx.Rollback(ctx)
|
_ = tx.Rollback(ctx)
|
||||||
return nil, fmt.Errorf("rls: declare user: %w", err)
|
return nil, fmt.Errorf("rls: declare user: %w", err)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/utils"
|
"bookhoard/internal/utils"
|
||||||
"context"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -102,7 +101,7 @@ func (h *AnalyticsHandler) GetReadingStats(c *echo.Context) error {
|
|||||||
// "today") never appear.
|
// "today") never appear.
|
||||||
endTime = endTime.AddDate(0, 0, 1).Add(-time.Nanosecond)
|
endTime = endTime.AddDate(0, 0, 1).Add(-time.Nanosecond)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := c.Request().Context()
|
||||||
|
|
||||||
history, err := h.db.GetUserReadingHistory(ctx, database.GetUserReadingHistoryParams{
|
history, err := h.db.GetUserReadingHistory(ctx, database.GetUserReadingHistoryParams{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
@@ -220,7 +219,7 @@ func (h *AnalyticsHandler) calculateMostActiveDay(history []database.GetUserRead
|
|||||||
func (h *AnalyticsHandler) GetDeviceUsage(c *echo.Context) error {
|
func (h *AnalyticsHandler) GetDeviceUsage(c *echo.Context) error {
|
||||||
user := c.Get("user").(database.Users)
|
user := c.Get("user").(database.Users)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := c.Request().Context()
|
||||||
|
|
||||||
usage, err := h.db.GetUserDeviceUsage(ctx, user.ID)
|
usage, err := h.db.GetUserDeviceUsage(ctx, user.ID)
|
||||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
@@ -268,7 +267,7 @@ func (h *AnalyticsHandler) GetPopularBooks(c *echo.Context) error {
|
|||||||
limitInt = parsedLimit
|
limitInt = parsedLimit
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := c.Request().Context()
|
||||||
|
|
||||||
books, err := h.db.GetPopularBooks(ctx, database.GetPopularBooksParams{
|
books, err := h.db.GetPopularBooks(ctx, database.GetPopularBooksParams{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ func (h *ConflictHandler) GetConflict(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
|
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 err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
|
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")
|
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 {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get media item")
|
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}
|
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 err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
|
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
|
||||||
@@ -283,7 +283,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
|||||||
|
|
||||||
resolutionDataJSON, _ := json.Marshal(resolutionData)
|
resolutionDataJSON, _ := json.Marshal(resolutionData)
|
||||||
|
|
||||||
_, err = h.db.ResolveSyncConflict(context.Background(), database.ResolveSyncConflictParams{
|
_, err = h.db.ResolveSyncConflict(c.Request().Context(), database.ResolveSyncConflictParams{
|
||||||
ID: conflictUUID,
|
ID: conflictUUID,
|
||||||
ResolutionStatus: pgtype.Text{String: "user_resolved", Valid: true},
|
ResolutionStatus: pgtype.Text{String: "user_resolved", Valid: true},
|
||||||
ResolutionData: resolutionDataJSON,
|
ResolutionData: resolutionDataJSON,
|
||||||
@@ -530,7 +530,7 @@ func (h *ConflictHandler) DeleteConflict(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
|
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 err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
|
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")
|
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")
|
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 {
|
func (h *ConflictHandler) DismissAllResolved(c *echo.Context) error {
|
||||||
user := c.Get("user").(database.Users)
|
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 {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to list conflicts")
|
return echo.NewHTTPError(http.StatusInternalServerError, "failed to list conflicts")
|
||||||
}
|
}
|
||||||
@@ -560,7 +560,7 @@ func (h *ConflictHandler) DismissAllResolved(c *echo.Context) error {
|
|||||||
deleted := 0
|
deleted := 0
|
||||||
for _, conflict := range conflicts {
|
for _, conflict := range conflicts {
|
||||||
if conflict.ResolutionStatus.String == "user_resolved" || conflict.ResolutionStatus.String == "bulk_resolved" {
|
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++
|
deleted++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -620,7 +620,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
|
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 err != nil {
|
||||||
results = append(results, ConflictResult{
|
results = append(results, ConflictResult{
|
||||||
ConflictID: conflictIDStr,
|
ConflictID: conflictIDStr,
|
||||||
@@ -719,7 +719,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
resolutionDataJSON, _ := json.Marshal(resolutionData)
|
resolutionDataJSON, _ := json.Marshal(resolutionData)
|
||||||
|
|
||||||
_, err = h.db.ResolveSyncConflict(context.Background(), database.ResolveSyncConflictParams{
|
_, err = h.db.ResolveSyncConflict(c.Request().Context(), database.ResolveSyncConflictParams{
|
||||||
ID: conflictUUID,
|
ID: conflictUUID,
|
||||||
ResolutionStatus: pgtype.Text{String: "bulk_resolved", Valid: true},
|
ResolutionStatus: pgtype.Text{String: "bulk_resolved", Valid: true},
|
||||||
ResolutionData: resolutionDataJSON,
|
ResolutionData: resolutionDataJSON,
|
||||||
@@ -817,7 +817,7 @@ func (h *ConflictHandler) BulkDismissConflicts(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
|
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 err != nil {
|
||||||
results = append(results, ConflictResult{
|
results = append(results, ConflictResult{
|
||||||
ConflictID: conflictIDStr,
|
ConflictID: conflictIDStr,
|
||||||
@@ -838,7 +838,7 @@ func (h *ConflictHandler) BulkDismissConflicts(c *echo.Context) error {
|
|||||||
continue
|
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{
|
results = append(results, ConflictResult{
|
||||||
ConflictID: conflictIDStr,
|
ConflictID: conflictIDStr,
|
||||||
Status: "error",
|
Status: "error",
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/rls"
|
||||||
"bookhoard/internal/services"
|
"bookhoard/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,12 +52,17 @@ func (h *JobsHandler) CreateJob(c *echo.Context) error {
|
|||||||
// Add database to params
|
// Add database to params
|
||||||
req.Params["db"] = h.db
|
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
|
// Create job
|
||||||
job := &services.Job{
|
job := &services.Job{
|
||||||
ID: uuid.New().String(),
|
ID: uuid.New().String(),
|
||||||
Type: jobType,
|
Type: jobType,
|
||||||
Params: req.Params,
|
Params: req.Params,
|
||||||
Status: services.JobStatusPending,
|
Status: services.JobStatusPending,
|
||||||
|
Context: rls.WithUser(context.Background(), pgtype.UUID{Bytes: userUUID, Valid: true}),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enqueue job
|
// Enqueue job
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
wsync "bookhoard/internal/sync"
|
wsync "bookhoard/internal/sync"
|
||||||
"bookhoard/internal/utils"
|
"bookhoard/internal/utils"
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"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},
|
UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
|
||||||
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
|
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
|
||||||
Limit: int32(limit),
|
Limit: int32(limit),
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ func (h *WSHandler) getInitialState(userID string) map[string]interface{} {
|
|||||||
|
|
||||||
progressMap := make(map[string]map[string]interface{})
|
progressMap := make(map[string]map[string]interface{})
|
||||||
for _, item := range mediaItems {
|
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,
|
MediaItemID: item.ID,
|
||||||
UserID: pgUserID,
|
UserID: pgUserID,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ package rls
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"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)
|
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
|
// DeclareUser sets the RLS identity GUC inside an ALREADY-OPEN
|
||||||
// transaction. The `true` argument is is_local: transaction-scoped,
|
// transaction. The `true` argument is is_local: transaction-scoped,
|
||||||
// evaporating at COMMIT/ROLLBACK — NEVER session-scoped on a pooled
|
// evaporating at COMMIT/ROLLBACK — NEVER session-scoped on a pooled
|
||||||
|
|||||||
@@ -675,6 +675,13 @@ func (s *MediaScanner) enqueueLibraryScan(rootFolder, trigger string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
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
|
// Cancellation is checked at every stage boundary and inside the
|
||||||
// walk callback: the in-flight file finishes, then the scan aborts
|
// walk callback: the in-flight file finishes, then the scan aborts
|
||||||
// and reports context.Canceled (processJob classifies that as a
|
// and reports context.Canceled (processJob classifies that as a
|
||||||
|
|||||||
Reference in new issue
Block a user