Files
atomic-design-poc/docs/reference/openzaak-integration.md
T
ehoandClaude Sonnet 5 29257048b5 fix(format): prettier drift in 2 docs (pre-existing, caught by format:check)
No content change — markdown list-marker/line-wrap normalization only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 09:24:53 +02:00

20 KiB
Raw Blame History

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: 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.csdefault; 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).

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 13 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. Every request also carries Accept-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, 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:

{
  "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.

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:

  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), 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