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>
285 lines
18 KiB
Markdown
285 lines
18 KiB
Markdown
# OpenZaak / ZGW integration — how the BFF connects (& how to extend)
|
||
|
||
How the BFF sources (and now creates) cases, and uploads/links documents, against a real
|
||
**OpenZaak** (ZGW APIs) while the frontend stays unchanged. For the _why_, see
|
||
[ADR-0005](architecture/0005-openzaak-behind-bff.md); this page is _how the seam is built and
|
||
how to add the next slice_. Built in
|
||
[WP-49](../project/backlog/WP-49-openzaak-zaken-read-seam.md) (read-only zaken),
|
||
[WP-50](../project/backlog/WP-50-openzaak-create-zaak.md) (create-zaak), and
|
||
[WP-51](../project/backlog/WP-51-openzaak-documenten.md) (Documenten/DRC upload + zaak link).
|
||
|
||
## The one rule: OpenZaak sits behind the BFF, never in the browser
|
||
|
||
The Angular app only ever sees the BFF's decision DTOs (BFF-lite, ADR-0001). All ZGW
|
||
awkwardness — URL-as-identity, cross-service joins, JWT auth, pagination — is absorbed by the
|
||
.NET BFF. Flipping the data source from local SQLite to OpenZaak is a **backend config change**
|
||
with **zero frontend change and no api-client drift**.
|
||
|
||
## The seam (data source by config)
|
||
|
||
- `Data/IZaakSource.cs` — the cases READ + (WP-50) WRITE interface: `ListCases` and
|
||
`CreateZaak`. Both return the existing DTOs, so each implementation owns its own mapping.
|
||
`CreateZaak` also returns the zaak's URL (`ZaakUrl`, null under the local source) so WP-51
|
||
can later link documents to it.
|
||
- `Data/LocalZaakSource.cs` — **default**; reads the local SQLite `ApplicationStore`
|
||
(offline, unchanged behaviour). `CreateZaak` is a pure passthrough of what the submit
|
||
endpoint already computed locally — no external call.
|
||
- `Zgw/OpenZaakZaakSource.cs` — the OpenZaak client; selected only when `Zgw:Enabled=true`.
|
||
`CreateZaak` posts a Zaak, then a Status, then a Rol (see below).
|
||
- `Data/IDocumentSource.cs` — the documents seam (WP-51), sibling of `IZaakSource`: `Upload`
|
||
and `LinkToZaak`. `Data/LocalDocumentSource.cs` is the same `DocumentStore.Add`/`Link` calls
|
||
the upload/submit endpoints used to make inline; `Zgw/OpenZaakDocumentSource.cs` also
|
||
registers each upload as a DRC document and links it to a zaak once one exists.
|
||
- Wiring (`Program.cs`): `if (Zgw:Enabled)` registers `OpenZaakZaakSource` +
|
||
`OpenZaakDocumentSource`, else `LocalZaakSource` + `LocalDocumentSource`. The `/admin/cases`
|
||
GET, the `/uploads` POST, and the `/applications/{id}/submit` POST all resolve their seam
|
||
from DI — routes + DTOs untouched either way.
|
||
|
||
## Create-zaak (WP-50) — the first write
|
||
|
||
`POST /applications/{id}/submit` already persists the aanvraag locally (`ApplicationStore.Submit`
|
||
— unconditionally, regardless of `Zgw:Enabled`, since draft/step/document bookkeeping stays
|
||
local either way) and only THEN calls `zaken.CreateZaak(submitted, now)`. The submit endpoint
|
||
never branches on `Zgw:Enabled` itself — DI already picked the implementation, so the endpoint
|
||
just asks the seam for `(Referentie, Status, ZaakUrl)` and returns the first two, unchanged, in
|
||
`SubmitApplicationResponse` (`ZaakUrl` is persisted via `ApplicationStore.SetZaakUrl` for
|
||
WP-51's document link, not returned to the FE). Under the default (local) source this returns
|
||
precisely what was just computed; under OpenZaak, three calls happen in order:
|
||
|
||
1. **POST zaak** (`{ZrcBaseUrl}/zaken`) — `zaaktype` resolved from `Zgw:ZaaktypeUrls[aanvraag.Type]`
|
||
(OpenZaak validates the URL by fetching it), `bronorganisatie`/`verantwoordelijkeOrganisatie`
|
||
(RSIN) from config, `identificatie` set to the **same** reference `ApplicationStore.Submit`
|
||
already generated — so the human-readable reference matches in both places, not two
|
||
independently-generated ones.
|
||
2. **POST status** (`{ZrcBaseUrl}/statussen`) — `statustype` resolved via a Catalogi GET
|
||
(`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 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
|
||
(`ZgwZaakMapper.ToCreatedStatusDto`).
|
||
|
||
ponytail shortcuts, marked at the call sites: (a) "first statustype/roltype Catalogi returns"
|
||
rather than a fully-configured per-type map — fine while a zaaktype has exactly one initial
|
||
status and initiator role; (b) no compensating transaction — if any ZGW call throws, the
|
||
aanvraag is already `Submitted` locally with no matching zaak (acceptable for a demo backend;
|
||
a production arc needs retry/reconciliation or an outbox before trusting this dual-write).
|
||
|
||
## Documenten / DRC upload + zaak link (WP-51)
|
||
|
||
`POST /uploads` and `POST /applications/{id}/submit` route through `IDocumentSource` the same
|
||
way submit routes through `IZaakSource`: the local write (`DocumentStore.Add`/`Link`) always
|
||
happens first — it stays the record of truth for preview/download/audit regardless of
|
||
`Zgw:Enabled` — and `OpenZaakDocumentSource` additionally does the DRC side-effect:
|
||
|
||
1. **Upload** — POST `enkelvoudiginformatieobjecten` (`{DrcBaseUrl}`) with the file's base64
|
||
content, `informatieobjecttype` resolved from `Zgw:InformatieobjecttypeUrls[categoryId]`
|
||
(the document analogue of `ZaaktypeUrls`), `identificatie` set to the local document id. The
|
||
returned DRC url is persisted (`DocumentStore.SetDrcUrl`) so the link step below doesn't
|
||
need to re-upload.
|
||
2. **Link to zaak** — once `IZaakSource.CreateZaak` has returned a `ZaakUrl` (persisted via
|
||
`ApplicationStore.SetZaakUrl`), submit calls `documents.LinkToZaak(documentIds, zaakUrl)`,
|
||
which POSTs a `zaakinformatieobjecten` (`{ZrcBaseUrl}`) per document that has a `DrcUrl`.
|
||
Documents uploaded before a zaak existed (or under a config gap) have no `DrcUrl` yet and
|
||
are silently skipped — same "nothing extra to link" behaviour as the local source.
|
||
|
||
`ZgwHttpClient` (shared GET/POST-with-bearer-JWT plumbing) was factored out of
|
||
`OpenZaakZaakSource` once `OpenZaakDocumentSource` needed the identical boilerplate.
|
||
|
||
ponytail shortcut: `vertrouwelijkheidaanduiding` is hardcoded to `"openbaar"` — a per-category
|
||
confidentiality level would matter for production but isn't needed to prove the seam.
|
||
|
||
## The ZGW client (`backend/src/BigRegister.Api/Zgw/`)
|
||
|
||
- `ZgwOptions.cs` — bound from the `Zgw` appsettings section: `Enabled`, per-service base URLs
|
||
(`ZrcBaseUrl`, `ZtcBaseUrl`, `DrcBaseUrl`), `ClientId`, `Secret`, `UserId`,
|
||
`UserRepresentation`. The five ZGW APIs are separate base URLs; slices 1–3 need Zaken (ZRC),
|
||
Catalogi (ZTC), and Documenten (DRC).
|
||
- `ZgwTokenProvider.cs` — mints an **HS256 JWT per call** (`iss`/`client_id`/`iat`/`user_id`/
|
||
`user_representation`). No refresh flow — OpenZaak expires tokens 1h past `iat`, so per-call
|
||
minting is the recommended pattern. Hand-rolled (no `Microsoft.IdentityModel.*` dependency).
|
||
- `ZgwHttpClient.cs` — shared GET/POST-with-bearer-JWT plumbing used by both
|
||
`OpenZaakZaakSource` and `OpenZaakDocumentSource`.
|
||
- `ZgwZaakMapper.cs` — the anti-corruption map: ZGW Zaak → `ApplicationSummaryDto`. This is
|
||
where **URL identity** becomes the trailing uuid and the **zaaktype URL** is resolved to a
|
||
human label (the cross-service join).
|
||
- `OpenZaakZaakSource.cs` — follows `{count,next,previous,results}` pagination, resolves +
|
||
caches zaaktype labels, attaches `Authorization: Bearer <jwt>`.
|
||
- `OpenZaakDocumentSource.cs` — DRC upload + zaak-link (WP-51), same auth/JSON pattern.
|
||
- `NotificatieDto.cs` + the `POST /api/v1/zgw/notificaties` endpoint (`Program.cs`, WP-52) — the
|
||
**inbound** NRC webhook, not a source/mapper: see the dedicated section below.
|
||
|
||
## Notificaties (NRC) webhook — inbound, WP-52
|
||
|
||
Unlike ZRC/ZTC/DRC (which the BFF calls outbound as a client), the Notificaties API calls
|
||
**this BFF** — OpenZaak POSTs a `NotificatieDto`-shaped body to `POST /api/v1/zgw/notificaties`
|
||
on every event on a subscribed kanaal. Auth is inverted too: no per-call JWT, just a fixed
|
||
shared secret compared in constant time (`CryptographicOperations.FixedTimeEquals`) against
|
||
`ZgwOptions.NotificatieAuthorization` — an unconfigured (empty) secret rejects every call,
|
||
never accepts. Every attempt (accept or reject) is written to the same `AuthzAuditStore` the
|
||
authz gate uses (`action="zgw:notificatie"`, `resource=hoofdObject` — a URL, not PII, `role="nrc"`)
|
||
via the store directly, since there's no `Principal` for an NRC caller to run through the
|
||
`AuditAuthz` helper.
|
||
|
||
There is no cache to invalidate today (`/admin/cases` and every other read already goes straight
|
||
to `IZaakSource` per call), so a valid notification's only visible effect right now is the audit
|
||
row proving the round-trip works end-to-end. Add real invalidation at the `// ponytail:` marker
|
||
in `Program.cs` if a cache is ever introduced.
|
||
|
||
**Provisioning the `abonnement` is out-of-band, one-time config against a live OpenZaak — not
|
||
app code.** Register it once (e.g. via OpenZaak's admin UI or a `POST` to its Abonnementen API)
|
||
pointing at this BFF's public URL:
|
||
|
||
```jsonc
|
||
{
|
||
"callbackUrl": "https://<this-bff>/api/v1/zgw/notificaties",
|
||
"auth": "<same value as Zgw:NotificatieAuthorization>",
|
||
"kanalen": [{ "filters": {}, "naam": "zaken" }],
|
||
}
|
||
```
|
||
|
||
## 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 |
|
||
| ------------ | --------- | --------------------------------------------------- |
|
||
| Zaken | ZRC | slice 1 (read), WP-50 (create) |
|
||
| Catalogi | ZTC | slice 1 (zaaktype label; also type URLs for create) |
|
||
| Documenten | DRC | WP-51 (upload + zaak↔document link) |
|
||
| Besluiten | BRC | later (formal decisions) |
|
||
| Notificaties | NRC | WP-52 (live status via webhooks, not polling) |
|
||
|
||
## How to add the next slice
|
||
|
||
1. **Read** — extend `IZaakSource` (or add a sibling interface, like `IDocumentSource`, WP-51)
|
||
with the new operation; implement it on both the local store and the OpenZaak source. Keep
|
||
the return type the existing DTO so the FE never changes.
|
||
2. **Write** (create-zaak WP-50, DRC upload/link WP-51) — a create/upload needs a type URL
|
||
from Catalogi (OpenZaak validates it by fetching), then usually a follow-up call (`status` +
|
||
`rol` for a zaak; `zaakinformatieobject` for a document). Route it through the existing
|
||
submit/mutation seam.
|
||
3. **Enforce server-side** for anything the FE gates — a config value the FE echoes is never
|
||
the authority (ADR-0001).
|
||
|
||
## Coupling
|
||
|
||
Low and one-directional. Consumer coupling is near zero — `IZaakSource`/`IDocumentSource` are
|
||
each injected at one endpoint, and the FE is fully decoupled by the DTO. The producer side is
|
||
contained in `Zgw/`: add a slice by adding a source method + a mapper case, not by touching the
|
||
FE or the contract. Watch the **sync-over-async** `ponytail:` note in `OpenZaakZaakSource` (and
|
||
its `OpenZaakDocumentSource` sibling) — make the read/write paths async if OpenZaak becomes the
|
||
default.
|
||
|
||
## Config
|
||
|
||
```jsonc
|
||
// appsettings.json — off by default (POC runs offline on the local store)
|
||
"Zgw": {
|
||
"Enabled": true,
|
||
"ZrcBaseUrl": "https://open-zaak.example/zaken/api/v1",
|
||
"ZtcBaseUrl": "https://open-zaak.example/catalogi/api/v1",
|
||
"DrcBaseUrl": "https://open-zaak.example/documenten/api/v1",
|
||
"ClientId": "big-register", "Secret": "<from a secret store>",
|
||
"UserId": "<session user>", "UserRepresentation": "<session name>",
|
||
// WP-50 (create-zaak): RSINs + the aanvraag-type → zaaktype URL map.
|
||
"Bronorganisatie": "<RSIN>", "VerantwoordelijkeOrganisatie": "<RSIN>",
|
||
"ZaaktypeUrls": {
|
||
"registratie": "https://open-zaak.example/catalogi/api/v1/zaaktypen/<uuid>",
|
||
"herregistratie": "https://open-zaak.example/catalogi/api/v1/zaaktypen/<uuid>",
|
||
"intake": "https://open-zaak.example/catalogi/api/v1/zaaktypen/<uuid>"
|
||
},
|
||
// WP-51 (Documenten): upload category → informatieobjecttype URL map.
|
||
"InformatieobjecttypeUrls": {
|
||
"identiteit": "https://open-zaak.example/catalogi/api/v1/informatieobjecttypen/<uuid>",
|
||
"diploma": "https://open-zaak.example/catalogi/api/v1/informatieobjecttypen/<uuid>"
|
||
},
|
||
// WP-52 (Notificaties): NRC base URL (documentation/provisioning only, no outbound call) +
|
||
// the shared secret NRC must send back on every webhook POST.
|
||
"NrcBaseUrl": "https://open-zaak.example/notificaties/api/v1",
|
||
"NotificatieAuthorization": "<same value registered in the abonnement's `auth` field>"
|
||
}
|
||
```
|
||
|
||
## Anti-corruption layer — two nested boundaries (what to learn)
|
||
|
||
This setup is an anti-corruption layer (ACL) **twice over**, and seeing them as a pair is the
|
||
lesson worth taking away:
|
||
|
||
1. **The BFF guards everything against upstream systems.** OpenZaak's foreign model —
|
||
URL-as-identity, a `zaaktype` that is a URL _into another service_, `{count,next,previous,
|
||
results}` pagination, HS256 JWT auth — never leaves the BFF. `ZgwZaakMapper` translates it
|
||
into the BFF's own `ApplicationSummaryDto`; `IZaakSource` makes the boundary swappable
|
||
(`LocalZaakSource` vs `OpenZaakZaakSource` return the _same_ DTO).
|
||
2. **The Angular app guards itself against the BFF.** `infrastructure/` is the only layer that
|
||
touches the network (lint-enforced); every response crosses a `parse*` (`Result`) trust
|
||
boundary + a `toDomain` mapper before any domain/UI code sees it (ADR-0001, ARCHITECTURE §6).
|
||
|
||
The DTO at `/api/v1` is the membrane between them — which is why wiring OpenZaak touched **zero
|
||
frontend code and produced zero api-client drift**. That was the proof the ACL held.
|
||
|
||
Principles this demonstrates:
|
||
|
||
- **An ACL is a _mapping_, not a passthrough.** A DTO that is the upstream shape renamed is
|
||
corruption with extra steps; the valuable ACLs here (`ZgwZaakMapper`, the `parse*`/`toDomain`
|
||
pairs) actively translate a foreign model into a local one.
|
||
- **Put the ACL where trust changes, and make it the _only_ place.** One choke point per
|
||
boundary — the `Zgw/` folder + `IZaakSource` server-side, `infrastructure/` client-side.
|
||
- **Decision DTOs and the ACL are complementary.** BFF-lite (server decides, FE renders) is an
|
||
ACL against _business-rule_ drift, layered on the ACL against _data-shape_ drift.
|
||
- **A real seam is swap-testable offline.** Because the ACL returns a stable DTO, the ZGW
|
||
client is unit-testable with fixtures + a stub handler — no live OpenZaak.
|
||
- **Mark the honest edges.** The `ponytail:` sync-over-async note and the "coarse status map"
|
||
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 (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 (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).
|