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>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
# 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:
|
||||
|
||||
```csharp
|
||||
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.
|
||||
Reference in New Issue
Block a user