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>
2.4 KiB
RB-03 — mask the owner BSN on the cross-owner case lists
Status: implemented · 2026-08-27 · Source findings: 07-bio2-compliance.md BIO-003 · 99-backlog.md RB-03
What was wrong
Mappers.ToAdminSummaryDto set Owner = a.Owner — the raw BSN. Two endpoints consume it,
both cross-owner lists read by someone who is not the subject:
GET /admin/cases(cases:manage)GET /werkvoorraad(aanvraag:beoordelen)
GET /beoordeling/{id} — the detail view of the same data — already masked. So the
detail screen showed ******782 while the list one click earlier showed the whole BSN.
What changed
| File | Change |
|---|---|
Domain/People/Pii.cs |
new — Pii.MaskTail, moved out of Program.cs |
Contracts/Mappers.cs |
Owner = Pii.MaskTail(a.Owner, 3) |
Program.cs |
local MaskTail deleted; two call sites point at Pii.MaskTail |
AdminCasesTests.cs |
asserts the masked value and that DemoOwner does not appear |
WerkvoorraadTests.cs |
same assertion, replacing the IsNullOrEmpty one |
Masked in the mapper, not at the endpoints. The point of the ticket is that both lists inherit it, so a third cross-owner list cannot be added that forgets to mask.
MaskTail moved to Domain/People/Pii.cs because it now has three callers across
three folders (Contracts, Program.cs, and Data once RB-04 lands), and a second
hand-rolled copy is how one of them drifts into leaking. It is documented as idempotent,
which is what lets /beoordeling/{id} keep its own call: IZaakSource has a second
implementation (OpenZaakZaakSource → ZgwZaakMapper, which maps Owner from the zaak
identificatie), so that endpoint's guarantee should not depend on which source answered.
Blast radius on the frontend — none
Both consumers use the value for display only (admin-cases.page.ts:101,
beoordeling-view.ts:40, werkvoorraad-item-view.ts:28); the parse* boundaries require
a non-empty string, which a masked BSN still is. Nothing keys, filters or looks up by owner.
Verification
dotnet format --verify-no-changes clean. dotnet test: 251 passed, 1 failed — the
pre-existing OpenZaakIntegrationTests.Admin_cases_…, which needs a live container.