feat(zgw): real per-request identity seam + citizen-scoping (WP-53)
Replaces the hardcoded DocumentStore.DemoOwner and the static ZgwOptions
UserId/UserRepresentation with one per-request CallerIdentity, resolved by a
pluggable IIdentityProvider (StubIdentityProvider reads X-Role/X-Subject
today; a real OIDC/DigiD provider swaps in without touching any consumer).
- Domain/Authorization/{CallerIdentity,IIdentityProvider,StubIdentityProvider}.cs
+ a resolution middleware in Program.cs, right after correlation-id.
- Authz.ResolvePrincipal(ctx) keeps its signature (now reads ctx.Caller().Role),
so its ~15 call sites needed no changes.
- Every endpoint that passed DocumentStore.DemoOwner to a store now passes
ctx.Caller().Bsn.
- ZgwTokenProvider gains Mint(CallerIdentity) alongside the original Mint()
(kept for calls not tied to one citizen); ZgwHttpClient threads an optional
caller through to pick the right overload.
- IZaakSource gains ListMyCases(caller, now) — the citizen-scoped read
OpenZaakZaakSource backs with ZGW's rol__...__inpBsn filter. GET /applications
now routes through it instead of ApplicationStore directly, closing the last
"reads a static store" gap for a citizen-facing endpoint.
Backend 159/159 tests (+8, incl. an HTTP-level two-identity scoping proof),
npm run ci green, no api-client drift.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -55,7 +55,7 @@ precisely what was just computed; under OpenZaak, three calls happen in order:
|
||||
(`statustypen?zaaktype=...`, lowest `volgnummer`); marks the zaak as freshly opened.
|
||||
3. **POST rol** (`{ZrcBaseUrl}/rollen`) — `roltype` resolved via a Catalogi GET
|
||||
(`roltypen?zaaktype=...&omschrijvingGeneriek=initiator`); `betrokkeneIdentificatie.inpBsn`
|
||||
set to the aanvraag's owner (BSN) — the current stand-in for real identity (WP-53).
|
||||
set to the aanvraag's owner (BSN) — the acting citizen resolved by the identity seam (WP-53).
|
||||
|
||||
The created zaak's `identificatie` becomes the returned `Referentie`; its status maps to the
|
||||
same coarse `InBehandeling` shape `ZgwZaakMapper` already uses for a freshly-opened zaak
|
||||
@@ -140,6 +140,39 @@ pointing at this BFF's public URL:
|
||||
}
|
||||
```
|
||||
|
||||
## Identity — the acting citizen (WP-53)
|
||||
|
||||
Everything above used to hardcode a single owner (`DocumentStore.DemoOwner`) and a single static
|
||||
ZGW audit identity (`ZgwOptions.UserId`/`UserRepresentation`). WP-53 replaced both with one
|
||||
per-request `CallerIdentity` (subject BSN + display name + role, `Domain/Authorization/
|
||||
CallerIdentity.cs`):
|
||||
|
||||
- **Resolution**: an `IIdentityProvider` runs once per request (middleware in `Program.cs`,
|
||||
right after the correlation-id middleware) into `HttpContext.Items`, read back everywhere via
|
||||
`ctx.Caller()`. `StubIdentityProvider` (the only implementation today, **not a security
|
||||
boundary**) reads the existing `X-Role` header (unchanged — mirrors the FE's `?role=` toggle)
|
||||
plus a new `X-Subject` header for the BSN, defaulting to the single seeded citizen — so every
|
||||
request that doesn't send `X-Subject` (which is every request today; the FE never sends it)
|
||||
behaves exactly as before this WP. A production provider swaps in real OIDC/DigiD claims
|
||||
without touching a single consumer.
|
||||
- **`Authz.ResolvePrincipal(ctx)` kept its exact signature** — it now reads `ctx.Caller().Role`
|
||||
instead of the header directly, so its ~15 call sites across `Program.cs` needed no changes.
|
||||
- **Ownership**: every endpoint that used to pass `DocumentStore.DemoOwner` to a store
|
||||
(`ApplicationStore`, `DocumentStore`, `BriefStore`) now passes `ctx.Caller().Bsn`.
|
||||
- **The ZGW JWT** (`ZgwTokenProvider`) grew a `Mint(CallerIdentity)` overload alongside the
|
||||
original parameterless `Mint()`: citizen-scoped calls (create-zaak, upload, zaak-link, the
|
||||
citizen's own case list) mint with the caller's BSN/name as `user_id`/`user_representation`;
|
||||
calls not tied to one citizen (the admin cross-owner list, Catalogi metadata lookups) keep
|
||||
minting with the BFF's own system identity from `ZgwOptions`. `ZgwHttpClient.GetAsync`/
|
||||
`PostAsync` take an optional `CallerIdentity?` that picks which `Mint` overload runs.
|
||||
- **Citizen-scoped reads**: `IZaakSource` gained `ListMyCases(CallerIdentity, now)` alongside the
|
||||
existing admin-only `ListCases(now)`. `LocalZaakSource` filters `ApplicationStore.List(bsn)`
|
||||
(unchanged local behaviour); `OpenZaakZaakSource` appends ZGW's
|
||||
`rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn=<bsn>` query filter to `GET
|
||||
{ZrcBaseUrl}/zaken`. `GET /applications` (the citizen's own dashboard) now routes through this
|
||||
instead of calling `ApplicationStore` directly — the last "reads a static store directly" gap
|
||||
the ACL caveat below used to flag for a citizen-facing endpoint.
|
||||
|
||||
## The five ZGW APIs (context for later slices)
|
||||
|
||||
| API | Component | Used by |
|
||||
@@ -233,19 +266,19 @@ Principles this demonstrates:
|
||||
comment in `ZgwZaakMapper` show where the ACL is deliberately thin — an ACL need not be
|
||||
complete on day one, but its shortcuts should be visible.
|
||||
|
||||
Caveat: `IZaakSource` covers the cases **read + create** path (WP-49/50), `IDocumentSource`
|
||||
covers **upload + zaak-link** (WP-51), and the inbound `POST /zgw/notificaties` webhook
|
||||
(WP-52) closes the read/write/document/notify arc. Other BFF endpoints still read
|
||||
`SeedData`/static stores directly — ACL-ready (the DTO seam exists) but not yet swappable.
|
||||
What's left in this arc is the two cross-cutting WPs production needs: **WP-53** (a real
|
||||
per-request identity seam + citizen-scoping — today the owner/BSN is stubbed) and **WP-54** (a
|
||||
docker OpenZaak harness + opt-in integration test — today everything is fixture/mock-tested
|
||||
against no live instance).
|
||||
Caveat: `IZaakSource` covers the cases **read (admin + citizen-scoped) + create** path
|
||||
(WP-49/50/53), `IDocumentSource` covers **upload + zaak-link** (WP-51), the inbound
|
||||
`POST /zgw/notificaties` webhook (WP-52) closes the read/write/document/notify arc, and WP-53
|
||||
threaded a real per-request `CallerIdentity` through all of it (ownership + the ZGW audit
|
||||
claims). Other BFF endpoints (reference data like `SeedData`'s BRP/DUO mimics) still read static
|
||||
stores directly — ACL-ready (the DTO seam exists) but not yet swappable, and not part of this
|
||||
arc. What's left is **WP-54**: a docker OpenZaak harness + opt-in integration test — today
|
||||
everything is fixture/mock-tested against no live instance.
|
||||
|
||||
## See also
|
||||
|
||||
- [ADR-0005 — OpenZaak behind the BFF](architecture/0005-openzaak-behind-bff.md) — the decision.
|
||||
- [ADR-0001 — BFF-lite + decision DTOs](architecture/0001-bff-lite-decision-dtos.md) — why the FE doesn't change.
|
||||
- [WP-49](../project/backlog/WP-49-openzaak-zaken-read-seam.md) (this), WP-50/51 (CRUD arc so far), WP-52 (notificaties), WP-53/54 (identity seam + integration harness).
|
||||
- [WP-49](../project/backlog/WP-49-openzaak-zaken-read-seam.md) (this), WP-50/51 (CRUD arc so far), WP-52 (notificaties), WP-53 (identity seam + citizen-scoping), WP-54 (integration harness, open).
|
||||
- `backend/src/BigRegister.Api/Zgw/` — the client; `Data/IZaakSource.cs`/`Data/IDocumentSource.cs` — the seams.
|
||||
- [ZGW standard (VNG)](https://vng-realisatie.github.io/gemma-zaken/) · [OpenZaak auth docs](https://open-zaak.readthedocs.io/en/stable/client-development/authentication.html).
|
||||
|
||||
Reference in New Issue
Block a user