collection_id scoping is assumed rather than enforced on by-id routes #70
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
The bug
Only
list_photosfilters by collection (photos.py:129). Every single-object fetch —photo, evidence, comment, job — is a bare primary-key lookup with no collection predicate:
photos.py:68-72(_get_photo_or_404),:340,:508, andjobs.py:81,93.GET /api/jobshasno collection filter at all and lists jobs across every collection.
POST /api/evidence/{id}/supersede(photos.py:333-355) is the worst case: it fetches evidenceby raw id, does not load the parent photo, does not verify collection membership, and — unlike
every sibling route — performs no
review_versionconflict check despite mutatingreviewer-visible state.
Why it matters
The single-collection deployment masks all of this. But the schema carries
collection_idonall seven owned tables specifically to support multiple collections, and
list_photosalreadyresolves "the default collection" as though choice existed. The moment a second collection is
created, every by-id route becomes a cross-tenant read/write.
Even today, the supersede route lets any reviewer deactivate any evidence row by id with no
concurrency guard — quietly changing the evidentiary basis of a photo's date. Deactivated rows
are still returned but rendered at 40% opacity, so it is easy to miss.
Fixing this now is nearly free; retrofitting after multi-collection ships means touching every
route, with no authorization tests (#8) to catch regressions.
Fix
get_scoped(model, id, collection_id)repository helper.
db.getfor user-supplied ids by convention, and enforce it in review.collection_idfiltering toJobRepository.list_jobs.review_versioncheck to the supersede route.Done when
GET /api/jobsis collection-scopedReferences
backend/app/api/routes/photos.py:68-72,333-355,508;jobs.py:81,93Related: #8 (authorization tests) should cover this matrix.
Done in
ebd6121. All four "done when" items covered, 45 tests inbackend/tests/test_collection_scoping.py.The fix has two halves, and the second is the one that lasts.
get_active_collection(backend/app/api/deps.py) is the request-scoped dependency — one place that decides which collection a request operates in, which grows a membership check when multi-collection lands without the routes changing.Then
collection_idbecame a required keyword argument on every repository by-id lookup, not an optional filter. A call site that forgets to scope its query is aTypeErrorat import or test time rather than a silent cross-collection read. There's a test that reflects over the signatures and asserts exactly that — required, keyword-only, no default — because the point is that the property holds for call sites nobody has written yet. That's a stronger version of the "forbid baredb.getby convention" in the issue: conventions need someone to remember them in review.Client-supplied pagination cursors were bare
db.gettoo, so those resolve within the collection now as well. Foreign rows return 404, not 403 — confirming a photo exists is itself a disclosure, same reasoning as the existing viewer rule.Supersede. As you flagged, this was the worst one, and fixing it properly meant a bit more than scoping. It now resolves evidence within the collection, loads the parent photo (so the viewer rule applies too), takes
review_version, and increments it. The bump isn't in the issue text but is load-bearing: without it two concurrent supersedes both pass a check against the same version, which defeats the point of adding the check. There's a test for that specific race.review_versionis now a required body field — a breaking API change, butsupersedeEvidencewas defined in the frontend client and called from no component, so nothing in the UI breaks. Client signature updated anyway.UserRepository.get_by_idis deliberately left unscoped — theusertable has nocollection_id; users are global, not collection-owned.One behaviour change worth knowing about:
GET /api/photosused to return an empty list when no collection existed; it now 404s, because collection resolution is a dependency. Only reachable before the first login, which is what creates the default collection. ThreeTestBodyLimitstests needed thecollectionfixture for the same reason — dependencies are solved before request-validation errors are raised, so they were seeing the scoping 404 instead of the 422 they assert on.Related #8 (authorization tests) — this covers the collection axis of that matrix; the role axis is already in
test_authz_matrix.py.