Opt-in docker-compose (postgres+redis+OpenZaak, no celery/nginx) + bootstrap-catalogus.sh seed a real OpenZaak instance; OpenZaakIntegrationTests (Category=Integration, excluded from default dotnet test/CI) proves the ZGW seam against it for the first time. That live run caught a real bug: ZgwHttpClient never sent Content-Crs/Accept-Crs headers, so every write would 412 against a spec-compliant OpenZaak — fixed alongside the harness. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
20 KiB
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; this page is how the seam is built and how to add the next slice. Built in WP-49 (read-only zaken), WP-50 (create-zaak), and WP-51 (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:ListCasesandCreateZaak. Both return the existing DTOs, so each implementation owns its own mapping.CreateZaakalso 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 SQLiteApplicationStore(offline, unchanged behaviour).CreateZaakis a pure passthrough of what the submit endpoint already computed locally — no external call.Zgw/OpenZaakZaakSource.cs— the OpenZaak client; selected only whenZgw:Enabled=true.CreateZaakposts a Zaak, then a Status, then a Rol (see below).Data/IDocumentSource.cs— the documents seam (WP-51), sibling ofIZaakSource:UploadandLinkToZaak.Data/LocalDocumentSource.csis the sameDocumentStore.Add/Linkcalls the upload/submit endpoints used to make inline;Zgw/OpenZaakDocumentSource.csalso registers each upload as a DRC document and links it to a zaak once one exists.- Wiring (
Program.cs):if (Zgw:Enabled)registersOpenZaakZaakSource+OpenZaakDocumentSource, elseLocalZaakSource+LocalDocumentSource. The/admin/casesGET, the/uploadsPOST, and the/applications/{id}/submitPOST 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:
- POST zaak (
{ZrcBaseUrl}/zaken) —zaaktyperesolved fromZgw:ZaaktypeUrls[aanvraag.Type](OpenZaak validates the URL by fetching it),bronorganisatie/verantwoordelijkeOrganisatie(RSIN) from config,identificatieset to the same referenceApplicationStore.Submitalready generated — so the human-readable reference matches in both places, not two independently-generated ones. - POST status (
{ZrcBaseUrl}/statussen) —statustyperesolved via a Catalogi GET (statustypen?zaaktype=..., lowestvolgnummer); marks the zaak as freshly opened. - POST rol (
{ZrcBaseUrl}/rollen) —roltyperesolved via a Catalogi GET (roltypen?zaaktype=...&omschrijvingGeneriek=initiator);betrokkeneIdentificatie.inpBsnset 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:
- Upload — POST
enkelvoudiginformatieobjecten({DrcBaseUrl}) with the file's base64 content,informatieobjecttyperesolved fromZgw:InformatieobjecttypeUrls[categoryId](the document analogue ofZaaktypeUrls),identificatieset to the local document id. The returned DRC url is persisted (DocumentStore.SetDrcUrl) so the link step below doesn't need to re-upload. - Link to zaak — once
IZaakSource.CreateZaakhas returned aZaakUrl(persisted viaApplicationStore.SetZaakUrl), submit callsdocuments.LinkToZaak(documentIds, zaakUrl), which POSTs azaakinformatieobjecten({ZrcBaseUrl}) per document that has aDrcUrl. Documents uploaded before a zaak existed (or under a config gap) have noDrcUrlyet 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 theZgwappsettings 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 pastiat, so per-call minting is the recommended pattern. Hand-rolled (noMicrosoft.IdentityModel.*dependency).ZgwHttpClient.cs— shared GET/POST-with-bearer-JWT plumbing used by bothOpenZaakZaakSourceandOpenZaakDocumentSource. Every request also carriesAccept-Crs/Content-Crs: EPSG:4326— every ZGW call must declare a coordinate reference system even when no geometry is involved, or a real OpenZaak 412s ("Content-Crs header ontbreekt"). This was missing until WP-54's live harness caught it — the stub-handler tests never modelled the header, so it had shipped silently since WP-49/50.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, attachesAuthorization: Bearer <jwt>.OpenZaakDocumentSource.cs— DRC upload + zaak-link (WP-51), same auth/JSON pattern.NotificatieDto.cs+ thePOST /api/v1/zgw/notificatiesendpoint (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:
{
"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
IIdentityProviderruns once per request (middleware inProgram.cs, right after the correlation-id middleware) intoHttpContext.Items, read back everywhere viactx.Caller().StubIdentityProvider(the only implementation today, not a security boundary) reads the existingX-Roleheader (unchanged — mirrors the FE's?role=toggle) plus a newX-Subjectheader for the BSN, defaulting to the single seeded citizen — so every request that doesn't sendX-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 readsctx.Caller().Roleinstead of the header directly, so its ~15 call sites acrossProgram.csneeded no changes.- Ownership: every endpoint that used to pass
DocumentStore.DemoOwnerto a store (ApplicationStore,DocumentStore,BriefStore) now passesctx.Caller().Bsn. - The ZGW JWT (
ZgwTokenProvider) grew aMint(CallerIdentity)overload alongside the original parameterlessMint(): citizen-scoped calls (create-zaak, upload, zaak-link, the citizen's own case list) mint with the caller's BSN/name asuser_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 fromZgwOptions.ZgwHttpClient.GetAsync/PostAsynctake an optionalCallerIdentity?that picks whichMintoverload runs. - Citizen-scoped reads:
IZaakSourcegainedListMyCases(CallerIdentity, now)alongside the existing admin-onlyListCases(now).LocalZaakSourcefiltersApplicationStore.List(bsn)(unchanged local behaviour);OpenZaakZaakSourceappends ZGW'srol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn=<bsn>query filter toGET {ZrcBaseUrl}/zaken.GET /applications(the citizen's own dashboard) now routes through this instead of callingApplicationStoredirectly — 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
- Read — extend
IZaakSource(or add a sibling interface, likeIDocumentSource, 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. - 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+rolfor a zaak;zaakinformatieobjectfor a document). Route it through the existing submit/mutation seam. - 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.
Run against real OpenZaak (WP-54)
Everything above was, until WP-54, only proven against fixtures + a stub HttpMessageHandler —
no live OpenZaak. backend/openzaak/ is a separate, opt-in docker-compose harness (never
merged into the root docker-compose.yml, which stays FE+BFF-only) that brings up a real
OpenZaak, seeds a minimal catalogus/zaaktype/zaak via a bootstrap script, and backs one
xunit test (OpenZaakIntegrationTests.cs, tagged Category=Integration) that points the BFF at
it with Zgw:Enabled=true. See backend/openzaak/README.md for the exact commands; the test is
excluded from the default dotnet test run and from CI (--filter Category!=Integration) since
it only passes with the harness up.
This is also where the Content-Crs/Accept-Crs header gap above was found: a real OpenZaak
enforces ZGW's geo-header requirement in a way no stub-based test could catch, since a stub
never rejects an unexpected (or missing) header. That is the harness's whole point — proving
the seam against real protocol behaviour, not just the shapes we already assumed.
Config
// 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:
- The BFF guards everything against upstream systems. OpenZaak's foreign model —
URL-as-identity, a
zaaktypethat is a URL into another service,{count,next,previous, results}pagination, HS256 JWT auth — never leaves the BFF.ZgwZaakMappertranslates it into the BFF's ownApplicationSummaryDto;IZaakSourcemakes the boundary swappable (LocalZaakSourcevsOpenZaakZaakSourcereturn the same DTO). - The Angular app guards itself against the BFF.
infrastructure/is the only layer that touches the network (lint-enforced); every response crosses aparse*(Result) trust boundary + atoDomainmapper 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, theparse*/toDomainpairs) 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 +IZaakSourceserver-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 inZgwZaakMappershow 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), and WP-54 added a docker OpenZaak harness + opt-in integration test proving the seam
against a live instance (and, in doing so, caught the missing Content-Crs/Accept-Crs
headers noted above). 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. That closes the phase-9 OpenZaak/ZGW arc (WP-49..54).
See also
- ADR-0005 — OpenZaak behind the BFF — the decision.
- ADR-0001 — BFF-lite + decision DTOs — why the FE doesn't change.
- WP-49 (this), WP-50/51 (CRUD arc so far), WP-52 (notificaties), WP-53 (identity seam + citizen-scoping), WP-54 (integration harness).
backend/src/BigRegister.Api/Zgw/— the client;Data/IZaakSource.cs/Data/IDocumentSource.cs— the seams;backend/openzaak/— the live-OpenZaak test harness (WP-54).- ZGW standard (VNG) · OpenZaak auth docs.