Endpoint/DTO/options landed already in c4dd846; this closes the loop with NotificatieTests.cs (accept/reject/missing-header, asserting the AuthzAuditStore row), missing appsettings.json keys (also backfills DrcBaseUrl/ InformatieobjecttypeUrls, stale since WP-51), and the webhook + abonnement provisioning docs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
16 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 current stand-in for real identity (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.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" }],
}
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.
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 + 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).
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/54 (identity seam + integration harness).
backend/src/BigRegister.Api/Zgw/— the client;Data/IZaakSource.cs/Data/IDocumentSource.cs— the seams.- ZGW standard (VNG) · OpenZaak auth docs.