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

View File

@@ -15,6 +15,19 @@ type Config struct {
ListenAddr string // bind interface — 127.0.0.1 for host mode, 0.0.0.0 in containers
Port int // HTTP listen port
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) {
@@ -45,12 +58,29 @@ func loadConfig() (*Config, error) {
"?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{
OriginalsRoot: abs,
PhotoprismBaseURL: envOr("PHOTOPRISM_BASE_URL", "http://localhost:2342"),
ListenAddr: envOr("SIDECAR_LISTEN_ADDR", "127.0.0.1"),
Port: port,
DSN: dsn,
PpDSN: ppDSN,
UserBasepaths: parseUserBasepaths(os.Getenv("USER_BASEPATHS")),
}, nil
}