The two highest-value OpenZaak roadmap gaps, each written self-contained (a "current state" handoff section) so a fresh session can execute from the file + repo alone: - WP-53: replace the stubbed owner/BSN with a real per-request CallerIdentity (pluggable stub, not DigiD), threading it into Authz, the ZGW JWT user claims, and a citizen-scoped read (rol__…__inpBsn). Production-blocking for a real deployment. - WP-54: a separate docker-compose OpenZaak + scripted bootstrap + opt-in Category=Integration test — makes 50/51/52 developable against a live instance instead of only fixtures; kept out of the default gate. Indexed in the backlog README (rows + phase-9 ordering note) and cited from openzaak-integration.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7.2 KiB
WP-53 — Inbound identity + citizen-scoping (the ZGW auth seam)
Status: todo Phase: 9 — OpenZaak / ZGW integration
Why
WP-49 made the cases read path swappable, but everything runs as a stubbed identity: the
principal comes from an X-Role header and the "owner" is a single hardcoded BSN. A real
OpenZaak integration needs a genuine per-request user in order to (a) fill the ZGW JWT
user_id/user_representation audit claims, and (b) scope zaken to the logged-in citizen
(you must never return another citizen's cases). This WP threads a real identity through the
system without building DigiD/OIDC itself — CLAUDE.md keeps real auth out of scope, so the
deliverable is the seam: a per-request CallerIdentity (subject BSN + display name) produced
by a pluggable, stubbed provider, consumed everywhere the hardcoded owner is used today.
Production later swaps the stub for OIDC/DigiD without touching any consumer.
Context — current state (read before designing; this is the handoff, no prior chat needed)
Identity is faked in these exact places — this WP replaces the fakes with one identity flow:
- Backend principal:
backend/src/BigRegister.Api/Domain/Authorization/Authz.cs—ResolvePrincipal(ctx)reads theX-Roleheader (drafter/approver/admin). Its own doc comment says "A real system builds this from verified AD/OIDC claims … everything else in this file carries over unchanged once that swap happens." That is the seam to formalize. - Hardcoded owner/BSN:
backend/src/BigRegister.Api/Data/DocumentStore.cs—public const string DemoOwner = "19012345601";(the single seeded citizen's BIG-nummer). GrepDemoOwneracrossProgram.cs+ stores — every "whose data is this" decision uses it. - Owner-scoped stores already take an
ownerstring:Data/ApplicationStore.cs(List(owner),Get(id, owner),CreateConcept(type, owner),Submit(id, owner, …)) andData/DocumentStore.cs. They are ready to receive a real BSN — today the endpoints passDocumentStore.DemoOwner. - ZGW JWT user claims are static:
Zgw/ZgwTokenProvider.csMint()readsZgwOptions.UserId/ZgwOptions.UserRepresentation(constant strings). These must become per-request (the acting citizen), or the ZGW audit trail is wrong. - The cases read interface
Data/IZaakSource.cshas one method,ListCases(now), with no caller — it returns the admin cross-owner list. There is no citizen-scoped "my cases" read yet, andOpenZaakZaakSourcelists ALL zaken ({ZrcBaseUrl}/zaken, no filter). - Correlation middleware (
Program.cs, theapp.Use(...)block settingX-Correlation-Id) is the pattern/location to add an identity-resolution middleware next to. - Frontend identity is the dev role switch:
?role=drafter|approver|admin+ the⚙ statepanel +SessionStore(src/app/auth/), documented indocs/reference/roles-and-access.md. The FE already persists a session (localStorage). No FE change is required for the backend seam, but the citizen's BSN must originate from the session, not a constant — note where.
ZGW detail that drives the scoping query: OpenZaak filters a citizen's zaken via the query
param rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn=<bsn> on GET {ZRC}/zaken.
Read first
- openzaak-integration.md — the seam + the "two nested ACLs" section (this WP is about the identity that flows through both).
- ADR-0005 — OpenZaak behind the BFF ("Deferred: real inbound OIDC/JWT auth" — this WP formalizes the seam, not the provider).
- ADR-0002 — user groups & bounded contexts, roles-and-access.md.
CLAUDE.md→ "Out of scope: Real auth/DigiD" — respect it: build the seam + a stub, not DigiD.
Decisions (pre-made, don't relitigate)
- Seam, not provider. Introduce a
CallerIdentity(subject BSN + display name + role) and anIIdentityProviderwith aStubIdentityProvider(reads the existingX-Role+ a configurable/X-SubjectBSN, defaulting to the seeded citizen). Production swaps the provider; no consumer changes. Do not add DigiD/OIDC. - One source of "who". Resolve
CallerIdentityonce per request (middleware, beside the correlation block) and flow it to:Authz.ResolvePrincipal, the storeownerarguments (replaceDocumentStore.DemoOwnercall sites), andZgwTokenProvider.Mint(caller). - Citizen-scoped reads are separate from admin reads. Keep the admin cross-owner list
(
cases:manage) as-is; add a citizen-scoped "my zaken" path that filters by the caller's BSN (ZGWrol__…__inpBsn; local store:List(owner)). - Ownership stays server-authoritative. The BSN comes from the resolved identity, never from a client-supplied body field.
Files
- New:
Domain/Authorization/CallerIdentity.cs,Domain/Authorization/IIdentityProvider.cs+StubIdentityProvider.cs; an identity-resolution middleware inProgram.cs. - Edit:
Domain/Authorization/Authz.cs(build the principal fromCallerIdentity),Zgw/ZgwTokenProvider.cs(Mint(CallerIdentity)),Zgw/OpenZaakZaakSource.cs(BSN filter on the citizen read),Data/IZaakSource.cs(+ a caller-scoped read),Program.cs(replaceDemoOwnercall sites with the resolved BSN; DI-register the provider). - Tests: identity resolution (stub), token carries the per-request user, citizen read filters by BSN (stub handler asserts the query param), admin read still cross-owner.
Steps
- Add
CallerIdentity+IIdentityProvider+StubIdentityProvider(X-Role + X-Subject BSN, default = seeded citizen); DI-register; resolve once in middleware intoHttpContext.Items. - Route
Authz.ResolvePrincipaland everyDemoOwnercall site through the resolved identity. ZgwTokenProvider.Mint(caller)— per-requestuser_id/user_representation.- Add a caller-scoped cases read to
IZaakSource(+ both impls);OpenZaakZaakSourceadds therol__…__inpBsnfilter; local usesApplicationStore.List(owner). - Tests as above; keep the admin list unchanged.
Acceptance criteria
- No
DocumentStore.DemoOwnerreference remains in request handling (grep clean); ownership comes from the resolved identity. - ZGW JWT carries the acting citizen's
user_id/user_representation(test-verified). - A citizen read returns only that BSN's zaken (local + ZGW-stub tests); admin read unchanged.
dotnet testgreen;npm run cigreen with no api-client drift (FE contract intact).
Verification
cd backend && dotnet test; manual: X-Role/X-Subject (or ?role=) still switches identity
offline; with Zgw:Enabled=true (WP-54 harness) a citizen sees only their zaken.
Out of scope
Real DigiD/OIDC/JWT validation (this is the seam + stub only), FE login redesign, multi-tab session sync (CLAUDE.md out-of-scope list).
Risks
- Missing a
DemoOwnercall site → a citizen sees another's data. Mitigate: grep gate in the acceptance criteria + a test that two identities don't see each other's cases. - ZGW rol filter param name is exact and version-sensitive; assert it in the stub-handler test.