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