Files
mule-image/.hermes/plans/2026-06-06_120000-photos_users-label-isolation.md
dtoro 7df1c04c0f sidecar: scoped photos/timeline proxy (fixes review + archive leak)
- New GET /api/sidecar/timeline — proxies PP's /api/v1/photos and
  post-filters by FileName prefix matching the user's BasePath
- Also works for review/archive views (q=review:true, q=archived:true)
- Frontend route uses /timeline to avoid Gin route conflict with
  existing /photos/:uid/marks pattern
2026-06-06 20:11:07 +02:00

11 KiB
Raw Blame History

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:

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:

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.

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 filePhotoUser 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:

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 SearchPhotosUserPhotossearchPhotosScopePhotosForSession Yes
GET /api/v1/labels SearchLabelssearch.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:

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:

DELETE FROM photoprism.photos_users WHERE user_uid = 'utg7jjbd8iwaghn6';