# 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 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=` on `GET {ZRC}/zaken`. ## Read first - [openzaak-integration.md](../reference/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](../reference/architecture/0005-openzaak-behind-bff.md) ("Deferred: real inbound OIDC/JWT auth" — this WP formalizes the seam, not the provider). - [ADR-0002 — user groups & bounded contexts](../reference/architecture/0002-user-groups-and-bounded-contexts.md), [roles-and-access.md](../reference/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 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 - [x] No `DocumentStore.DemoOwner` reference remains in request handling (grep clean); ownership comes from the resolved identity. - [x] ZGW JWT carries the acting citizen's `user_id`/`user_representation` (test-verified). - [x] A citizen read returns only that BSN's zaken (local + ZGW-stub tests); admin read unchanged. - [x] `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.