Two backlog trees are complete: `docs/project/backlog/` (75 files, every WP done) and `docs/project/refactor-backlog-setup/` (the arc before it). Move both under `docs/project/archive/` with `git mv`, so history stays intact through `git log --follow`. `SHOWCASE-ROADMAP.md` moves with them, because it points at the now-archived backlog README. Add `docs/project/archive/README.md`. It states that these trees are historical and names the two directories that are still live. Repoint every inbound reference named in RD-30's Files table: CLAUDE.md, the root README, both backend READMEs, `LetterHtml.cs`, `a11y.mdx`, the `document-feature` and `new-ssp` skills, and the readable-codebase PLAN, README, and RD-19 ticket. Fix two upward-relative links inside the moved WP files (WP-68, WP-69) that gained a directory level and would otherwise break. Repoint `.prettierignore`'s two agent-prompt exclusions to their new path, so prettier keeps leaving those files' exact wording alone. Mark RD-30 done and check off its acceptance criteria; flip its README row to done. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
80 KiB
Scope: apps/ssp (auth, registratie, herregistratie, brief, showcase+shell+root), apps/behandelportal (auth, behandeling, shell+root), libs/shared (per layer), libs/beheer, backend (Program.cs, Domain, Data, Zgw, Contracts, Stamdata) — controls 5.12, 5.13, 8.15, 8.16, 8.24, 8.25, 8.26, 8.28, 8.29, 8.32, 9.1, 9.2, 9.4
Status: complete
Last updated: 2026-08-27
Depends on: 00-baseline.md, 02-testability.md, 04-cqrs-light.md, 06-adr-conformance.md
---
07 — BIO2 / ISO 27002:2022 compliance
Scope, in full. Frontend: ssp/auth, ssp/registratie, ssp/herregistratie, ssp/brief,
ssp/showcase+shell+root, bhp/auth, bhp/behandeling, bhp/shell+root, libs/shared
(domain, application, infrastructure, ui, layout, kernel, upload, testing, environments),
libs/beheer. Backend: Program.cs, Domain, Data, Zgw, Contracts, Stamdata.
Controls: 5.12, 5.13, 8.15, 8.16, 8.24, 8.25, 8.26, 8.28, 8.29, 8.32, 9.1, 9.2, 9.4.
0. The control-set assumption, stated for the record
No explicit control list was supplied to this agent. The seven areas below were selected for privacy and security relevance to a BIG-register portal handling BSN, diploma and health-professional registration data:
| # | Area | ISO 27002:2022 / BIO2 |
|---|---|---|
| 1 | Access control | 9.1, 9.2, 9.4 |
| 2 | Logging & monitoring | 8.15, 8.16 |
| 3 | Data classification & handling | 5.12, 5.13 |
| 4 | Cryptography | 8.24 |
| 5 | Secure development | 8.25, 8.28, 8.29 |
| 6 | Change control | 8.32 |
| 7 | Input validation | 8.26 |
Flag if a different set should apply. Three plausible narrowings/widenings a reviewer should decide on before this file is treated as authoritative:
- BIO 2.0 thema-uitwerkingen rather than raw ISO 27002 — a Dutch government system would normally be assessed against the BIO's own thematic elaborations (toegangsbeveiliging, logging & monitoring), which are stricter on logging retention and on the "verwerking van bijzondere persoonsgegevens" than the bare ISO controls used here. Nothing below would be withdrawn under that set; several items would rise in severity.
- NEN 7510 (Dutch healthcare information security) is arguably the governing standard for a register of healthcare professionals. Not applied here.
- AVG/GDPR obligations proper (art. 5 minimisation, art. 9 special-category, art. 30 register of processing, art. 32 measures) are referenced only where the code itself invokes them. A DPIA is out of scope for this pass and is listed in the pre-production checklist.
Framing, per the brief. This POC has deliberately faked authentication; CLAUDE.md's "Out of scope" excludes real auth/DigiD and PRD-0002 §3 excludes real AD/OIDC/SAML. No finding below asks for real DigiD or employee SSO. Findings are split:
- Defect now — wrong even for a POC. Typically: PII reaching a store or a log that the code's own contract says holds none, or a missing authorization check that has nothing to do with the identity stub.
- Production gate — correct for a POC, must be true before production. These are the pre-production checklist at the end.
Nine of the twenty findings are defect now. Where a dev-only affordance is genuinely stripped from a production build, that is said plainly and no finding is filed (see §1.4 and the module notes).
1. Control area: access control (9.1, 9.2, 9.4)
BIO-001 — the backend trusts client-asserted identity headers in every environment
- Control: 9.2 (user access provisioning), 9.4 (least privilege / secure log-on)
- Class: production gate
- Severity: high — a single request header grants the full admin capability set. It is
high not because it is unknown (it is documented in three places) but because it is the
item on which every other authorization control in the system rests:
Authz, the capability model, the four-eyes rule and the audit trail are all correct given a trustworthyPrincipal, and all worthless without one. - Evidence (read):
backend/src/BigRegister.Api/Domain/Authorization/StubIdentityProvider.cs:23-27—ctx.Request.Headers["X-Role"]maps directly toPrincipalRole.Admin. No verification, no signature, no environment guard.:29-31—X-Medewerkerpresent ⇒MedewerkerCaller, id taken verbatim from the header;:38-46X-Rollenlikewise.:33-35—X-Subjectsets the caller's BSN, i.e. the ownership key every owner-scoped store reads.backend/src/BigRegister.Api/Program.cs:53registers this as the onlyIIdentityProviderunconditionally;:114-119runs it as middleware for every request in every environment.Domain/Authorization/Authz.cs:12-16andStubIdentityProvider.cs:7-9both label it "dev stub — NOT a security boundary". The labelling is accurate and complete; nothing in the build enforces it.
- Baseline citation: §7 Backend pattern inventory — "Single-impl interface |
IIdentityProvider→StubIdentityProvider"; BL-006 (the backend has zero automated architecture enforcement, so nothing would fail a build that shipped this stub). - Remediation, minimal: do not replace the stub in this backlog. Two cheap, in-scope
steps: (a) fail fast — throw at startup when
builder.Environment.IsProduction() && provider is StubIdentityProvider, so the stub can never boot outside Development; (b) giveIIdentityProvider.Resolvea way to say "no identity" (see BIO-002), so the production swap is a drop-in rather than a redesign. - Effort: S (a), S (b). The real provider is out of scope and is a checklist item, not a ticket.
BIO-002 — in a production build the backoffice has no identity, and the default is a citizen
Agent 06 handed this over explicitly (06-adr-conformance.md, "Observation for agent 07").
Here is what it actually means for 9.4.
- Control: 9.4 (least privilege), 9.2 (provisioning); PRD-0002 §4 goal 4 (deny-by-default)
- Class: production gate
- Severity: high — the failure mode is an identity substitution, not merely a missing identity, and it fails open in the direction nobody checked.
- Evidence (read), and the answer to "what identity does a production backoffice user get":
apps/behandelportal/src/app/app.config.ts:57-63—medewerkerInterceptoris inside theisDevMode()array. A production bundle sends noX-Medewerker/X-Rollen.StubIdentityProvider.cs:29-37— with noX-Medewerkerand noX-Subject, the provider falls through tonew ZorgverlenerCaller(DocumentStore.DemoOwner, SeedData.Registration.Naam, PrincipalRole.Drafter).DocumentStore.cs:48—DemoOwner = "123456782", the single seeded citizen's BSN.- So a production backoffice user authenticates to the backend as the seeded citizen,
role
drafter. Concretely:- Fails closed, correctly, on the backoffice capability.
Authz.CanBeoordelen(caller)iscaller is MedewerkerCaller m && …, so a zorgverlener isfalseregardless ofX-Role.GET /werkvoorraad,GET /beoordeling/{id}andPOST /beoordeling/{id}/besluitall 403 through theBeoordelengate (Program.cs:814-820) and write a deny audit row.GET /mereturns an empty capability list, socapabilityGuard('aanvraag:beoordelen')(app.routes.ts:25) denies too. This part of the design is right and should be recorded as such. - Fails open on the citizen's own rights. The same user is the seeded citizen for
every citizen-scoped endpoint:
GET /applications,GET /applications/{id},PUT/DELETE /applications/{id},POST /applications/{id}/submit,DELETE /uploads/{id},GET|PUT /brief,POST /brief/submit|send|reset(Program.cs:281-372,:253,:603-656) all resolvectx.Zorgverlener().Bsnto123456782. An employee with no employee identity is granted a citizen's read and write rights over that citizen's aanvragen, uploads and letters. - Holds the PII-reveal capability.
Authz.CanRevealBigNummer(principal)isprincipal.Role == PrincipalRole.Drafter— anddrafteris exactly the role the no-header default produces. See BIO-006.
- Fails closed, correctly, on the backoffice capability.
- Root cause, and why it is worth a ticket now:
IIdentityProvider.Resolvereturns a non-nullableCallerIdentity(IIdentityProvider.cs:12). The interface cannot express "no identity", so any implementation — stub or real — is forced to invent one for an unauthenticated request.CallerIdentityHttpContextExtensions.Caller()(CallerIdentity.cs:44-50) already throws rather than defaulting when the middleware did not run, i.e. the codebase reaches for fail-loud one layer up and then defaults one layer down.
- Baseline citation: §7 Backend — "Single-impl interface |
IIdentityProvider→StubIdentityProvider"; §2 size inventory (apps/behandelportal29 src files, 1 309 lines — a whole app with no non-dev identity path). - Remediation, minimal: change
ResolvetoCallerIdentity?and have the middleware either reject (401) or set an explicitAnonymousCallerwhen it returns null; keepStubIdentityProviderreturning the current default only underIHostEnvironment.IsDevelopment(). That is the smallest change that makes "unauthenticated" representable, and it is the natural home for agent 06's ADR-C-004 (Session → Principal). - Effort: S for the interface + middleware; the behandelportal's real login is out of scope.
BIO-003 — X-Admin is a second authorization gate, outside Authz, unaudited, with no caller
- Control: 9.4; 8.15 (audit trail); PRD-0002 §7 ("a single shared authorization helper … used on every endpoint … the helper makes emit and enforce the same code path")
- Class: defect now
- Severity: medium — a destructive cross-owner delete behind the weakest gate in the system, leaving no audit record. Not high only because the stronger gate it should use is itself header-asserted today (BIO-001).
- Evidence (read):
Program.cs:773—static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true";Program.cs:268-270—DELETE /admin/uploads/{documentId}is gated byIsAdminalone, not byAuthz.CanManageCases/ theCasesAdminwrapper the four sibling admin surfaces use (:801-808).Data/DocumentStore.cs:165-179—AdminDelete"bypasses ownership", deletes the row and its bytes, and writes only aDocumentStore.Audit("delete-admin", …)metadata row — noAuthzAuditStoreentry, so the action never appears on/beheer/audit.grep -rn "X-Admin"overapps,libs,backend,e2e: the only sender isbackend/tests/BigRegister.Tests/EndpointTests.cs:231. No frontend uses this endpoint. It is an orphaned gate, not a live seam.
- Baseline citation: BL-003 (940 lines, 48 endpoint mappings in one file, read/write
separated only by a comment banner — the structural condition under which one endpoint keeps
a superseded gate); §7 Backend CQRS-light row (the local helpers
Submit,StamdataAdmin,CasesAdmin,Beoordelen,OrgAdmin,FlagsAdminare "authorization/idempotency wrappers" —IsAdminis the one that never became a wrapper). - Remediation, minimal: route the endpoint through
CasesAdmin(or a newAuthz.CanDeleteAnyDocument) and deleteIsAdmin; add theAuditAuthzcall the sibling gates make. One test (EndpointTests.cs:231) changes its header. - Effort: S
BIO-004 — two upload endpoints have no authorization check at all
- Control: 9.4 (broken object-level authorization); 5.12 (the objects are diploma and identity scans)
- Class: defect now
- Severity: high — it is the only place in the backend where AVG-relevant content is
served with no owner and no capability test, and the codebase demonstrably knows the
pattern: the sibling
DELETEon the same resource is owner-scoped, and submit validates foreign ids. Mitigating factor, stated honestly: document ids areGuid.NewGuid()(DocumentStore.cs:54) and local ids arecrypto.randomUUID()on the client, so this is a capability-URL exposure rather than an enumerable one. - Evidence (read):
Program.cs:231-237—GET /uploads/{documentId}/contentcallsDocumentStore.Get(documentId)and streamsdoc.Content. The lambda does not takeHttpContext; it cannot check anything.Data/DocumentStore.cs:65-72—Gethas no owner parameter.- Contrast, one screen away:
Program.cs:253-254DELETE /uploads/{documentId}→DocumentStore.DeleteOwned(documentId, ctx.Zorgverlener().Bsn)(:146-153, explicitd.Owner != ownercheck), andProgram.cs:367→DocumentStore.ForeignIds(documentIds, ctx.Zorgverlener().Bsn)(:103-113), whose own docstring says it "guards submit/draft-sync against a citizen attaching another citizen's upload to their own aanvraag". Program.cs:242-250—GET /uploads/status?localIds=maps client local ids to document ids for any caller, with no owner filter (DocumentStore.ByLocalIds,:76-84).- Why it is unscoped is legible:
Program.cs:451-452(the beoordeling detail) hands a behandelaar theDocumentIds of another citizen's uploads, so a cross-owner read is a genuine requirement. The defect is that the requirement was met by removing the check rather than by widening it.
- Baseline citation: §7 Backend —
DocumentStorelisted among the 7 stores "Not behind any port"; §3cbackend/Data75.5% branch against 99.0% line (BL-005), the exact signature of "the unit is entered, the guard branches are not there to enter". - Remediation, minimal: take
HttpContextin both lambdas and allow whendoc.Owner == ctx.Caller().SubjectIdorAuthz.CanBeoordelen(ctx.Caller())orAuthz.CanManageCases(Authz.ResolvePrincipal(ctx)); 404 (not 403) otherwise, per PRD-0002 §8's "avoid resource-existence enumeration". Same predicate for/uploads/status. - Effort: S
BIO-005 — POST /registrations links arbitrary document ids with no ownership check
- Control: 9.4; 8.26 (unvalidated request field driving a state change)
- Class: defect now
- Severity: medium — integrity/availability, not confidentiality: the caller cannot read
another citizen's document, only permanently mark it
Linked, which blocks that citizen from ever deleting it (DocumentStore.DeleteOwnedreturnsLinked,Program.cs:257-259). - Evidence (read):
Program.cs:187-190—POST /registrationspassesreq.Documentsstraight toSubmit.Program.cs:919-925(Submit) —DocumentStore.Link(documents.Where(…).Select(d => d.DocumentId!))andDocumentStore.Audit("post-delivery", …). Neither is owner-scoped;DocumentStore.Link(Data/DocumentStore.cs:129-141) takes no owner at all.- Contrast
Program.cs:367, the newer submit path, which rejects foreign ids before linking. - The endpoint has no frontend caller.
grepoverapps/libsfor the generated client'sregistrationsmethod returns nothing outsideapi-client.tsitself; only/change-requestsis still called (registratie/infrastructure/change-request.adapter.ts:17).
- Baseline citation: BL-003 (48 endpoint mappings in one 940-line file — the condition
under which a guard added on one submit path is not added to the other); §7 Backend
CQRS-light row, which names
Submitas one of the cross-cutting wrappers. - Remediation, minimal: either delete the endpoint (it is dead, and WP-72 already removed
its siblings), or add the one
ForeignIdsguard the other submit path uses. Deleting is smaller and removes the surface entirely. - Effort: S
BIO-006 — the PII-reveal capability belongs to the default role, and its step-up is a client-asserted constant
- Control: 9.4 (least privilege); PRD-0002 §5d ("the server re-checks the environment attribute before permitting")
- Class: production gate (the role mapping is a documented POC choice) with one defect-now sub-item (the step-up check is a no-op as wired)
- Severity: medium
- Evidence (read):
Domain/Authorization/Authz.cs—CanRevealBigNummer(principal) => principal.Role == PrincipalRole.Drafter.StubIdentityProvider.cs:23-27— the_ =>arm of the role switch isDrafter. So the absence of any role header yields the role that holds the PII reveal.roles-and-access.mdstates this as "drafter… the only role that may reveal a BSN" without noting that it is also the default.Program.cs:668-681— the reveal requirescanReveal && ctx.Request.Headers["X-Step-Up"] == "true".apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.ts:26— the client sends'X-Step-Up': 'true'unconditionally, as a literal. The precondition is therefore satisfied by every call that reaches the endpoint; it constrains nothing. The endpoint's own comment atProgram.cs:665calls it "stubbed here as the X-Step-Up header", which is accurate, but the FE side turns the stub into a constant rather than into a gesture-gated value.
- Baseline citation: §7 Backend —
Domain/.../Authz.cslisted among the pure rule classes with co-located tests; §3bssp/brief42% spec reach (11 of 26 files), withreveal-bignummer.adapter.tsamong the 15 unreached and not aui/file, so BL-004's Storybook carve-out does not cover it. - Remediation, minimal: (a) send
X-Step-Uponly from the confirm handler, not from the adapter's literal — one line, and it makes the stub behave like the control it stands in for; (b) record inroles-and-access.mdthatdrafteris the default role, so the least-privilege consequence is visible; (c) production gate: bind the reveal to an app-overlay attribute rather than the coarse role, as PRD-0002 §5c already says ("Role-based in the POC; a real system resolves it from the app overlay independent of role"). - Effort: S for (a)+(b); the overlay is a checklist item.
BIO-013 — the seeded-citizen endpoints ignore the caller entirely
- Control: 9.4 (row-level scoping); 5.12
- Class: production gate — explicitly acknowledged as unbuilt in PRD-0002 §9 P2 ("Row-level scoping (§5b) still unbuilt")
- Severity: medium — with one seeded citizen it is invisible; the moment a second identity
exists (which
?subject=already creates in e2e) every citizen reads the seeded citizen's BRP address, birthdate and registration. - Evidence (read):
Program.cs:135-155—GET /dashboard-view,GET /notes,GET /brp/address,GET /duo/diplomastake noHttpContextand returnSeedData.Registration/SeedData.Person/SeedData.BrpAddressregardless of the resolved caller. The identity middleware runs (:114-119) and its result is discarded. - Baseline citation: §7 Backend — the read side is "screen-shaped reads. Decisions are
computed here"; BL-003 (all 48 mappings in one file). §3c
Program.cs97.4% line — these endpoints are covered, so the gap is by design, not by omission. - Remediation, minimal: none proposed for the POC. The checklist item is: every read that returns person data must take the caller and scope on it, and the acceptance test is a second seeded citizen who cannot see the first's data.
- Effort: M (out of this backlog)
BIO-018 — IdempotencyStore is keyed on a client-supplied string with no caller scoping, TTL or bound
- Control: 9.4; 8.26
- Class: defect now
- Severity: low — the cached values are only a
ReferentieResponseor a ProblemDetails, so a cross-caller replay leaks a reference number, not personal data. Filed because it is an unscoped shared cache in an access-control path and the fix is trivial. - Evidence (read):
Data/IdempotencyStore.cs:11-27— a process-globalDictionary<string, IResult>keyed on the header alone; the file's ownponytail:comment concedes "no TTL/eviction … an unbounded dictionary keyed on client-supplied strings is a memory leak at scale".Program.cs:901-909reads and writes it with the raw header value, never composed with the caller'sSubjectId. - Baseline citation: §7 Backend —
IdempotencyStorelisted among the 7 stores "Not behind any port"; agent 02'sbackend/Datanote ("the only store that is purely in-memory with noReset()and no TTL … shared by every test class in the process"). - Remediation, minimal: key on
$"{ctx.Caller().SubjectId}:{idemKey}". One line, and it also removes the cross-test-class bleed agent 02 flagged. - Effort: S
2. Control area: logging & monitoring (8.15, 8.16)
BIO-007 — only denied authorization decisions are audited; successful admin and approval actions are not
- Control: 8.15 (logging), 8.16 (monitoring activities); PRD-0002 §8 ("Audit log of authorization-relevant events — denials, PII reveals, approvals/rejections, step-up, break-glass")
- Class: defect now
- Severity: medium — the queryable trail the product ships as its audit surface
(
/beheer/audit) cannot answer "who changed this", only "who was turned away". For a register whose integrity is the product, that is the wrong half. - Evidence (read) — every
AuditAuthzcall site, checked:Program.cs:783OrgAdmin→allowed: false.:794StamdataAdmin→false.:805CasesAdmin→false.:817Beoordelen→false.:827FlagsAdmin→false. All five gates audit only the denial branch; the allow branch callsaction()and returns.- The one exception is
Program.cs:674(the reveal), which passes the realallowed.:539audits the NRC notification.:856audits a ZGW divergence. - Therefore the following leave no row in
AuthzAuditStore:PUT /admin/flags/{key}(:592, andData/FeatureFlagStore.cs:54-66writes nothing either — the endpoint does not even emit a log line),PUT /admin/org-template/{subOrgId}(:739),POST /admin/org-template/{subOrgId}/rollback/{version}(:764),DELETE /admin/cases/{id}(:554, log line only at:557),DELETE /admin/uploads/{documentId}(:268, see BIO-003),POST /brief/approve//reject//send(:631-658,LogBriefwrites a log line with no actor), andPOST /beoordeling/{id}/besluit(:473). - The comment at
Program.cs:777-778states the intent — "the allow path is left un-logged (the endpoints log their own effect, e.g. publish)" — and the intent is only half met: publish (:755) and admin case delete (:557) log; the other six do not log at all.
- Baseline citation: §7 Backend —
AuthzAuditStorelisted among the 7 static stores; §3cbackend/Program.cs97.4% line / 84.8% branch — the allow branches run constantly and simply have nothing in them. - Remediation, minimal: move the
AuditAuthzcall from each gate's deny branch to the gate itself, passing the real boolean and wrappingaction():var ok = Authz.CanX(p); AuditAuthz(ctx, "x", resource, ok, p); return ok ? action() : Forbidden(...). Five identical edits inProgram.cs:779-830, no signature change, no new concept. AddAuditAuthzto the three brief transitions and the besluit. - Effort: S for the five gates; M including the brief/besluit sites and their tests.
BIO-008 — the BSN is written into the authz audit trail's Resource column
- Control: 8.15; 5.12 (classification of special-category data)
- Class: defect now
- Severity: high — this is the single clearest "wrong even for a POC" item in the file: a national identifier is persisted to a store whose own type documentation, class documentation and endpoint documentation all state it holds none, and it is then served over an API and rendered in a UI that repeat the same claim.
- Evidence (read), the whole chain:
Program.cs:674—AuditAuthz(ctx, "brief:reveal-bignummer", "brief/" + ctx.Zorgverlener().Bsn, allowed, principal);The BSN is concatenated into theresourceargument.Program.cs:840-844— that argument is logged ("authz action={Action} resource={Resource} …") and passed toAuthzAuditStore.Record(action, resource, …).Data/AuthzAuditStore.cs:30-38—Recordinserts it asAuthzAuditEntry.Resourceinto SQLite. The entity's doc comment (:5-8) says "never a name, BSN, or the value that was (or wasn't) revealed"; the class comment (:22-23) says "Holds NO PII by construction (see the entity); the schema test asserts it".Program.cs:566-569—GET /admin/auditreturnsa.Resourceverbatim inAuthzAuditDto.libs/beheer/src/ui/audit.page.ts:10-12— the page's own header comment reads "data-minimised, no PII", and its table renders the resource column.Program.cs:666-667— the endpoint comment: "every attempt — allow or deny — is audited with NO PII (AuditAuthz)".
- The false assurance, named:
backend/tests/BigRegister.Tests/AuthzAuditTests.cs:51-53asserts over column names —Assert.DoesNotContain(names, n => Regex.IsMatch(n, "naam|name|bsn|value|waarde", …))— not over values. The BSN travels in a column calledResource, which the regex cannot see. Four documents claim the control; the test that is cited as enforcing it does not enforce it. - Baseline citation: §7 Backend — the 7 static stores,
AuthzAuditStoreamong them; §3cbackend/Data75.5% branch (BL-005). - Remediation, minimal: the resource ref for a per-owner brief does not need the BSN — use
the brief's own id, or
"brief/" + MaskTail(bsn, 3)(the masker already exists atProgram.cs:861-863and is already used for the BIG-nummer at:878). Then extendAuthzAuditTeststo assert on values: seed a reveal attempt and assert no storedResourcematches\d{9}. - Effort: S
BIO-009 — the BSN is the Actor on every document audit row
- Control: 8.15; 5.12
- Class: defect now
- Severity: medium — same class as BIO-008 but a narrower blast radius: this table is not
exposed by any endpoint (
grep:DocumentStore.AuditLoghas no caller inProgram.cs), so it is a storage-side leak only. - Evidence (read):
Data/DocumentStore.cs:52-63—Add(..., string owner)ends withAudit("upload", doc.DocumentId, categoryId, owner), andowneris the caller's BSN (Program.cs:224passesctx.Zorgverlener(), whoseBsnbecomesStoredDocument.Owner).:159—DeleteOwnedlikewise:Audit("delete-user", documentId, categoryId, owner).:181-189—Auditpersists it asAuditEntry.Actor.- The class doc comment (
:30-36) states "The audit log holds metadata only (never file content or other PII)." StoredDocument.Owneritself is the BSN by design (it is the ownership key) — that is correct and is not the finding; the finding is the audit row, which needs only a pseudonymous actor.
- Baseline citation: §7 Backend —
DocumentStoreamong the 7 static stores; §3cbackend/Data99.0% line / 75.5% branch. - Remediation, minimal: pass
MaskTail(owner, 3)(or a per-session pseudonym) as theactorargument at:61and:159; the ownership column is untouched. - Effort: S
BIO-010 — the BSN reaches logs and a persisted aanvraag field through the ZGW error path
- Control: 8.15; 5.12
- Class: defect now, conditional on
Zgw:Enabled=true(off by default,appsettings.json) — which is why the severity is medium and not high today, and why it becomes high the moment OpenZaak is switched on. - Severity: medium
- Evidence (read):
Zgw/OpenZaakZaakSource.cs:52— the citizen-scoped list buildsurl += $"?rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn={Uri.EscapeDataString(bsn)}". The BSN is in the request URI.Zgw/ZgwHttpClient.cs:76-81— on a non-transient failure the client throwsnew HttpRequestException($"ZGW {req.Method} {req.RequestUri} failed: {(int)res.StatusCode} {snippet}"), i.e. the exception message carries both the BSN-bearing URI and up to 500 characters of the OpenZaak response body.Zgw/OpenZaakZaakSource.cs:246shows the write side postsinpBsnin the body, andData/DocumentStore.cs:44-47records that OpenZaak's own validation errors for that field are a real, observed failure mode — so the body snippet is a live BSN-echo path, not a hypothetical one.Program.cs:851-856(RecordZgwDivergence) —app.Logger.LogError(ex, …)logs that message, andApplicationStore.SetZgwError(id, ex.Message)persists it to the aanvraag row (Data/ApplicationStore.cs:286-294).Data/AanvraagMapper.cs:25,50,63,78,96carry it onward through the mapper.- Mitigating, verified:
ZgwErroris not on any DTO inContracts/and does not appear anywhere in the frontend — the value is stored and logged, not served. - Also verified and not a finding:
Zgw/ZgwDiagnosticHandler.cs:17-24logs only method, URI and byte counts, and only whenrequest.Content is not null— so the BSN-bearing GET URI is never reached by it, and no body is logged. It is opt-in behindZGW_DEBUG_HTTP=1(Program.cs:73-78). That hatch is clean.
- Baseline citation: §7 Backend — "ZGW anti-corruption layer | Fully built"; §3c
backend/Zgw98.1% line / 85.5% branch — the strongest branch figure on the backend, so this is a design gap, not a test gap. - Remediation, minimal: in
ZgwHttpClient.SendWithRetryAsync, build the message fromreq.RequestUri.GetLeftPart(UriPartial.Path)(drop the query) and omit the body snippet from the message, logging it separately at Debug if the diagnostic value is wanted. - Effort: S
3. Control area: data classification & handling (5.12, 5.13)
BIO-011 — cross-owner list endpoints ship unmasked BSNs while the detail endpoint masks
- Control: 5.12 (classification), 5.13 (labelling/handling); PRD-0002 §5c ("Default DTO carries a masked BSN … or omits it entirely")
- Class: defect now
- Severity: medium — the correct behaviour is implemented one file away, so this is an inconsistency rather than a missing capability, and the list is the wider exposure (every open case, not one).
- Evidence (read):
Contracts/Mappers.cs:72-74—ToAdminSummaryDto(now) => a.ToSummaryDto(now) with { Owner = a.Owner }.a.Owneris the raw BSN.Contracts/Dtos.cs:107documents the field as "populated for the admin cross-owner list (WP-36)".Data/LocalZaakSource.cs:15-16—ListCasesmaps every row throughToAdminSummaryDto.Program.cs:425-427—GET /admin/casesreturnszaken.ListCases(...)unmodified.Program.cs:434-438—GET /werkvoorraadreturns the same list, filtered by status only.- Contrast, in the same file:
Program.cs:453— the beoordeling detail doesvar masked = c with { Owner = MaskTail(c.Owner!, 3) };before returning. The detail masks; the list that leads to it does not. - Rendered unmasked in both UIs:
apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts:28(subtitle: $localize\:@@werkvoorraad.row.bsn:BSN ${item.owner}`) andapps/ssp/src/app/registratie/ui/admin-cases.page.ts:91("Eigenaar (BSN)").behandeling/domain/beoordeling.ts:26correctly documents its own field as "masked by the server";werkvoorraad-item.ts:20` documents its as "always populated" with no masking note.
- Baseline citation: §7 Backend — "Mapping |
Contracts/Mappers.cs(.ToDto(),.ToDetailDto()),Data/AanvraagMapper.cs" (the exact seam); §3abhp/behandeling91.6% line / 81.5% branch — well tested, so nothing here is accidental. - Remediation, minimal: apply
MaskTail(a.Owner, 3)inToAdminSummaryDto(Mappers.cs:74) — one expression, and both list endpoints inherit it. If an admin genuinely needs the full value, that is a reveal action with the existing gated+audited shape, not a default list field. - Effort: S
BIO-012 — ?role= and ?subject= are not stripped from production builds on three hand-written fetch paths
- Control: 5.13 (handling — a BSN written to web storage and onto the wire), 9.4
- Class: defect now — the defect is that a documented control does not hold, not that the residual exposure is large. Stated plainly: an attacker who can send a header does not need this path (see BIO-001), so the incremental attack value is low. The value of the finding is that the docs and CLAUDE.md assert a production property the code does not have, and anyone reasoning about production risk from those docs will get it wrong.
- Severity: medium
- Evidence (read):
- The interceptor chain is correctly gated:
apps/ssp/src/app/app.config.ts:58-62andapps/behandelportal/src/app/app.config.ts:57-63both register[scenarioInterceptor, roleInterceptor, subjectInterceptor(, medewerkerInterceptor)]only whenisDevMode(). That much of the claim is true. - But three adapters bypass
HttpCliententirely and set the headers themselves, with noisDevMode()guard:apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.ts:26—headers: { 'X-Role': currentRole(), 'X-Step-Up': 'true' }apps/ssp/src/app/brief/infrastructure/letter-preview.adapter.ts:43-46—headers: { 'X-Role': currentRole(), ...(subject ? { 'X-Subject': subject } : {}) }apps/ssp/src/app/brief/infrastructure/org-template.adapter.ts:79—headers: { 'X-Role': currentRole() }
- The readers are ungated too:
libs/shared/src/infrastructure/role.ts:23-31andlibs/shared/src/infrastructure/subject.ts:23-29both read the query param and write it intosessionStorageon any navigation, in any build. For?subject=that value is a BSN — persisted to web storage underdev-subject, then sent asX-Subjecton/brief/preview.subject.interceptor.ts:14-29argues at length that the BSN must not leaveSessionStore("SessionStore's G1 comment is explicit that the BSN … is never persisted or otherwise handed outward, by design") and then routes it throughsessionStorageinstead. - The claim being contradicted:
docs/reference/roles-and-access.md:23— "Both are wired only underisDevMode()— they do not exist in a production build"; CLAUDE.md's "Scenario toggle (dev-only, not wired in prod builds)" and "Dev role stand-in (dev-only)".?scenario=genuinely is stripped (its only consumer is the gated interceptor plusupload.adapter.ts's simulator);?role=and?subject=are not. - BSN-in-URL has its own consequences independent of the header: browser history,
Referer, and any reverse-proxy access log.
- The interceptor chain is correctly gated:
- Baseline citation: §3b
ssp/brief42% spec reach (11 of 26 files) — all three hand-writtenfetchadapters are among the 15 unreached, and none is aui/file, so BL-004's Storybook carve-out does not cover them; §3assp/brief68.8% branch. - Remediation, minimal: guard the three adapters —
...(isDevMode() ? { 'X-Role': currentRole() } : {})— or, cleaner, havecurrentRole()andcurrentSubject()returnundefinedoutsideisDevMode()so every caller inherits the guard and the sessionStorage write disappears with it. Then correctdocs/reference/roles-and-access.md:23. - Effort: S
BIO-017 — two PII guards have no executable test
- Control: 8.29 (security testing in development); 5.13
- Class: defect now
- Severity: low — both guards were verified correct by reading them; the finding is that nothing would catch a regression.
- Evidence (read):
apps/ssp/src/app/auth/application/session.store.ts— G1 holds on every path, verified:restore()(:12-21) returns{ bsn: '', naam }and never reads a stored BSN; the constructoreffect()(:43-48) writes only{ naam: s.naam };login()(:52-56) sets the signal in memory only;logout()removes the key. There is no path that writes the BSN tolocalStorage. Identical inapps/behandelportal/src/app/auth/application/session.store.ts.- But agent 02's TE-001 is right that the guard is untestable as written:
restore()is module-private and readslocalStoragein a field initializer. Per-file lcov (agent 02, from §3a): LH 2 / LF 20 (10.0% line), BRH 3 / BRF 13. apps/ssp/src/app/shell/debug-state/mask.ts:13-25—redactProfileis a pure, exported, directly callable PII-redaction function with no spec (agent 02's "missing test, not blocked test"). It redacts name, birthdate and address and masks the BIG-nummer; verified correct by reading.
- Baseline citation: §3a
ssp/auth·bhp/auth42.9% line / 46.2% branch — jointly the worst line coverage in the frontend (§8 ranking); BL-009 (no coverage threshold is enforced anywhere, so nothing ratchets this). - Remediation, minimal: take agent 02's TE-001 seam (
parseStoredSessioninauth/domain/session.ts, which already has a spec file) and add the three cases — absent, non-JSON, wrong shape — plus one that asserts a stored{"bsn":"…","naam":"…"}yieldsbsn: ''. Add a five-line spec forredactProfile. - Effort: S (this is TE-001 plus one assertion; it does not need its own ticket if TE-001 is scheduled — but the BSN assertion must be in TE-001's acceptance criteria, which today it is not).
4. Control area: cryptography (8.24)
BIO-014 — nothing is encrypted at rest: document bytes, BSNs and the audit trail sit in a plaintext SQLite file
-
Control: 8.24 (use of cryptography)
-
Class: production gate
-
Severity: high — the file contains the highest-classification data in the system in one place, unprotected.
-
Evidence (read) — what is at rest, and what class of data:
Table Contains Class Documents(DocumentStore.cs:9-20)byte[] Content— the uploaded diploma / identity / language-proficiency scanAVG art. 9-adjacent; identity documents Documents.OwnerBSN AVG art. 87 national identifier Applications.Owner(ApplicationStore)BSN same Applications.ZgwErrormay contain a BSN (BIO-010) same AuthzAudit.Resourcecontains a BSN today (BIO-008) same AuditEntries.ActorBSN (BIO-009) same Briefs/Briefs.ArchivedHtmlthe rendered letter, incl. name, BIG-nummer, address personal data Data/Db.cs:16-18—UseSqlite(ConnectionString), default"Data Source=bigregister.db". No key, noSQLCipher, no page encryption.DocumentStore.cs:3-8records the intent — "a real backend persists them to blob storage keyed by DocumentId" — so the shape is a known stand-in.- The file lands in the app's working directory and, under
docker-compose.yml, on the host through the./backend:/src:zbind mount. Verified: it is gitignored (backend/.gitignore:5,git check-ignoreconfirms both copies), andgit ls-filesshows zero tracked.dbfiles — so there is no committed-database finding. - No transparent-data-encryption, no filesystem-level requirement, and no key management exists anywhere in the repo.
- Also verified and not a finding: no secrets are committed.
appsettings.json'sZgwblock ships every credential field empty,backend/openzaak/setup_configuration/data.prod.yamlandseeded.envare both gitignored (.gitignore:57,:60), andZgwOptions.Secret's doc comment correctly states it is "Held only by the BFF, never the browser".ZgwTokenProvider.csmints a short-lived HS256 JWT per call with no refresh token to store — sound for what it is.
-
Baseline citation: §2 size inventory —
backend/Data(excl. Migrations) 1 697 lines, the largest backend folder afterProgram.cs; §7 Backend — the 7 static stores, "each opens a short-lived context viaDb.Create()under its own lock". -
Remediation, minimal: none for the POC. Checklist items: move document bytes out of the relational store to encrypted object storage keyed by
DocumentId(the code already says this is the target); require encryption at rest for the database (managed-service TDE or SQLCipher); define key custody and rotation. Prerequisite: BIO-008/009/010 first, so the BSN is not in three places that do not need it before deciding what must be encrypted. -
Effort: L (infrastructure), but S to write the requirement down and to stop widening it.
BIO-015 — no transport security, no security response headers, Swagger and AllowedHosts: * unconditional
- Control: 8.24 (cryptography in transit), 8.28 (secure coding — attack surface)
- Class: production gate
- Severity: medium
- Evidence (read):
Program.cs:86-131is the whole pipeline. It contains noUseHttpsRedirection, noUseHsts, noUseAuthentication/UseAuthorization, and no response-header middleware — so noStrict-Transport-Security, noX-Content-Type-Options: nosniff, noContent-Security-Policy, noReferrer-Policy.app.UseSwagger(); app.UseSwaggerUI();(:121-122) run in every environment, with noapp.Environment.IsDevelopment()guard.appsettings.jsonsets"AllowedHosts": "*". CORS (:37-39) allows onlyhttp://localhost:4200— which is tight, and worth noting is effectively unused since both the dev servers anddocker-compose.ymlproxy/apisame-origin (API_PROXY_TARGET); the behandelportal's:4201is not in the list and does not need to be.nosniffin context: the upload allow-list isapplication/pdf,image/jpeg,image/pngonly (Domain/Documents/DocumentCategory.cs:20-22, enforced atProgram.cs:216-217), andGET /uploads/{id}/contentservesinlineonly for pdf andimage/*(:235). No script-capable type (SVG, HTML) can be uploaded, so the usual stored-XSS-via-inline-attachment path is closed by the allow-list. The content type is nevertheless client-declared rather than sniffed from magic bytes, which is whynosniffbelongs on the checklist rather than being a finding today.
- Baseline citation: BL-003 (
Program.cs940 lines, file CC 78 — the one place every pipeline decision lives); §3cProgram.cs97.4% line / 84.8% branch. - Remediation, minimal: wrap Swagger in
if (app.Environment.IsDevelopment())— one line, and it is a genuine attack-surface reduction with no POC cost. The rest are checklist items, most of which belong to the reverse proxy rather than the app. - Effort: S for Swagger; the rest is deployment configuration.
5. Control area: secure development (8.25, 8.28, 8.29)
BIO-016 — what the security gates cover, and what they do not
-
Control: 8.25 (secure development lifecycle), 8.28 (secure coding), 8.29 (security testing in development and acceptance)
-
Class: production gate
-
Severity: medium
-
Evidence (read) —
.github/workflows/ci.yml, in full:Present, and genuinely blocking:
semgrep scan --config p/default --config p/csharp --metrics=off --error(:254-279) — SAST on both sides,--errormakes it a gate, telemetry off, prior findings triaged rather than suppressed wholesale.npm audit --omit=dev(:128) — the shipped bundle must audit clean.- All actions pinned to full SHAs (
:32-33,:64,:70, …) — supply-chain hygiene. permissions: contents: readat workflow level (:9-11), plus per-ref concurrency cancel.npm run dep:check(:109) — 11severity: errorarchitecture rules, frontend only.dotnet format --verify-no-changes+dotnet test --filter "Category!=Integration"(:199-204) — 241 backend tests.api-client-drift(:281-317) — the wire contract cannot drift unnoticed.
Absent:
- No dependency vulnerability scan on the backend. There is no
dotnet list package --vulnerable --include-transitivestep;npm auditcovers only the frontend. The .NET dependency tree is unscanned. - No secret scanning (gitleaks/trufflehog). Today nothing is committed (verified in BIO-014), so this is prevention, not repair.
- No authorization regression suite as a gate.
AuthzTests.cs,AuthzAuditTests.cs,WerkvoorraadTests.csandStubIdentityProviderTests.csexist and run insidedotnet test, but nothing asserts the set of gated endpoints — so an endpoint added without a gate (BIO-004's shape) fails no test. - No backend architecture enforcement at all — BL-006.
Domain/purity holds by convention. The property ADR-0005 depends on ("ZGW shapes never leaveZgw/") and the property this file depends on ("authorization lives inAuthz") are both review-maintained. - No coverage ratchet — BL-009. Nothing can regress-test a security fix by CI number.
- No DAST, no container image scan, no SBOM. Reasonable omissions for a POC; listed so the production decision is explicit.
-
Baseline citation: BL-006 (verbatim: "the backend has zero automated architecture enforcement …
Domain/purity currently holds by convention") and BL-009 (no coverage threshold is enforced anywhere). -
Remediation, minimal: two cheap additions with real value here — (a)
dotnet list package --vulnerable --include-transitiveas a failing step in thebackendjob; (b) one endpoint-inventory test that enumerates the app's route table and asserts every route outside a small allow-list passes through one of the six authorization wrappers. (b) is the test that would have caught BIO-003, BIO-004 and BIO-005. -
Effort: S for (a), M for (b).
6. Control area: change control (8.32)
BIO-020 — the only deployment artifact in the repo builds development bundles
- Control: 8.32 (change management); 8.25
- Class: production gate
- Severity: medium — there is no release path, so there is no gate at which any of the production-gate items in this file would be checked.
- Evidence (read):
docker-compose.yml— theapiservice setsASPNETCORE_ENVIRONMENT=Developmentand runsdotnet run; bothwebandweb-behandelportalrunnpx ng build … --configuration development --localize, and the file's own header comment states why: "development config keepsisDevMode()=trueso the dev tools render". The image ismcr.microsoft.com/dotnet/sdk:10.0/node:24-slim, and the header opens "dev-server images (not multi-stage prod builds) — this is a demo". Consequence for every dev hatch in this file: in the one containerised deployment the repo ships,isDevMode()istrue, soroleInterceptor,subjectInterceptor,medewerkerInterceptor,scenarioInterceptorand the⚙ statedebug panel are all live. That is correct for a demo and must not be mistaken for a production deployment. - Baseline citation: §2 size inventory (two apps + one backend, all deployed by this one file); BL-006 (no automated enforcement that would distinguish the two).
- Remediation, minimal: none for the POC. Checklist: a separate production compose/Helm
artifact with
--configuration production,ASPNETCORE_ENVIRONMENT=Production, and a release checklist that names this file's production gates. - Effort: M (out of this backlog)
Positive finding, recorded rather than ticketed. Change control over the business rules
is genuinely strong and is the model the rest should follow: stamdata is config-as-code
(ADR-0004), validated at build by StamdataValidationTests, with no runtime write
endpoint at all — verified: Program.cs:164 and :173 are both GETs behind
StamdataAdmin, and libs/beheer's editor downloads a JSON file for a reviewed PR
(libs/beheer/src/infrastructure/stamdata.adapter.ts:16-20, "There is no write method"). A
bad reference-data edit fails CI, never production. The two sanctioned runtime-editable
surfaces (OrgTemplateStore, FeatureFlagStore) both keep their catalog in code and fail
closed on an unknown key (Data/FeatureFlagStore.cs:56) — but see BIO-007: their writes are
not audited, which fails clause (4) of the four-part test agent 06's ADR-C-009 proposes.
7. Control area: input validation (8.26)
The server is the authority, and it is. Verified against ADR-0001's rule ("the FE renders
decisions, it does not recompute business rules"): SubmissionRules.RejectPhoneChange
(Domain/Submissions/SubmissionRules.cs:36-42) re-validates the phone number server-side
with the same normalisation the FE's parseTelefoonnummer applies, and says so;
DocumentRules.RejectUpload (Domain/Documents/DocumentCategory.cs:82-90) authoritatively
enforces the content-type allow-list and the size cap before any byte is stored;
Program.cs:208-217 validates multipart shape and required fields before reading the file;
Program.cs:367 validates document ownership on submit. IntakePolicy.ScholingThreshold is
shipped to the FE as a value for instant feedback and re-validated server-side — the
config-value shape ADR-0001 prescribes. The 30 FE parse* boundaries (baseline §7) are
defence in depth on the response direction, not a substitute; CLAUDE.md explicitly puts
runtime DTO validation on every endpoint out of scope, and this pass does not reopen that.
Two gaps, both narrow:
BIO-019 — GET /stamdata/{table}?peildatum= 500s on unparseable input
- Control: 8.26
- Class: defect now
- Severity: low — admin-gated, and the failure is a 500 rather than a leak. Filed because an unhandled exception on a user-supplied string is exactly what 8.26 exists to prevent, and in a Development environment the exception detail is returned to the caller.
- Evidence (read):
Program.cs:178—var rows = peildatum is { Length: > 0 } p ? t.RowsOn(DateOnly.Parse(p)) : t.Rows();.DateOnly.ParsethrowsFormatExceptionon anything unparseable; there is noTryParse, no 400 path, and.Produceson the endpoint declares only 200/403/404. - Baseline citation: §3c
backend/Stamdata96.8% line but 71.7% branch — named in BL-005 as the second-weakest branch axis; this is one of the unentered branches. - Remediation, minimal:
DateOnly.TryParse(p, out var d) ? … : Results.Problem(statusCode: 400). - Effort: S
Noted, not filed
GET /uploads/status?localIds= splits an unbounded comma-separated list into a single
WHERE IN query (Program.cs:242-248, DocumentStore.ByLocalIds). No cap. Real but
negligible at POC scale; it belongs with BIO-018's "bound the client-supplied inputs" theme
rather than as a ticket of its own.
Module sections
Every module in scope gets a section. "No findings" is a result, not an omission.
apps/ssp — auth
BIO-017 (the G1 guard has no executable test). The guard itself holds on every path —
restore(), the persistence effect(), login() and logout() were each read and none
writes the BSN to storage. No other findings: DigidAdapter is the faked login CLAUDE.md
places out of scope, and Session.bsn never leaves the in-memory signal (verified: the one
place that needed it, subject.interceptor.ts, deliberately does not reach into
SessionStore — see BIO-012 for what it does instead).
apps/ssp — registratie
No findings of its own. It is the consumer side of BIO-011:
ui/admin-cases.page.ts:91 renders the unmasked BSN column the backend ships; the fix is
server-side. application/draft-sync.ts and the five value objects
(domain/value-objects/) do format validation only and never act as authority — correct per
ADR-0001. parseBsn (libs/shared/src/kernel/bsn.ts) is checksum-only and says so.
apps/ssp — herregistratie
No findings. The scholing threshold arrives from the server as a config value
(domain/intake.machine.ts:52) with the offline fallback ADR-0001 sanctions; nothing in this
context touches PII, authorization or the network directly.
apps/ssp — brief
BIO-012 (the three hand-written fetch adapters carry ?role=/?subject= into
production builds) and BIO-006 (the client sends X-Step-Up: 'true' as a literal). The
context is otherwise the best-behaved consumer of the decision-DTO pattern in the repo:
application/brief.store.ts:31,103-108 derives every gate from BriefState.loaded.decisions
and states "this store never computes them itself" — verified true.
apps/ssp — showcase, shell, root
BIO-017 (shell/debug-state/mask.ts::redactProfile has no spec; verified correct by
reading). The dev panel is genuinely gated and is not a finding —
libs/shared/src/layout/shell/shell.component.ts:73,79 renders it only under
@if (isDev && debugPanel) with isDev = isDevMode(), and
shell/debug-state/debug-state.component.ts:176 masks the BSN even there. The showcase
context is a teaching page and reads every context by sanction; it introduces no data path.
apps/behandelportal — auth
BIO-002 — this is where it lands. app.config.ts:57-63 is the gate;
auth/infrastructure/medewerker.ts:14 is the fixed stand-in id. Also relevant and already
owned by agent 06: auth/ui/login.page.ts:31 logs a backoffice user in through DigiD with a
BSN (ADR-C-004).
apps/behandelportal — behandeling
No findings of its own. Consumer side of BIO-011:
domain/werkvoorraad-item-view.ts:28 renders the unmasked BSN for every queue row while
domain/beoordeling.ts:26 correctly documents its own field as server-masked. Positively:
infrastructure/beoordeling.adapter.ts:87-97 rejects the payload at the parse boundary if
decisions.canBesluiten is absent — deny-by-default at the wire, which is the right reflex.
apps/behandelportal — shell, root
BIO-002 (the app.config.ts interceptor gate). No other findings — routing, providers and
nav config only.
libs/shared — domain
No findings. capability.ts, role.ts, feature-flag.ts — 30 lines of type declarations
with no executable statement (agent 02's verified correction to BL-004). The capability names
are enforced at runtime by parseMe, which is exported and spec'd.
libs/shared — application
No findings. Both access-control primitives were read and are correct:
access.store.ts:34-37 is deny-by-default (rd.tag === 'Success' && rd.value.includes(cap)),
and whenReady() (:50-53) exists specifically so the guard cannot read can() mid-load and
deny an entitled user. auth.guard.ts:22-47 (already moved here per agent 06's ADR-C-006)
reads only SESSION_PORT and AccessStore and documents itself accurately as "the UX
pre-gate. The backend re-enforces regardless (403)". That claim was verified endpoint by
endpoint for the admin surfaces and holds: /beheer/stamdata → StamdataAdmin
(Program.cs:164,173), /beheer/zaken → CasesAdmin (:425,554), /beheer/audit →
CasesAdmin (:566), /beheer/functies → FlagsAdmin (:592), /brief/huisstijl →
OrgAdmin (:726,732,739,751,764,703), /aanvraag/:id → Beoordelen (:446,473). Every
capability the guard checks has a server-side twin. The exceptions are in Program.cs, not
here: BIO-003 (a gate outside Authz), BIO-004 and BIO-005 (endpoints with no gate at all) —
and none of those has a capabilityGuard claiming to front it.
libs/shared — infrastructure
BIO-012 — role.ts:23-31 and subject.ts:23-29 are the ungated readers, and
subject.ts is where a BSN reaches sessionStorage. Otherwise clean:
api-client.provider.ts:58 attaches the Idempotency-Key only when method !== 'GET', and
:66's retry({count: 2}) is GET-only.
libs/shared — ui
No findings. No network, no storage, no PII decision. The masked-value atom renders what
it is given; the masking itself lives in kernel/pii.ts.
libs/shared — layout
No findings. shell.component.ts:73,79 is the dev-panel gate and it is correct
(see ssp/shell above).
libs/shared — kernel
No findings, and this is the reference standard. bsn.ts is a "parse, don't validate"
value object whose doc comment correctly classifies a BSN as "art. 9 GDPR/AVG
special-category data" and correctly scopes itself to format+elfproef ("identity is still
faked in this POC"). pii.ts's maskTail/maskBsn are pure, and the backend keeps a
verified twin (Program.cs:861-863) so wire redaction and UI redaction agree. §3a: 96.4%
line / 90.0% branch, §3b 100% reach — the best-covered module in the repo.
libs/shared — upload
No findings in this remit, but two dependencies to record: the document bytes this module
moves are the objects BIO-004 serves without a check and BIO-014 stores unencrypted.
upload.adapter.ts:216-217's server-side counterpart enforces the content-type allow-list
authoritatively, so the FE's accept filter is UX only — the correct division. Agent 02's
TE-005 (uploadOutcome) is an 8.26-relevant boundary; see the compliance-review section.
libs/shared — testing
No findings. Kept out of production by the no-testing-in-production dependency-cruiser
rule (§6, 0 violations).
libs/shared — environments
No findings. apiBaseUrl only; no credentials, no keys.
libs/beheer
No findings of its own. It is the display surface for BIO-008: ui/audit.page.ts:10-12
describes the trail as "data-minimised, no PII" and renders the resource column that today
contains a BSN. ui/feature-flags.page.ts:93 is the caller of the unaudited admin write in
BIO-007. infrastructure/stamdata.adapter.ts:16-20 is genuinely read-only, which is the
positive ADR-0004 note in §6.
backend/Program.cs
BIO-003, BIO-004, BIO-005, BIO-006, BIO-007, BIO-013, BIO-015, BIO-019. Eight of the
twenty findings land in one 940-line file, which is itself the observation: BL-003 records
it as "the single largest complexity concentration in the repo", and the six authorization
wrappers (OrgAdmin, StamdataAdmin, CasesAdmin, Beoordelen, FlagsAdmin, plus the
orphaned IsAdmin) sit 500 lines below the endpoints they gate, with nothing that enumerates
which endpoint uses which. That is the structural condition under which BIO-003/004/005 exist,
and it is why BIO-016's proposed route-table test is worth more than three individual fixes.
backend/Domain
BIO-001, BIO-002, BIO-006 — all three live in Domain/Authorization/. The rest of the
folder is clean and its design is right: Authz is a single source of truth where it is used,
the same function both emits the decision flag and gates the mutation
(Authz.Decisions ↔ Authz.CanActOn ↔ BriefStore.Review), and the four-eyes rule
(CanActOn(Approve, …) => principal.Role == Approver && ActingId(principal) != drafterId) is
a real segregation-of-duty control, correctly ordered Forbidden-before-Conflict.
CanBeoordelen(CallerIdentity) is deliberately caller-kind-derived rather than role-derived
and is the one capability that a forged X-Role cannot reach — the right instinct, and the
reason BIO-002 fails closed in that direction. Domain/ is EF-free and ASP-free (§7), though
nothing enforces it (BL-006).
backend/Data
BIO-008, BIO-009, BIO-014, BIO-018. All four are storage-side data-handling items rather
than logic defects; the store shape itself (static, Db.Create()-per-call, documented in
Data/Db.cs:6-12) is out of scope here and is deliberately not challenged — agent 02 reaches
the same conclusion from the testability side.
backend/Zgw
BIO-010 only. Otherwise the strongest module in the backend for this pass: the ACL is
complete (ADR-0005, fully conformed per agent 06), the client secret is BFF-only and never
reaches the browser, tokens are short-lived and minted per call with no stored refresh
credential, the inbound notification webhook fails closed on an unconfigured secret
(ZgwOptions.NotificatieAuthorization: "Empty (the default) means every notification is
rejected — an unconfigured secret must never mean 'accept anything'"), and the diagnostic
handler logs no bodies and is opt-in. §3c: 98.1% line / 85.5% branch, the best on the
backend.
backend/Contracts
BIO-011 — Mappers.cs:74 is the single line that decides whether a cross-owner list ships
a raw BSN, so the fix is one expression here rather than at each endpoint. No other findings:
Dtos.cs contains no Bsn field by name (verified by grep) — the BSN travels only as
Owner, which is exactly the field BIO-011 addresses.
backend/Stamdata
BIO-019 only, and it is a low-severity parse gap. The module is otherwise the best change-control story in the repo — see the positive finding in §6.
Compliance review required
This is the mandatory flag. Agent 08 must carry every row into 99-backlog.md, attached to
the finding, not filed as a separate ticket. These are findings from agents 02, 04 and 06
that touch a control area in §0. A flag is not a rejection: each of these should proceed, with
a compliance acceptance criterion added to its definition of done.
| Their ID | Their title (abbrev.) | Control touched | Why it is flagged, and what the added acceptance criterion must be |
|---|---|---|---|
| TE-001 | SessionStore.restore() reads localStorage inline |
5.13, 8.29 | The extracted parseStoredSession is the G1 PII guard. Ship it with an explicit assertion that a stored {"bsn":…} yields bsn: '' — see BIO-017. Without that assertion the ticket moves the guard without testing it. Lands twice (both apps). |
| TE-002 | Trust boundary hidden in a global-fetch method |
5.12, 8.26, 8.15 | The boundary guards a PII reveal. reveal-bignummer.adapter.ts is also the site of BIO-012 (X-Role ungated) and BIO-006 (X-Step-Up: 'true' literal). Fix all three in one touch of the file, or the next reviewer will re-open it. |
| TE-003 | UploadTransport port declared, concrete class injected |
5.12, 8.26 | The transport carries identity-document bytes. Introducing UPLOAD_TRANSPORT must not make it possible to substitute a transport in a production build — token factory only, no route/query override. |
| TE-004 | createUploadController injects + binds at call time |
8.26 | planFileSelection is the client-side accept/reject decision. It is UX only; the ticket must not let it read as authority — the server allow-list (DocumentRules.RejectUpload) stays the authority. State that in the moved function's docstring. |
| TE-005 | xhrUpload interprets responses inside an XHR closure |
8.26 | uploadOutcome is a trust boundary on an untrusted response. Also: the extraction must not move parseError's ProblemDetails text into a place where it can be logged with a filename. |
| TE-006 | Blob-to-browser handoff inlined in 3 commands | 5.13 | The blobs are rendered letters containing name, BIG-nummer and address, and the stamdata download is an admin export. BLOB_PRESENTER must not gain any persistence or caching; recording fakes belong in specs only. |
| TE-008 | Brief transition rules live inside store methods | 9.4 | The five guards being extracted include the authorization ones, and Authz.CanActOn (the four-eyes / SoD rule) sits one line away at BriefStore.cs:163. Acceptance: BriefRules must call Authz, never re-implement it, and tests/Domain/BriefRuleTests.cs must cover approver == drafter. |
| CQ-002 | 2 read stores write without the fold; errors dropped | 8.15, 8.16 | One of the two is AdminCasesStore.delete — a destructive admin action whose failure is currently silent. The error surfacing this ticket adds is the FE half of BIO-007's server-side audit gap. Ship them aware of each other. |
| CQ-003 / CQ-005 | runSubmit (write fold + idempotency mint) used for reads |
9.4, 8.26 | Splitting runResult/runSubmit narrows who mints an Idempotency-Key, which is the key BIO-018 shows is an unscoped cache key. Sequence CQ-003 before BIO-018 so the scoping change lands on a smaller call set. |
| CQ-004 | FeatureFlagStore.set skips the fold, drops the error |
8.15, 8.32 | A flags:manage admin write that fails silently and writes no audit row (BIO-007). This is the concrete case that fails clause (4) of agent 06's ADR-C-009 four-part test. Fix the FE error and the BE audit row together. |
| CQ-006 | Read/write banner split abandoned in 5 of 7 sections | 9.4 | A large-diff, zero-semantic-change reordering across all 48 endpoint mappings, including every authorization wrapper call site. Acceptance: AuthzTests, AuthzAuditTests, WerkvoorraadTests, OrgTemplateEndpointTests, StamdataEndpointTests and AdminCasesTests all green, and a reviewer confirms each moved endpoint kept its gate. Land it alone, as CQ-006 already says. |
| CQ-007 | GET /brief creates a row |
8.26, 9.4 | A GET with a persisted side effect, auto-retried by the FE. Either fix is acceptable to compliance; the documentation-only alternative is not — a non-idempotent GET must be visible in the code, not only in a ticket. |
| ADR-C-002 | libs/shared/src/upload/ does network outside infrastructure/ |
5.12 | Moving the adapter that carries identity documents. Mechanical, but the acceptance criterion (deleting the depcruise carve-out) must not be met by widening a different rule. |
| ADR-C-004 | The Principal union never landed |
9.2, 9.4 | This is the natural home for BIO-002. Acceptance must include: the behandelportal's identity works in a production build, and IIdentityProvider can express "no identity". Landing Principal on the FE alone would close the ADR and leave BIO-002 open. |
| ADR-C-006 | Extract the actor-agnostic route guards to libs/shared |
9.4 | Already present in the tree at libs/shared/src/application/auth.guard.ts — verify the state before ticketing. Any future change to authGuard/capabilityGuard is an access-control change and needs the guard spec re-run for both apps. |
| ADR-C-009 | Generalise the runtime-editable-config exception | 8.32, 9.4 | Agent 06's proposed clause (4) is "writes are admin-capability-gated and audited". Today they are gated but not audited (BIO-007). Either the ADR amendment lands with BIO-007, or the amendment ratifies a control the code does not implement. |
Not flagged (read and judged to touch no control in §0): TE-007, TE-009, CQ-001, ADR-C-001, ADR-C-003, ADR-C-005, ADR-C-007, ADR-C-008, ADR-C-010, ADR-C-011.
Pre-production compliance checklist
The production gate items, as a checklist. None of these is a defect in the POC; every one must be true before this system holds real BSNs. Ordered by dependency, not by severity.
Identity and access (9.1, 9.2, 9.4)
- Replace
StubIdentityProviderwith a provider built from verified DigiD claims (zorgverlener) and verified employee-SSO/eHerkenning claims (medewerker).X-Role,X-Subject,X-Medewerker,X-RollenandX-Adminare removed as inputs, not merely ignored. — BIO-001 IIdentityProvidercan express "no identity", and an unauthenticated request is rejected rather than defaulted. No code path may resolve a caller from a constant. — BIO-002- The behandelportal has a non-dev identity path. Verify by building both apps with
--configuration productionand confirming the backoffice cannot act as a citizen. — BIO-002 (see also ADR-C-004) - A startup assertion fails the app in Production if the resolved
IIdentityProvideris the stub. — BIO-001 - Row-level scoping (PRD-0002 §5b) on every read that returns person data. Acceptance: a second seeded citizen cannot see the first's dashboard, notes, BRP address or diplomas. — BIO-013
- The PII-reveal capability comes from the app overlay, not the coarse role, and is not held by the default role. — BIO-006
- Real step-up.
X-Step-Upis replaced by a server-verified assurance/recency attribute; no client may satisfy it with a constant. — BIO-006
Cryptography (8.24)
- Encryption at rest for the database, with documented key custody and rotation. — BIO-014
- Document bytes move to encrypted object storage keyed by
DocumentId(the code already names this as the target). — BIO-014 - TLS everywhere:
UseHttpsRedirection+ HSTS at the edge, and no plaintext listener. — BIO-015 - Security response headers:
X-Content-Type-Options: nosniff, CSP,Referrer-Policy, and a realAllowedHosts. — BIO-015 - Swagger and the OpenAPI document are Development-only. — BIO-015
Logging, monitoring and retention (8.15, 8.16)
- Every authorization-relevant event is audited on the allow path too — admin mutations, brief approvals/rejections, besluiten, PII reveals. — BIO-007
- No BSN in any audit row, log line or persisted error field, enforced by a test that asserts on values, not column names. — BIO-008, BIO-009, BIO-010
- Audit retention, integrity and access are defined: how long, append-only, who may
read
/beheer/audit(today it reusescases:manage, whichProgram.cs:565already flags as a placeholder for a dedicatedaudit:read). - Log shipping and alerting — the audit trail is a SQLite table with no export path today.
Data protection (5.12, 5.13)
- A DPIA covering BSN, uploaded identity documents and the BIG register, with a documented lawful basis and retention schedule. Nothing in the repo covers this.
- Data minimisation on every list endpoint — no unmasked BSN as a default field. — BIO-011
- Deletion / retention for uploaded documents and the audit trail.
AdminDeleteexists; no retention policy does.
Secure development (8.25, 8.28, 8.29)
- Backend dependency vulnerability scanning in CI. — BIO-016
- Secret scanning in CI. — BIO-016
- An authorization regression gate: a test that enumerates the route table and asserts every route passes an authorization wrapper or is on an explicit allow-list. — BIO-016
- Backend architecture enforcement (NetArchTest/ArchUnitNET) so
Domain/purity, the "ZGW shapes stay inZgw/" property (ADR-0005) and "authorization lives inAuthz" are CI-maintained rather than review-maintained. — BL-006 - A coverage ratchet, so a security fix can be verified as not regressed by CI. — BL-009
- Penetration test / DAST before go-live, with BIO-004's object-level authorization and BIO-005's document linking as named test cases.
Change control (8.32)
- A production build and deployment artifact exists (
--configuration production,ASPNETCORE_ENVIRONMENT=Production), separate from the demo compose file, and its release checklist references this list. — BIO-020 - Verify by build, not by reading: in a production bundle,
?role=,?subject=,?scenario=,?rollen=and the⚙ statepanel are all inert — including on the three hand-writtenfetchpaths. — BIO-012
Summary
| ID | Title | Control | Class | Sev. | Module | Effort |
|---|---|---|---|---|---|---|
| BIO-001 | Backend trusts client-asserted identity headers in every environment | 9.2, 9.4 | production gate | high | BE/Domain+Program | S |
| BIO-002 | Production backoffice has no identity; default is the seeded citizen | 9.4 | production gate | high | bhp/auth+root, BE | S |
| BIO-003 | X-Admin is a second, unaudited gate outside Authz |
9.4, 8.15 | defect now | medium | BE/Program | S |
| BIO-004 | GET /uploads/{id}/content + /uploads/status have no authz check |
9.4, 5.12 | defect now | high | BE/Program+Data | S |
| BIO-005 | POST /registrations links arbitrary documents, unscoped |
9.4, 8.26 | defect now | medium | BE/Program | S |
| BIO-006 | Reveal capability on the default role; step-up is a client constant | 9.4 | production gate | medium | BE/Domain, ssp/brief | S |
| BIO-007 | Only denied decisions are audited; admin writes leave no trail | 8.15, 8.16 | defect now | medium | BE/Program | S–M |
| BIO-008 | BSN written into the authz audit trail's Resource column |
8.15, 5.12 | defect now | high | BE/Program+Data | S |
| BIO-009 | BSN is the Actor on every document audit row |
8.15, 5.12 | defect now | medium | BE/Data | S |
| BIO-010 | BSN reaches logs + a persisted field via the ZGW error path | 8.15, 5.12 | defect now | medium | BE/Zgw+Program | S |
| BIO-011 | Cross-owner lists ship unmasked BSNs; the detail masks | 5.12, 5.13 | defect now | medium | BE/Contracts | S |
| BIO-012 | ?role= / ?subject= reach production on 3 hand-written fetches |
5.13, 9.4 | defect now | medium | ssp/brief, shared/infra | S |
| BIO-013 | Seeded-citizen endpoints ignore the caller (no row scoping) | 9.4, 5.12 | production gate | medium | BE/Program | M |
| BIO-014 | No encryption at rest for bytes, BSNs and the audit trail | 8.24 | production gate | high | BE/Data | L |
| BIO-015 | No TLS/HSTS/nosniff/CSP; Swagger + AllowedHosts:* unconditional |
8.24, 8.28 | production gate | medium | BE/Program | S |
| BIO-016 | CI security gates: semgrep+audit present; 5 gaps | 8.25/28/29 | production gate | medium | repo (CI) | S–M |
| BIO-017 | Two PII guards have no executable test | 8.29, 5.13 | defect now | low | ssp/auth, ssp/shell | S |
| BIO-018 | IdempotencyStore unscoped by caller, no TTL, unbounded |
9.4, 8.26 | defect now | low | BE/Data | S |
| BIO-019 | ?peildatum= 500s on unparseable input |
8.26 | defect now | low | BE/Stamdata | S |
| BIO-020 | The only deployment artifact builds development bundles | 8.32 | production gate | medium | repo (compose) | M |
Twelve defect-now findings (BIO-003, 004, 005, 007, 008, 009, 010, 011, 012, 017, 018, 019 — BIO-006's step-up sub-item is counted inside its production gate), eight production gates. Fifteen of the twenty are effort S.
Modules with no findings of their own: ssp/registratie · ssp/herregistratie · bhp/behandeling · libs/shared/{domain, application, ui, layout, kernel, upload, testing, environments} · libs/beheer.
Recorded as correct, so a later pass does not "fix" them: the deny-by-default
AccessStore.can() + whenReady() pair; capabilityGuard's "UX pre-gate, backend
re-enforces" claim, verified endpoint by endpoint for all six admin surfaces;
Authz.CanBeoordelen's caller-kind derivation (the one capability a forged X-Role cannot
reach); the four-eyes rule in Authz.CanActOn with Forbidden-before-Conflict ordering; the
isDevMode() gate on the debug panel and the interceptor chain; the ZGW secret never reaching
the browser and the notification webhook failing closed on an unset secret; the upload
content-type allow-list enforced server-side; stamdata having no runtime write endpoint at
all; and libs/shared/src/kernel/{bsn,pii}.ts, which are the standard the rest should be
measured against.