Story-1851 pramaan-functions RFD-DOC-001 ADR-099 Matter-bound upload

Proving the upload can't escape its matter

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.

Why this exists (the catastrophe it prevents)

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.

The three locks, and the test that guards each

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.

Lock 1 — Membership (RLS)

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).

Lock 2 — Permission gate

Who can write? The route's require_permission("matters:write"). A read-only firm_member carries only matters:read.

Lock 3 — Composite key

Does the document belong to this matter? Finalize resolves on (firm_id, matter_id, document_id) — not doc_id alone.

Lock 1: a member can't reach an unassigned matter

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
The crux. The matter bind is enforced by RLS, not by the query's firm_id predicate alone. A member who is not on the matter cannot presign or finalize into it, full stop.

Lock 2: a read-only member is denied at the gate

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.

Lock 3: composite finalize, no cross-matter leak

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.

What actually changed

FileChange
tests/integration/test_app_documents.pyThree regression tests (one per lock) + small RLS-aware helpers.
src/domains/document/service.pyDoc comments on _matter_for_number (membership invariant) and _document_for_matter (why composite, not doc_id-only). Comments only.
tests/factories.pycleanup_seeded_data now clears matter_assignments before matters (FK RESTRICT).
Was the lookup already composite? Yes. _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.

Recall in one sentence

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.