No content change — markdown list-marker/line-wrap normalization only. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
9.0 KiB
WP-53 — Inbound identity + citizen-scoping (the ZGW auth seam)
Status: done 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 (159/159, incl. 8 new); dotnet format --verify-no-changes clean;
npm run ci green (no api-client drift). Manual: X-Role/X-Subject still switch identity
offline (no header → the seeded citizen, drafter); with Zgw:Enabled=true (WP-54 harness) a
citizen would see only their zaken via the new rol__…__inpBsn filter.
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. Mitigated: grep gate (clean)ApplicationTests.Applications_are_scoped_to_the_caller_bsn(twoX-Subjectidentities, HTTP end-to-end) proving neither sees the other's cases.
- ZGW rol filter param name is exact and version-sensitive; asserted in
OpenZaakZaakSourceTests.ListMyCases_filters_by_the_callers_bsn.
Session notes
Built as designed — no premise in the Decisions/Context block turned out stale. One
implementation choice not spelled out in the WP: Authz.ResolvePrincipal(HttpContext ctx) kept
its exact signature (now new(ctx.Caller().Role) instead of re-reading X-Role itself), so
none of its ~15 call sites needed touching — "flow it to Authz.ResolvePrincipal" didn't require
threading CallerIdentity through every endpoint that resolves a Principal. ZgwTokenProvider
grew a Mint(CallerIdentity) overload alongside the existing parameterless Mint() (kept for
calls not tied to one citizen — the admin cross-owner ListCases, and Catalogi metadata lookups)
rather than replacing it outright, so ZgwOptions.UserId/UserRepresentation stay meaningful as
the BFF's own system identity. IZaakSource/IDocumentSource gained an explicit CallerIdentity
parameter on every citizen-scoped method (ListMyCases, CreateZaak, Upload, LinkToZaak)
rather than resolving it ambiently via IHttpContextAccessor — kept it unit-testable without any
DI/HttpContext ceremony (see StubIdentityProviderTests, the ZgwTokenProviderTests addition).
GET /applications (the citizen's own dashboard list) is now routed through
IZaakSource.ListMyCases instead of calling ApplicationStore directly — closing the exact gap
openzaak-integration.md's ACL caveat used to flag for that endpoint; under Zgw:Enabled=true it
would now source from OpenZaak (BSN-filtered) like /admin/cases already did.