Extends IZaakSource with RecordBesluit, mirroring WP-50's CreateZaak write pattern: OpenZaakZaakSource POSTs a new Statussen entry (highest-volgnummer statustype, since the harness catalogus has no per-outcome besluittype), carrying the besluit + toelichting in statustoelichting; LocalZaakSource no-ops. The beoordeling endpoint calls it after the local decision commits, flagging a failure via RecordZgwDivergence the same way submit's create-zaak/document writes do — closing WP-60's "second write pair" gap. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
447 lines
30 KiB
Markdown
447 lines
30 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 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 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`. 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.
|
||
|
||
**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 | 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.
|
||
|
||
### 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).
|