Media access is uploader-scoped, not project-scoped — collaborator attachments are invisible to the project owner #135
Labels
No labels
bug
duplicate
enhancement
future
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
rbrooks/TeaLeaves#135
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: Medium · Confidence: High · Effort: M · Category: code (access control)
Problem
Every media route authorizes on
media.user_id = <viewer>rather than on access to the media's project:api/src/routes/media.ts:153—GET /:id(serve file):WHERE id = $1 AND user_id = $2api/src/routes/media.ts:179—GET /entry/:entryId(list an entry's media):WHERE entry_id = $1 AND user_id = $2:115thumbnail,:137transcription,:193delete,:217,:239,:316,:357,:370similarEntries moved to project-scoped access in v4 (
ACCESSIBLE_PROJECTS_SQL/ACTIVE_PROJECT_SQL, migration 026). Media never followed — it still uses the pre-project ownership model.Impact
Today, with no deletion involved: if a collaborator uploads media to an entry in a project you own,
media.user_idis their id — soGET /api/media/entry/:entryIdreturns nothing for you andGET /api/media/:id404s. You can see their entry but not its attachments.Interaction with #98 (v7.2.0): #98 changed
media.user_idtoON DELETE SET NULLso a departed collaborator's attachments are no longer destroyed along with the entries that survive them. That fix is correct and necessary — the rows and files are now preserved rather than silently deleted — butuser_id = NULLnever matchesuser_id = $viewer, so those preserved attachments remain unreachable through the API until access is project-scoped. #98 stops the data loss; this issue is what makes the data usable again.Fix
Re-scope the media routes from
user_id = $viewerto the entry's project, mirroring entries:user_idthen becomes what it should be — provenance/attribution, not authorization. Worth auditing whether delete should stay stricter (uploader or project owner) rather than any project member.Acceptance criteria
user_id IS NULL) is reachable by users with access to that project.Notes
Found while implementing #98 during v7.2.0 — the audit didn't catch this because it read the FK change in isolation. Filed separately rather than widening #98's scope: #98 is a one-migration data-preservation fix, this is a multi-route access-control change with its own authorization decisions.
Fixed in
c7ff1a2(v7.2.0).Every media route authorized on
media.user_id = <viewer>— a pre-v4 ownership check that never followed entries into the project model. Re-scoped the whole surface to the media's project:GET /:idserve,/:id/thumbnail,/:id/transcription,/entry/:entryIdlist,/:id/analyze-region,/:id/similar— both the source lookup and the candidate set) now go through aloadAccessibleMedia()helper that joinsentry → projectand checksACCESSIBLE_PROJECTS_SQL. Upload verifies the target entry is in an accessible project rather than authored by the uploader.annotations.tscarried the same uploader-scoped media guard — fixed, so you can annotate any media you can access. Annotation ownership is left per-user; whether annotations should be shared project-wide is a separate product question, flagged not decided.Acceptance criteria:
user_id IS NULL) is reachable by users with access to that project — this is the #98 interaction, now closed: #98 stopped the deletion, this makes the preserved media reachable.Tests —
api/src/test/integration/mediaAccess.test.ts, a real four-user matrix (owner / collaborator / second-editor / outsider via the/__test_loginbackdoor): owner sees the collaborator's upload, null-uploader media stays reachable, a non-member gets 404, and the full delete matrix (uploader ✓, owner ✓, other-editor ✗ 404, outsider ✗ 404).Caught while doing it: the
/similarrewrite would have used.replace('$1', '$2')onACCESSIBLE_PROJECTS_SQL, which references$1twice —String.replaceonly swaps the first, leaving the second$1bound to the embedding vector. Reordered the params souserIdis$1instead. Worth noting since that fragment is used across the codebase — a.replaceon it is a trap.Verified on the dev server: tsc clean, 268/268, deployed and healthy. All three CI jobs green on
c7ff1a2.Note on delete policy: I went uploader-or-owner. If you'd rather any project editor be able to manage all project media (consistent with editors editing entries), it's a one-line change — say so and I'll flip it.