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:
@@ -177,6 +177,21 @@ services:
|
|||||||
# mariadb/init/01-sidecar.sql on first boot of the mariadb volume.
|
# mariadb/init/01-sidecar.sql on first boot of the mariadb volume.
|
||||||
SIDECAR_DB_PASSWORD: ${SIDECAR_DB_PASSWORD:-replace-at-m4-bringup}
|
SIDECAR_DB_PASSWORD: ${SIDECAR_DB_PASSWORD:-replace-at-m4-bringup}
|
||||||
SIDECAR_DB_NAME: mule_sidecar
|
SIDECAR_DB_NAME: mule_sidecar
|
||||||
|
# Second DB connection for poking PhotoPrism's own schema (only
|
||||||
|
# used by the user-basepath reconciler today). Stays inert if
|
||||||
|
# PP_DB_PASSWORD is empty — the reconciler then silently no-ops.
|
||||||
|
PP_DB_HOST: mariadb
|
||||||
|
PP_DB_PORT: "3306"
|
||||||
|
PP_DB_USER: ${PP_DB_USER:-photoprism}
|
||||||
|
PP_DB_PASSWORD: ${PP_DB_PASSWORD:-}
|
||||||
|
PP_DB_NAME: ${PP_DB_NAME:-photoprism}
|
||||||
|
# Declarative username → originals-relative BasePath mapping.
|
||||||
|
# Format: comma-separated `user:path` pairs. Sidecar applies it
|
||||||
|
# to auth_users on boot and every 60s, and `mkdir -p`s each
|
||||||
|
# target subdirectory so PhotoPrism's ACL filter has somewhere to
|
||||||
|
# point. Leave empty to disable.
|
||||||
|
# USER_BASEPATHS="test:test, alice:family/alice"
|
||||||
|
USER_BASEPATHS: ${USER_BASEPATHS:-}
|
||||||
volumes:
|
volumes:
|
||||||
# Sidecar mutates originals (rename, folder mutations, heap
|
# Sidecar mutates originals (rename, folder mutations, heap
|
||||||
# convert) — always rw regardless of PhotoPrism's mount mode.
|
# convert) — always rw regardless of PhotoPrism's mount mode.
|
||||||
|
|||||||
@@ -15,6 +15,19 @@ type Config struct {
|
|||||||
ListenAddr string // bind interface — 127.0.0.1 for host mode, 0.0.0.0 in containers
|
ListenAddr string // bind interface — 127.0.0.1 for host mode, 0.0.0.0 in containers
|
||||||
Port int // HTTP listen port
|
Port int // HTTP listen port
|
||||||
DSN string // GORM/MySQL connection string for mule_sidecar
|
DSN string // GORM/MySQL connection string for mule_sidecar
|
||||||
|
// PpDSN is a second DB connection string pointed at PhotoPrism's own
|
||||||
|
// schema (`photoprism.*`). Sidecar code that needs to mutate
|
||||||
|
// PhotoPrism-managed rows (e.g. auth_users.base_path) opens its own
|
||||||
|
// connection with these creds rather than asking for grants on the
|
||||||
|
// mule_sidecar user. Empty if PP_DB_PASSWORD isn't provided, in
|
||||||
|
// which case PP-touching features (user-basepath reconciler) stay
|
||||||
|
// dormant.
|
||||||
|
PpDSN string
|
||||||
|
// UserBasepaths is the parsed `USER_BASEPATHS` env. Maps PhotoPrism
|
||||||
|
// usernames to originals-relative base paths so OIDC-provisioned
|
||||||
|
// users land with the right library scope without any admin
|
||||||
|
// touching `photoprism users mod`.
|
||||||
|
UserBasepaths map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() (*Config, error) {
|
func loadConfig() (*Config, error) {
|
||||||
@@ -45,12 +58,29 @@ func loadConfig() (*Config, error) {
|
|||||||
"?charset=utf8mb4&parseTime=true&loc=Local"
|
"?charset=utf8mb4&parseTime=true&loc=Local"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PhotoPrism schema connection — only used by the user-basepath
|
||||||
|
// reconciler. Stays empty if PP_DB_PASSWORD isn't set, and callers
|
||||||
|
// gate behaviour on that. We use PhotoPrism's own DB user rather
|
||||||
|
// than the sidecar's because `mule_sidecar` has no grants on
|
||||||
|
// `photoprism.*` (see mariadb/init/01-sidecar.sql).
|
||||||
|
ppDSN := ""
|
||||||
|
if ppPass := os.Getenv("PP_DB_PASSWORD"); ppPass != "" {
|
||||||
|
ppUser := envOr("PP_DB_USER", "photoprism")
|
||||||
|
ppHost := envOr("PP_DB_HOST", envOr("SIDECAR_DB_HOST", "mariadb"))
|
||||||
|
ppPort := envOr("PP_DB_PORT", envOr("SIDECAR_DB_PORT", "3306"))
|
||||||
|
ppName := envOr("PP_DB_NAME", "photoprism")
|
||||||
|
ppDSN = ppUser + ":" + ppPass + "@tcp(" + ppHost + ":" + ppPort + ")/" + ppName +
|
||||||
|
"?charset=utf8mb4&parseTime=true&loc=Local"
|
||||||
|
}
|
||||||
|
|
||||||
return &Config{
|
return &Config{
|
||||||
OriginalsRoot: abs,
|
OriginalsRoot: abs,
|
||||||
PhotoprismBaseURL: envOr("PHOTOPRISM_BASE_URL", "http://localhost:2342"),
|
PhotoprismBaseURL: envOr("PHOTOPRISM_BASE_URL", "http://localhost:2342"),
|
||||||
ListenAddr: envOr("SIDECAR_LISTEN_ADDR", "127.0.0.1"),
|
ListenAddr: envOr("SIDECAR_LISTEN_ADDR", "127.0.0.1"),
|
||||||
Port: port,
|
Port: port,
|
||||||
DSN: dsn,
|
DSN: dsn,
|
||||||
|
PpDSN: ppDSN,
|
||||||
|
UserBasepaths: parseUserBasepaths(os.Getenv("USER_BASEPATHS")),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ func main() {
|
|||||||
}
|
}
|
||||||
pp := newPPClient(cfg.PhotoprismBaseURL)
|
pp := newPPClient(cfg.PhotoprismBaseURL)
|
||||||
|
|
||||||
|
// Apply any declared username→BasePath mapping to PhotoPrism's
|
||||||
|
// auth_users table. Runs immediately + every 60s thereafter so a
|
||||||
|
// user who logs in after the sidecar booted still gets their
|
||||||
|
// BasePath wired without an admin restart.
|
||||||
|
startUserBasepathReconciler(cfg)
|
||||||
|
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
// Keep `%2F` literal in path params so callers can pass URL-encoded
|
// Keep `%2F` literal in path params so callers can pass URL-encoded
|
||||||
|
|||||||
124
sidecar/users.go
Normal file
124
sidecar/users.go
Normal 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()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user