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>
118 lines
7.2 KiB
Markdown
118 lines
7.2 KiB
Markdown
# 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
|
|
|
|
- [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
|
|
|
|
- [ ] 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.
|