Files
atomic-design-poc/docs/project/backlog/WP-53-inbound-identity-and-citizen-scoping.md
T
ehoandClaude Opus 4.8 dfd64baba8
CI / frontend (push) Successful in 2m20s
CI / backend (push) Successful in 1m51s
CI / e2e (push) Successful in 3m20s
CI / storybook-a11y (push) Failing after 8m35s
CI / semgrep (push) Successful in 1m5s
CI / api-client-drift (push) Successful in 1m52s
docs(backlog): add WP-53 (identity seam + citizen-scoping) and WP-54 (OpenZaak harness)
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>
2026-07-27 09:05:58 +02:00

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 the X-Role header (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). Grep DemoOwner across Program.cs + stores — every "whose data is this" decision uses it.
  • Owner-scoped stores already take an owner string: Data/ApplicationStore.cs (List(owner), Get(id, owner), CreateConcept(type, owner), Submit(id, owner, …)) and Data/DocumentStore.cs. They are ready to receive a real BSN — today the endpoints pass DocumentStore.DemoOwner.
  • ZGW JWT user claims are static: Zgw/ZgwTokenProvider.cs Mint() reads ZgwOptions.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.cs has one method, ListCases(now), with no caller — it returns the admin cross-owner list. There is no citizen-scoped "my cases" read yet, and OpenZaakZaakSource lists ALL zaken ({ZrcBaseUrl}/zaken, no filter).
  • Correlation middleware (Program.cs, the app.Use(...) block setting X-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 ⚙ state panel + SessionStore (src/app/auth/), documented in docs/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

Decisions (pre-made, don't relitigate)

  • Seam, not provider. Introduce a CallerIdentity (subject BSN + display name + role) and an IIdentityProvider with a StubIdentityProvider (reads the existing X-Role + a configurable/X-Subject BSN, defaulting to the seeded citizen). Production swaps the provider; no consumer changes. Do not add DigiD/OIDC.
  • One source of "who". Resolve CallerIdentity once per request (middleware, beside the correlation block) and flow it to: Authz.ResolvePrincipal, the store owner arguments (replace DocumentStore.DemoOwner call sites), and ZgwTokenProvider.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 (ZGW rol__…__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 in Program.cs.
  • Edit: Domain/Authorization/Authz.cs (build the principal from CallerIdentity), Zgw/ZgwTokenProvider.cs (Mint(CallerIdentity)), Zgw/OpenZaakZaakSource.cs (BSN filter on the citizen read), Data/IZaakSource.cs (+ a caller-scoped read), Program.cs (replace DemoOwner call 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

  1. Add CallerIdentity + IIdentityProvider + StubIdentityProvider (X-Role + X-Subject BSN, default = seeded citizen); DI-register; resolve once in middleware into HttpContext.Items.
  2. Route Authz.ResolvePrincipal and every DemoOwner call site through the resolved identity.
  3. ZgwTokenProvider.Mint(caller) — per-request user_id/user_representation.
  4. Add a caller-scoped cases read to IZaakSource (+ both impls); OpenZaakZaakSource adds the rol__…__inpBsn filter; local uses ApplicationStore.List(owner).
  5. Tests as above; keep the admin list unchanged.

Acceptance criteria

  • No DocumentStore.DemoOwner reference 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 test green; npm run ci green 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 DemoOwner call 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.