Files
atomic-design-poc/docs/project/backlog/WP-53-inbound-identity-and-citizen-scoping.md
T
ehoandClaude Sonnet 5 29257048b5 fix(format): prettier drift in 2 docs (pre-existing, caught by format:check)
No content change — markdown list-marker/line-wrap normalization only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 09:24:53 +02:00

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.csResolvePrincipal(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.cspublic 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 (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 DemoOwner call site → a citizen sees another's data. Mitigated: grep gate (clean)
    • ApplicationTests.Applications_are_scoped_to_the_caller_bsn (two X-Subject identities, 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.