POST /registrations passed its Documents list straight to Submit, which calls
DocumentStore.Link on every digital documentId in it — and linking a document
blocks its owner from ever deleting it (DeleteOwned returns 409 Linked). That
path had no ForeignIds ownership check, so any authenticated citizen could
post another citizen's document id and permanently block them from deleting
their own diploma scan. POST /applications/{id}/submit, the endpoint actually
in use, has had that guard since it was written.
Deleted rather than guarded: the endpoint is dead. No frontend caller, and
the whole registratie flow goes through /applications/{id}/submit.
RegistratieRequest went with it, and so did SubmissionRules.RejectRegistratie
— reachable only from here, and contradicted by the live path, which treats a
handmatig diploma as "does not auto-approve" rather than a 422 rejection. Its
own message said as much while being returned as a rejection. That last part
is a judgement call beyond the ticket's wording; reverting the two
SubmissionRules hunks restores it in isolation.
Coverage moved rather than vanished: the problem+json shape assertion is now
on /change-requests (the other endpoint on the same Submit helper), and the
linked-delete 409 test goes through the real submit path.
swagger.json, the generated client and the behaviour spec regenerated.
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.