Files
atomic-design-poc/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-01.md
T
ehoandClaude Opus 5 a2e935d1d8 fix(uploads): authorize the document-content and status endpoints (RB-01)
GET /uploads/{documentId}/content took only (string documentId) — no
HttpContext, so no authorization was possible. It streams diploma and
identity scans, protected by GUID unguessability alone, while DELETE on the
same resource has always been owner-scoped. GET /uploads/status had the same
shape and confirmed whether any client-chosen localId exists, plus its
documentId.

Both now take HttpContext. Content is readable by the owning
ZorgverlenerCaller or a caller passing Authz.CanBeoordelen — matched on the
caller kind rather than branched on a boolean, because ctx.Zorgverlener()
throws for a MedewerkerCaller and the behandelportal's beoordeling screen is
a legitimate reader. Status is scoped to ctx.Zorgverlener().Bsn via a new
owner parameter on DocumentStore.ByLocalIds (one call site).

404, not 403, on both: a foreign document id must not be distinguishable
from one that never existed, and a foreign localId reads back as "unknown".

Residual, recorded in the implementation note: both callers reach the URL as
a plain browser navigation (<a href> / previewUrl), which carries no identity
header and no interceptor, so StubIdentityProvider resolves it to the seeded
citizen. That is BIO-002 and belongs to RB-09; the links keep working today
only because one citizen owns every document in the POC.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:48:04 +02:00

3.5 KiB

RB-01 — authorize GET /uploads/{id}/content and /uploads/status

Status: implemented · 2026-08-27 · Source findings: 07-bio2-compliance.md BIO-004 · 99-backlog.md RB-01

What was wrong

GET /uploads/{documentId}/content took (string documentId) — no HttpContext, so no authorization was possible at all. It streams diploma and identity scans; the only protection was the unguessability of the document GUID. DELETE on the same resource has been owner-scoped (DocumentStore.DeleteOwned) since it was written.

GET /uploads/status?localIds= had the same shape, and leaks less but still confirms whether a given client-chosen localId exists anywhere in the store, plus its documentId.

What changed

File Change
Program.cs /uploads/{documentId}/content takes HttpContext; allowed for the owning ZorgverlenerCaller or a caller passing Authz.CanBeoordelen; else 404
Program.cs /uploads/status takes HttpContext; scoped to ctx.Zorgverlener().Bsn
Data/DocumentStore.cs ByLocalIds second parameter owner; filters on it (the only call site is the endpoint above)
tests/BigRegister.Tests/UploadAccessTests.cs new — 5 cases

The two actor kinds are matched, not branched on a boolean, because ctx.Zorgverlener() throws for a MedewerkerCaller — a behandelaar reading an aanvraag's linked documents (beoordeling-documenten.component.ts) is a legitimate caller here:

var allowed = ctx.Caller() switch
{
  ZorgverlenerCaller z => doc?.Owner == z.Bsn,
  var caller => Authz.CanBeoordelen(caller),
};

404, not 403, per the ticket: a foreign document id must not be distinguishable from one that never existed. doc is null || !allowed collapses both to the same answer, and /uploads/status reports a foreign localId as "unknown" — the same word an id that never existed gets.

Known residual — this endpoint is reached without identity headers

Both callers link to the URL directly (<a href> in beoordeling-documenten.component.ts, previewUrl in libs/shared/src/upload/upload.adapter.ts), so the request is a plain browser navigation that carries no X-Medewerker / X-Subject header and never passes through an Angular interceptor. StubIdentityProvider therefore resolves it to the seeded citizen, which owns every document in the POC, so both links keep working — by coincidence, not by authorization. That coincidence is BIO-002, and it is fixed by RB-09 (making IIdentityProvider able to express "no identity"), not here. RB-09 will need this endpoint to receive a real credential — a signed URL or a cookie — rather than the ambient default.

Verification

dotnet build clean. dotnet test: 250 passed, 1 failed — the failure is OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT, which needs a live OpenZaak container and fails identically on a stashed tree, i.e. it pre-dates this change.