# Plan: Populate `photos_users` to fix label isolation in PhotoPrism **Date:** 2026-06-06 **Author:** Hermes Agent **Status:** Draft --- ## 1. Goal Fix the label isolation leak where a user with `base_path` set (e.g. `muli`) sees photos from other users' directories (e.g. `dtoro`) in PhotoPrism's labels view. ## 2. Current Context ### The problem - PhotoPrism's `base_path` feature correctly scopes the main search (`/api/v1/photos`). - Label views (`/api/v1/labels`) do **not** respect `base_path` — labels show photo counts and thumbnails from the entire library. - User reports: "all photos on the main labels page are a mix of both" muli and dtoro. ### What we know - **53 files changed** in the sidecar (Go + GORM, gorm.io/gorm v1.31.1). - Sidecar has a working PhotoPrism DB connection via `PpDSN` (user: `photoprism`, schema: `photoprism.*`). - The `photos_users` table exists in PhotoPrism's MariaDB schema but is **empty** (0 rows). - Schema of `photos_users`: ``` photos_users: uid varbinary(42) NOT NULL PRI (composite PK or single?) user_uid varbinary(42) NOT NULL PRI team_uid varbinary(42) YES MUL perm int(10) unsigned YES ``` - Known user UIDs: `dtoro=utfetfdk0so2z9zl`, `muli=utg7jjbd8iwaghn6` - Known base paths: `dtoro→dtoro`, `muli→muli` - The `photo_path` column in `photos` stores paths like `muli/files/Photo Archive...` or `dtoro/Memories/...` - Currently: ~88K photos, ~52K files indexed. ### The sidecar's current reconciler (`users.go`) - Runs every 60s. - Only calls `UPDATE auth_users SET base_path = ? WHERE user_name = ?`. - Does **not** touch `photos_users`. ### Unknowns 1. **Does PhotoPrism use `photos_users` for general label filtering?** The table appears designed for explicit sharing (e.g. share a specific photo with another user), not for base_path ACL. PhotoPrism may ignore `photos_users` in label queries. 2. **Performance impact**: 88K photos × 2 users = up to 176K rows. Could slow label queries. 3. **Side effects**: If `photos_users` controls sharing, adding auto-entries might break explicit share workflow. 4. **`perm` values**: Unclear what `perm` value grants "view" access. Likely a bitmap (bit 0 = view). ## 3. Proposed Approach ### Phase 1: Investigate (prove the approach works before building) **Step 1.1: Insert test rows into `photos_users` manually** On the production DB, insert a few `photos_users` entries for muli mapping to some of muli's own photos, plus one entry mapping to a dtoro photo. Use a guessed `perm` value (e.g. `1` = view). Then check: - Does muli see fewer photos now? (If `photos_users` works as an exclusive ACL, yes.) - Does the dtoro photo with a `photos_users` entry for muli show up for muli? - Does the label view change? **Step 1.2: Test with `perm` variations** If `perm=1` does nothing, try `perm=2`, `perm=7`, or `perm=15` (common Unix-ish bitmap patterns). **Step 1.3: Examine PhotoPrism source** Check PhotoPrism's search/label code to confirm whether `photos_users` is joined in label queries. This tells us definitively whether the approach is viable. ### Phase 2: Build (if Phase 1 confirms the approach works) **Step 2.1: Add `photos_users` GORM model** New struct in `db.go` or a new file `perms.go`: ```go type PhotoUser struct { PhotoUID string `gorm:"primaryKey;size:42;column:uid"` UserUID string `gorm:"primaryKey;size:42;column:user_uid"` TeamUID string `gorm:"size:42;column:team_uid"` Perm int `gorm:"column:perm"` } func (PhotoUser) TableName() string { return "photos_users" } ``` Note: GORM `AutoMigrate` is called on `mule_sidecar` schema, not `photoprism.*`. The `photos_users` table already exists in the `photoprism` schema — we only query/insert, never migrate. **Step 2.2: Add `reconcilePhotoUsers` function** New function in a new file `perms.go` alongside `users.go`. Signature: ```go func reconcilePhotoUsers(ppDSN, originalsRoot string, mapping map[string]string) error ``` Logic: 1. For each `username:path` pair in `mapping`, look up the user's `user_uid` in `auth_users`. 2. Query `photos` for all `photo_uid` where `photo_path LIKE 'path/%'`. 3. Batch-insert entries into `photos_users` with a default `perm` value (to be determined in Phase 1). 4. Use `INSERT IGNORE` or `ON DUPLICATE KEY UPDATE` for idempotency. 5. Handle deletions: if a photo's path is changed (via rename), the old `photos_users` entry should be cleaned up. **Step 2.3: Wire into reconciler loop** Extend the existing `startUserBasepathReconciler` to call `reconcilePhotoUsers` after `reconcileUserBasepaths`. ```go func apply() { reconcileUserBasepaths(...) reconcilePhotoUsers(...) } ``` **Step 2.4: Handle re-index edge cases** - When new photos are indexed, they won't have `photos_users` entries until the next 60s tick. - Could add a webhook or a one-shot trigger after PhotoPrism's index completes. - Alternative: accept the 60s lag as a design trade-off (current base_path reconciler already has this lag). ### Phase 3: Validate (if Phase 1 confirms) 1. Build the binary: `cd sidecar && CGO_ENABLED=0 go build -o mule-sidecar .` 2. Rebuild the Docker image and restart the sidecar. 3. Check `photos_users` has expected rows. 4. Log in as `muli` via Authentik SSO, browse labels — verify dtoro photos are gone. 5. Log in as `dtoro` — verify still sees own photos. 6. Verify no regression: search, album, folder views still work for both users. ## 4. Files Likely to Change | File | Change | |------|--------| | `sidecar/perms.go` | **New file** — `PhotoUser` model, `reconcilePhotoUsers` function | | `sidecar/db.go` | Add `photos_users`-related constants/helpers (optional) | | `sidecar/users.go` | Extend `reconcileUserBasepaths` or add a phase to the existing reconciler | | `sidecar/main.go` | Wire the new reconciler phase (minor — call from existing ticker) | | `sidecar/Dockerfile` | Unchanged (Go build picks up new `.go` files automatically) | ## 5. Tests & Validation 1. **Build check**: `go build ./...` from `sidecar/` 2. **Manual DB test** (Phase 1): Insert test `photos_users` rows via `docker exec pp-mariadb mysql ...` 3. **Integration test**: After deploy, check `photos_users` row count matches expected photo count per user. 4. **Label isolation check**: Browse labels as each user — confirm no cross-user leaks. ## 6. Source Code Analysis (Completed) ### How base_path scoping works in PhotoPrism Found the critical function `ScopePhotosForSession` in `internal/entity/search/photos_scope.go`: ```go func ScopePhotosForSession(stmt *gorm.DB, sess *entity.Session) *gorm.DB { // Admin/library role → no scoping needed if sess == nil || acl.Rules.AllowAny(acl.ResourcePhotos, sess.GetUserRole(), acl.Permissions{acl.AccessAll, acl.AccessLibrary}) { return stmt } user := sess.GetUser() if basePath := user.GetBasePath(); basePath == "" { return stmt.Where(sharedAlbums + "photos.created_by = ? OR ...", ...) } else { return stmt.Where(sharedAlbums + "... OR photos.photo_path = ? OR photos.photo_path LIKE ?", ..., basePath, basePath + "/%") } } ``` Key: base_path filtering is done by adding `WHERE photos.photo_path LIKE 'muli/%'` to the SQL query. It is **NOT** done via `photos_users`. ### How endpoints use base_path | Endpoint | Function | Applies base_path? | |----------|----------|-------------------| | `GET /api/v1/photos` | `SearchPhotos` → `UserPhotos` → `searchPhotos` → **`ScopePhotosForSession`** | ✅ Yes | | `GET /api/v1/labels` | `SearchLabels` → `search.Labels(frm)` — no session passed | ❌ **No** | | Review tab | Uses `GET /api/v1/photos?quality=3` → goes through `ScopePhotosForSession` | ✅ Should scope | | Archive tab | Uses `GET /api/v1/photos?archived=true` → goes through `ScopePhotosForSession` | ✅ Should scope | | Albums | TBD — depends on whether they use `ScopePhotosForSession` | ⚠️ Unknown | ### The `photos_users` table Found in `internal/entity/photo_user.go`: ```go type PhotoUser struct { UID string // photo_uid UserUID string // user_uid TeamUID string // team_uid Perm uint // permission bitmap } ``` This table is **not referenced** in `ScopePhotosForSession`, `searchPhotos`, or any label/album search function. It is only used for **explicit sharing** (via `FirstOrCreatePhotoUser` called when sharing a specific photo with another user). **Conclusion: Populating `photos_users` will NOT fix the label, review, or archive tab isolation.** PhotoPrism does not consult this table for any of these queries. ### Why review/archive might show cross-user photos Since review and archive use `GET /api/v1/photos` which goes through `ScopePhotosForSession`, they **should** be scoped. The issue might be: 1. **Pre-computed counts** in the sidebar tabs show total numbers across all users 2. **Label thumbnails** and category summaries are computed from the `labels` table which is global 3. The actual photo list in review/archive should be correctly scoped — the user may be seeing dtoro photos only in the summary/counts ### DB experiment results Confirmed `photos_users` is empty (0 rows). Inserted 100 muli-photo entries + 1 dtoro-photo entry for muli with `perm=1`. Label API response unchanged — `photo_count` values remained the same (Dog: 733, Cat: 57), confirming labels ignore `photos_users`. ## 7. Updated Recommendation **Abandon the `photos_users` approach.** It won't fix the problem because PhotoPrism never consults this table for labels, review, or archive queries. ### Real fix options 1. **Sidecar label filter** (recommended) — The sidecar already validates sessions via `resolveSession()` which returns the user's `BasePath`. Extend the sidecar to expose a **proxied `/api/v1/labels`** endpoint that: - Accepts the caller's `X-Auth-Token` (already validated by `requireSession`) - Forwards the request to PhotoPrism's `/api/v1/labels` - **Filters the response** to remove labels whose `Thumb` belongs to a photo outside the user's `base_path` - Recalculates `PhotoCount` for the user's scope (count photos under `base_path/%` for that label) - Also filter `Count` values in the sidebar summary response **Why this works:** The sidecar already has DB access to PhotoPrism's schema (`PpDSN`) and validates sessions. It can query `photos` to count label intersections per base_path. 2. **Same approach for review/archive sidebar counts** — Intercept the relevant metadata/summary endpoints to scope counts by base_path. 3. **Accept the limitation** — Labels show cross-user thumbnails/counts but the actual photo list is scoped. ### Implementation sketch for option 1 ``` sidecar/ ├── proxy.go # New file │ ├── handleLabels(c) → GET /api/sidecar/labels → proxies to PP, filters by base_path │ ├── handleReviewCount(c) → GET /api/sidecar/review → returns scoped count │ └── handleArchiveCount(c) → GET /api/sidecar/archive → returns scoped count ``` The SvelteKit frontend would call `/api/sidecar/labels` instead of `/api/v1/labels`. ### Clean up: remove test rows from photos_users Since the approach won't work, remove the test rows inserted during Phase 1: ```sql DELETE FROM photoprism.photos_users WHERE user_uid = 'utg7jjbd8iwaghn6'; ```