sidecar: declarative USER_BASEPATHS reconciler

PhotoPrism's OSS edition has no way to map OIDC claims to BasePath, so
every freshly-registered OIDC user lands with BasePath="" and either
sees the whole library (admin) or nothing (guest) — never their own
subfolder.

Introduces a sidecar-driven reconciler with a single env knob the
admin sets in docker-compose / .env.photoprism:

  USER_BASEPATHS="test:test, alice:family/alice, bob:bob"

(`user:originals-relative-path` pairs, comma-separated.) On boot and
every 60s thereafter the sidecar:
  - mkdir -p's the target subdirectory under ORIGINALS_ROOT so
    PhotoPrism's path: ACL filter has somewhere real to point;
  - UPDATEs photoprism.auth_users.base_path for the matching row
    where it differs (idempotent, missing users skipped — they
    materialise on first OIDC login and the next pass catches them).

The reconciler uses a separate gorm connection scoped to the
`photoprism` schema with PhotoPrism's own DB user, since the existing
`sidecar` user only has grants on `mule_sidecar.*`. Connection stays
dormant when PP_DB_PASSWORD is empty — the feature is opt-in via env.

Compose changes: thread PP_DB_* + USER_BASEPATHS through to the
sidecar service. New users.go file isolates the reconciler logic;
main.go calls startUserBasepathReconciler() during boot.
This commit is contained in:
Claudio
2026-05-18 20:25:19 +00:00
parent 85847848c4
commit 2a75896274
4 changed files with 175 additions and 0 deletions

124
sidecar/users.go Normal file
View File

@@ -0,0 +1,124 @@
package main
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// PhotoPrism's open-source edition doesn't expose any way to map OIDC
// claims to a per-user BasePath — every newly-registered OIDC user
// lands with `BasePath = ""`, which means PhotoPrism's ACL filter
// shows them nothing (non-admin) or everything (admin). Neither is the
// per-user library scope a homelab admin typically wants.
//
// This reconciler reads a declarative `USER_BASEPATHS` env at sidecar
// startup, formatted as `username:originals-relative-path` pairs
// separated by commas (whitespace tolerated), e.g.:
//
// USER_BASEPATHS="test:test, alice:family/alice, bob:bob"
//
// For every entry the sidecar:
// 1. Ensures the originals subdirectory exists (so PhotoPrism's path:
// filter has somewhere to point — empty dirs are fine).
// 2. UPDATEs `photoprism.auth_users.base_path` for the matching user
// row if the current value differs. Idempotent: rows already
// matching are skipped, and missing users are no-ops (they'll
// materialise when they log in via OIDC; the periodic ticker
// catches them on the next pass).
//
// A 60-second ticker keeps the mapping in lockstep with new OIDC
// registrations without needing a webhook.
func parseUserBasepaths(raw string) map[string]string {
out := map[string]string{}
for _, p := range strings.Split(raw, ",") {
p = strings.TrimSpace(p)
if p == "" {
continue
}
bits := strings.SplitN(p, ":", 2)
if len(bits) != 2 {
continue
}
u := strings.TrimSpace(bits[0])
// Strip any leading/trailing slash so the value lands in
// auth_users.base_path the same way PhotoPrism's own user-edit
// UI persists it (relative, no slashes).
path := strings.Trim(strings.TrimSpace(bits[1]), "/")
if u == "" || path == "" {
continue
}
out[u] = path
}
return out
}
func reconcileUserBasepaths(ppDSN, originalsRoot string, mapping map[string]string) error {
if len(mapping) == 0 {
return nil
}
if ppDSN == "" {
return fmt.Errorf("USER_BASEPATHS set but PP_DB_PASSWORD missing — can't reach photoprism schema")
}
db, err := gorm.Open(mysql.Open(ppDSN), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
})
if err != nil {
return fmt.Errorf("open pp db: %w", err)
}
if sqlDB, derr := db.DB(); derr == nil {
defer sqlDB.Close()
}
for username, path := range mapping {
target := filepath.Join(originalsRoot, path)
if err := os.MkdirAll(target, 0o775); err != nil {
slog.Warn("user-basepath: mkdir failed", "user", username, "path", target, "err", err)
// Continue — the DB update is still useful so PhotoPrism's
// ACL kicks in even if the directory is created later.
}
res := db.Exec(`UPDATE auth_users
SET base_path = ?
WHERE user_name = ?
AND COALESCE(base_path, '') <> ?
AND deleted_at IS NULL`,
path, username, path)
if res.Error != nil {
slog.Error("user-basepath: update failed", "user", username, "err", res.Error)
continue
}
if res.RowsAffected > 0 {
slog.Info("user-basepath: set", "user", username, "path", path)
}
}
return nil
}
func startUserBasepathReconciler(cfg *Config) {
if len(cfg.UserBasepaths) == 0 {
return
}
slog.Info("user-basepath: starting reconciler", "entries", len(cfg.UserBasepaths))
apply := func() {
if err := reconcileUserBasepaths(cfg.PpDSN, cfg.OriginalsRoot, cfg.UserBasepaths); err != nil {
slog.Warn("user-basepath: reconciliation error", "err", err)
}
}
apply()
go func() {
t := time.NewTicker(60 * time.Second)
defer t.Stop()
for range t.C {
apply()
}
}()
}