Files
ehoandClaude Opus 5 6cfd70eeeb fix(backend): resolve besluit endpoint's id via Referentie, not local PK
POST /beoordeling/{id}/besluit always 404'd against a real OpenZaak: {id} is the
FE-facing case id from IZaakSource.ListCases, which under OpenZaakZaakSource is the
ZGW zaak's own uuid, not ApplicationStore's primary key. Resolve the case through
ListCases first (same seam the GET sibling already uses), then to the local Aanvraag
via its Referentie — the one identifier stable across both sources.

Adds ApplicationStore.GetByReferentie and a regression test that reproduces the
divergence with a decorating IZaakSource test double instead of a live OpenZaak.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:20:36 +02:00

450 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 shortcut still standing: "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. The "no compensating transaction" gap this section used to flag here is closed
by WP-60 — see "Write resilience" below.
## Write resilience (WP-60)
The local write (`ApplicationStore.Submit`, `DocumentStore.Add`/`Link`) and its paired ZGW
write aren't transactional — this section covers what happens when the ZGW half fails after the
local half already committed, closing the one gap the sections above used to flag as needing
"retry/reconciliation or an outbox" before this integration could be called production-ready.
Deliberately **not** an outbox: three write paths, each triggered by exactly one interactive
request, don't justify a persisted queue (which would also need to carry the acting citizen's
BSN for the JWT's audit claims — PII in a new table) — see WP-60 for the full reasoning.
- **Bounded retry, in `ZgwHttpClient`.** Every ZGW call gets up to 3 attempts (200ms, doubling)
on transport-shaped failures — 429/502/503/504/408, connection errors, timeouts — with a
fresh request and JWT per attempt (a sent request/content can't be resent). **500 is
deliberately not retried**: it can follow a partial commit on the two non-idempotent POSTs
(`/statussen`, `/rollen`), so retrying risks a duplicate write. The create-zaak/document POSTs
are additionally safe to retry because OpenZaak enforces uniqueness on
(`bronorganisatie`, `identificatie`) — and WP-50/51 already set `identificatie` to the
locally-generated reference/document id, so a retry after a lost response 400s instead of
duplicating.
- **The local write is never rolled back.** Un-submitting a local aanvraag after a partial ZGW
failure (e.g. the zaak POST succeeded but `/statussen` didn't) would let the citizen resubmit
under a _new_ reference, orphaning the first zaak — worse than leaving it flagged.
- **A caught ZGW failure is flagged, not silent.** `Program.cs`'s submit endpoint wraps
`CreateZaak` and `LinkToZaak` in separate try/catches (separate so a create-zaak failure
doesn't also skip the still-local document link) and, on catch, logs the error, sets
`Aanvraag.ZgwError` (non-null = "the ZGW side of this submit didn't complete"), and records a
`zgw:divergence` audit row (same `AuthzAuditStore` trail every other decision uses, visible at
`/beheer/audit`) — see `RecordZgwDivergence`. The endpoint still returns 200 with the local
reference/status: that's truthful (the reference _is_ what would become the zaak's
`identificatie`) and never branches on `Zgw:Enabled` (an offline `LocalZaakSource` never
throws, so the catch is dead code there). The besluit endpoint (WP-66, see below) wraps
`IZaakSource.RecordBesluit` in the same try/catch → `RecordZgwDivergence` shape — the second
write pair this section's "Repair" bullet used to anticipate.
- **The document upload path flags differently.** `OpenZaakDocumentSource.Upload` catches its
own ZGW failure (config gap or transport) and logs it, but doesn't set a separate flag column
`DocumentStore.Get(id).DrcUrl == null` is already the meaningful "not registered in ZGW yet"
detector `LinkToZaak` skips on, so no second mechanism is needed for that half.
- **Repair.** No automated reconcile job exists yet — a flagged zaak is repairable on demand
because its (would-be) `identificatie` always equals the aanvraag's `Referentie`, so a future
admin action can `GET /zaken?identificatie=...` and either adopt the existing zaak or retry
`CreateZaak`/`RecordBesluit`. WP-66 landed as the second write pair without needing an outbox —
a real deployment is still the trigger to re-ask that question, not slice count on its own.
## 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.
`vertrouwelijkheidaanduiding` is driven by a per-document-type stamdata table (WP-59,
`Stamdata/documentconfidentialiteit.json`, ADR-0004), falling back to `"openbaar"` for any
category absent from it. Unlike the zaak side, an upload's ZGW failure (past
`DocumentStore.Add`) is caught and logged rather than persisted as a separate flag column —
see "Write resilience" below for why the two write paths differ.
## Besluit write (WP-66)
`POST /beoordeling/{id}/besluit` (the behandelportal's decision endpoint, WP-65b) routes its
ZGW side-effect through `IZaakSource.RecordBesluit` the same way submit routes through
`CreateZaak`: the local write (`ApplicationStore.RecordBesluit`) always happens first and stays
the record of truth, then `OpenZaakZaakSource` additionally POSTs a new `statussen` entry to the
aanvraag's zaak (`Aanvraag.ZaakUrl`, set by `CreateZaak`).
There is no Besluiten (BRC) call here — the harness's catalogus (WP-56) provisions only a
begin/eind `statustype` pair per zaaktype (`Ontvangen`/`Afgehandeld`), not one per decision
outcome, so a real Besluiten API integration would need its own `besluittype` provisioning
first (still "later" in the table above). Instead this reuses the exact statustype-resolution
pattern `CreateZaak` already has (`FirstStatustypeUrlAsync`), just picking the highest
`volgnummer` (`LastStatustypeUrlAsync`) instead of the lowest, and carries the besluit
(`Goedkeuren`/`Afwijzen`/`MeerInfoOpvragen`) plus the behandelaar's toelichting in the status's
free-text `statustoelichting` field so the outcome is still visible on the ZGW side.
`RecordBesluit` is a no-op if the aanvraag never got a zaak (`Zgw:Enabled` was off at submit
time, or the create diverged) — same "nothing to do" skip `LinkToZaak` uses for a null
`zaakUrl`. `LocalZaakSource.RecordBesluit` is a no-op outright — the local decision already IS
the record of truth there. `caller` is the acting `MedewerkerCaller` (WP-62), so the minted ZGW
JWT's audit claims reflect the behandelaar, not a static identity.
## 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; `Type` takes the internal aanvraag-type
key (`AanvraagTypeFor`, below) — a real bug (found via a live behandelportal walkthrough,
fixed post-WP-66) had this carrying OpenZaak's human zaaktype label instead, which the FE's
`AANVRAAG_TYPES` trust boundary always rejected.
- `OpenZaakZaakSource.cs` — follows `{count,next,previous,results}` pagination, maps each
zaak's zaaktype URL back to the internal key via `Zgw:ZaaktypeUrls` (`AanvraagTypeFor` — a
local lookup, no Catalogi round-trip), 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.
**A real deployment provisioning is out-of-band, one-time config against a live OpenZaak — not
app code.** OpenZaak does not serve the Notificaties API itself — it's a separate application
(`open-notificaties`, its own image/DB/celery stack). Register the `abonnement` once (e.g. via
Open Notificaties' 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" }],
}
```
`auth` is sent verbatim as the `Authorization` header on every callback (the NRC's
`auth_type=api_key` default) — no `Bearer` prefix, matching this endpoint's plain string
compare.
**The dev harness (WP-58) skips the NRC entirely** — see "Notifications-enabled profile"
below.
## 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.
**WP-62 split `CallerIdentity` into the two actor kinds ADR-0002 §3 requires** — a
`ZorgverlenerCaller` (citizen, the WP-53 shape above) or a `MedewerkerCaller` (backoffice
employee: `MedewerkerId` + `Rollen`, no BSN), backend-only, unused by any frontend until WP-64.
`StubIdentityProvider` selects the medewerker kind when `X-Medewerker` is present (its value is
the medewerkerId; `X-Rollen` is a comma-separated rollen list, defaulting to `Behandelaar`) —
takes precedence over `X-Subject`; absent, every request today, falls through to the
zorgverlener path unchanged. `CallerIdentity.SubjectId` (BSN or medewerkerId) is what
`ZgwTokenProvider.Mint` now reads instead of `.Bsn` directly, so the ZGW JWT's `user_id` is
correct for either kind with no further change (WP-66's besluit write mints this for free). The
ownership-scoping seams (`ctx.Zorgverlener()`, `IDocumentSource.Upload`, `IZaakSource
.ListMyCases`) are narrowed to `ZorgverlenerCaller` — a medewerker hitting a citizen-scoped SSP
endpoint is a 500 today (unreachable, since no consumer sends `X-Medewerker` yet; WP-64 upgrades
it to a 403 once real backoffice traffic exists). `Authz.CanBeoordelen(CallerIdentity)` is the
first medewerker capability (rol-based, `MedewerkerRol.Behandelaar`), shipped only as a decision
flag, never a rollen matrix.
## The five ZGW APIs (context for later slices)
| API | Component | Used by |
| ------------ | --------- | ---------------------------------------------------------------- |
| Zaken | ZRC | slice 1 (read), WP-50 (create) |
| Catalogi | ZTC | WP-50/66 (statustype/roltype/resultaattype for create + besluit) |
| 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.
### Notifications-enabled profile (WP-58)
The base harness above runs with `NOTIFICATIONS_DISABLED: 'true'` (no celery worker) — fine for
proving the read/write ZGW seam, but it means a write to a notified resource never actually
delivers anything. `docker-compose.openzaak.notificaties.yml` is an opt-in overlay that adds the
one celery worker OpenZaak needs to deliver a notification, and flips that flag off. The two
changes are inseparable: the moment `NOTIFICATIONS_DISABLED` is false, OpenZaak's
`NotificationsConfig` must have a client configured or every write to a notified resource 500s
and rolls back (`NOTIFICATIONS_GUARANTEE_DELIVERY` defaults true) — so `bootstrap-notificaties.sh`
configures that client in the same step.
A real Notificaties API (NRC) is a separate application this harness doesn't stand up (see the
"Notificaties webhook" section above) — reproducing it here (its own DB + celery + a real
`abonnement`/kanaal registration) would roughly triple the harness for a benefit this dev loop
doesn't need: there's only ever one subscriber (this repo's own BFF). Instead
`bootstrap-notificaties.sh` points OpenZaak's `NotificationsConfig` straight at the BFF's webhook
via a `zgw_consumers.Service` (`auth_type=api_key`, so the configured secret is sent verbatim as
the `Authorization` header — exactly what the endpoint's plain string-compare expects). Same
delivery proof (`write → OpenZaak's celery worker → a real HTTP POST → the BFF's audit trail`),
far less to stand up and keep alive. A real deployment with more than one subscriber, or that
needs kanaal-filtered fan-out, needs a real NRC + `abonnement` — this harness's shortcut doesn't
model that.
The overlay's `celery` worker joins the repo root's own `docker compose up` network (by name,
`api`) to reach the BFF — `host.docker.internal:host-gateway` was tried first, but this
environment's rootless Podman doesn't route container→host-port traffic through it (DNS
resolves, every TCP connect times out); container-to-container is the reliable path regardless
of Docker vs. Podman. That means the notifications profile needs the repo root's `docker compose
up` (or an equivalent `api` container on that network) running too, with
`Zgw__NotificatieAuthorization` set:
```bash
docker compose run --rm -d --name atomic-design-poc-api-1 --service-ports \
-e Zgw__NotificatieAuthorization='<a secret>' api # repo root
cd backend/openzaak
docker compose -f docker-compose.openzaak.yml -f docker-compose.openzaak.notificaties.yml up -d
./bootstrap-catalogus.sh
BFF_AUTH='<the same secret>' ./bootstrap-notificaties.sh
BFF_AUTH='<the same secret>' ./verify-notificatie.sh # proves a real delivery, end to end
```
Verified live in-session: the preflight in `bootstrap-notificaties.sh` proved the BFF's auth gate
both ways (204 with the secret, 401 without/wrong), `verify-notificatie.sh` found the delivered
`zgw:notificatie`/`allow` audit row for the PATCHed zaak, and re-running both scripts against the
already-configured client stayed idempotent (no errors, no duplicate `Service` rows).
## 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 — a SEPARATE host/app from OpenZaak itself
// (documentation/provisioning only, no outbound call) + the shared secret NRC must send
// back on every webhook POST.
"NrcBaseUrl": "https://open-notificaties.example/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
- [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](../project/backlog/WP-54-openzaak-integration-harness.md) (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)](https://vng-realisatie.github.io/gemma-zaken/) · [OpenZaak auth docs](https://open-zaak.readthedocs.io/en/stable/client-development/authentication.html).