TL;DR. A document upload is bound to a matter by the URL path and firm RLS. This Story doesn't add a feature — it proves that binding holds against three attacks and closes the leak shape AGENT4 flagged. The finalize lookup was already composite (firm_id, matter_id, document_id); we lock it down with a regression test, document why it must stay composite, and prove a member can't reach a matter they're not assigned to. No production behaviour changed.
Picture the worst day. A firm's paralegal — legitimately on matter Smith — uploads a document. The system writes the row, hands back a presigned S3 URL, and finalizes the upload. Now imagine the matter id in that flow could be swapped: the same paralegal aims the finalize call at matter Jones, a matter they have nothing to do with. If the binding leaks, one of two disasters lands:
The defences already existed. This Story makes them non-regressable: tests that fail loudly the day someone "simplifies" a lookup or loosens a gate.
Think of the upload path as a door with three independent locks. A real attacker only needs one to be loose. So we test each in isolation.
Who can even see the matter? business.can_read_matter admits a row only for firm_admin/editor, or a firm_member with an explicit matter_assignments row (ADR-099).
Who can write? The route's require_permission("matters:write"). A read-only firm_member carries only matters:read.
Does the document belong to this matter? Finalize resolves on (firm_id, matter_id, document_id) — not doc_id alone.
Why test below the route. The permission gate (Lock 2) fires first at the HTTP boundary, so a route-only test would mask whether the membership binding actually holds. So this test drives DocumentService.presign_upload / finalize_upload directly under a real firm_member RLS session — the same GUCs a request sets — to prove the binding survives even if Lock 2 were bypassed.
The member is assigned to matter X and tries matter Y (same firm, unassigned). Because can_read_matter(Y) is false for them, _matter_for_number sees zero rows and raises pramaan.matter.not_found — before any document row is created. A positive control proves the rejection is membership-specific, not a blanket failure:
ok = await DocumentService.presign_upload(session, matter_id=1, req=...) # assigned X -> works
with pytest.raises(PramaanNotFoundError):
await DocumentService.presign_upload(session, matter_id=2, req=...) # unassigned Y -> 404
# and: zero documents written to matter Y, exactly one to matter X
firm_id predicate alone. A member who is not on the matter cannot presign or finalize into it, full stop.This one IS a route test, because it's the route gate under test. A firm_member POSTs presign and finalize and gets 403 both times — short-circuited before any matter or document lookup runs. We then confirm the firm's document table is still empty: the 403 wrote nothing.
The subtle one. Locks 1 and 2 don't bite a writer: a firm_admin can see and write every matter in the firm. So the forged-finalize attack is run as an admin — the only thing that can stop it is the composite key. The admin presigns a document into matter X, then forges the finalize under matter Y's path carrying X's doc_id:
POST /app/matters/2/documents/<docX_id>/finalize -> 404 pramaan.document.not_found
A doc_id-only lookup would have found Smith's row under Jones's path — the leak. The composite (firm_id, matter_id, document_id) makes the mismatched pair resolve to zero rows: a 404 that carries no matter-X metadata. The test asserts X's filename never appears in the response body, that X's document is left untouched, and that the honest finalize under matter X's own path still works (proving the document was real — only the wrong-matter bind blocked it).
On the doc_id in the 404 message. The error text repeats the caller-supplied doc_id — but that id came from the caller's own URL, so it isn't a leak. What must never appear is row content (filename, object key). That's what the assertion targets.
| File | Change |
|---|---|
tests/integration/test_app_documents.py | Three regression tests (one per lock) + small RLS-aware helpers. |
src/domains/document/service.py | Doc comments on _matter_for_number (membership invariant) and _document_for_matter (why composite, not doc_id-only). Comments only. |
tests/factories.py | cleanup_seeded_data now clears matter_assignments before matters (FK RESTRICT). |
_document_for_matter already filtered on all three columns; the only production-side change is a comment explaining why it must stay that way. No migration, no DTO/route change, no OpenAPI drift.An upload is locked to its matter by membership RLS, a write-permission gate, and a composite finalize key — and now each lock has a test that screams the day it loosens.