Compare commits

...
5 Commits
Author SHA1 Message Date
ehoandClaude Sonnet 5 ba24784586 feat(openzaak): one-command UI-OpenZaak bridge, real BSN fix, flake mitigation
CI / changes (push) Successful in 8s
CI / lint (push) Successful in 55s
CI / frontend (push) Successful in 1m33s
CI / backend (push) Successful in 1m46s
CI / e2e (push) Successful in 3m14s
CI / storybook-a11y (push) Successful in 6m50s
CI / semgrep (push) Successful in 1m13s
CI / api-client-drift (push) Successful in 1m47s
scripts/openzaak-ui-up.sh brings up the root app and the OpenZaak harness
together, wires them onto one docker network, seeds the catalogus, grants
the container-alias zaaktype scope, and verifies a real aanvraag submitted
through the UI lands in OpenZaak.

Along the way: DocumentStore.DemoOwner was reusing the seeded doctor's
11-digit BIG-nummer as a stand-in BSN, which isn't a valid 9-digit BSN shape
— OpenZaak rejects it, breaking both submit's rol-creation step and the
citizen's own applications list under Zgw:Enabled=true. Fixed to a real
elfproef-valid BSN.

Also adds mitigation for a still-unexplained per-container flake (every
outbound ZGW POST fails as if the body were empty, for that container's
whole lifetime) that correlates with host memory pressure: the script now
warns when host swap is heavily used, and an opt-in ZgwDiagnosticHandler
(ZGW_DEBUG_HTTP=1) logs Content-Length vs. actual bytes sent so the next
reproduction can confirm or rule out client-side body corruption.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 08:23:01 +02:00
ehoandClaude Sonnet 5 3ff80c124f feat(openzaak): bounded retry + flagged write divergence (WP-60)
Local aanvraag/document writes and their paired ZGW writes aren't
transactional; a ZGW failure after the local write succeeds used to
diverge silently. ZgwHttpClient now retries transport-shaped failures
(not 500, which can follow a partial commit on the non-idempotent
statussen/rollen POSTs), and a ZGW failure that survives retry sets
Aanvraag.ZgwError plus a zgw:divergence audit row instead of failing
or diverging quietly. No outbox/reconcile job: three request-triggered
write paths don't justify a persisted queue that would also need to
carry citizen PII for the JWT audit claims.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 18:11:55 +02:00
eho 67abc58052 feat(openzaak): per-document-type confidentialiteit config (WP-59)
Drives the DRC upload's vertrouwelijkheidaanduiding from a new stamdata
table instead of the hardcoded "openbaar", following the existing
config-as-code pattern (ADR-0004). Adds the referential-integrity check
StamdataValidationTests was missing for the new table.
2026-07-30 17:30:25 +02:00
ehoandClaude Sonnet 5 3e983bd2cc feat(openzaak): real notification delivery to the BFF webhook (WP-58)
OpenZaak doesn't serve the Notificaties API itself (it's a separate app,
open-notificaties) — standing one up for a real abonnement would triple
this harness for a benefit it doesn't need (exactly one subscriber, this
repo's own BFF). Instead, an opt-in compose overlay adds a celery worker
and points OpenZaak's NotificationsConfig straight at the BFF's webhook
via a zgw_consumers Service; bootstrap-notificaties.sh configures it
idempotently and verify-notificatie.sh proves a real write delivers to
the BFF's audit trail end-to-end.

Verified live: preflight proves the webhook's shared-secret gate both
ways (204/401), a zaak PATCH triggers real celery delivery, and rerunning
both scripts against an already-configured harness stays idempotent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 15:33:16 +02:00
ehoandClaude Sonnet 5 1e87997ea0 feat(openzaak): least-privilege client scopes (WP-57)
setup_configuration has no YAML field for granular autorisaties, so
bigregister-test now starts at heeft_alle_autorisaties: false (dev + prod
template) and bootstrap-catalogus.sh grants exactly the ztc/zrc scopes the
harness needs via the Django ORM, sidestepping the zero-scope
chicken-and-egg with the JWT-authenticated Autorisaties REST API.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 14:05:54 +02:00
37 changed files with 1762 additions and 127 deletions
+92 -7
View File
@@ -55,6 +55,67 @@ and is **excluded** from the default `dotnet test` run and from CI (`ci.yml`,
`scripts/ci-local.sh` both filter `Category!=Integration`) — it only passes with this harness
up, so it never runs where the harness doesn't exist.
## Notifications-enabled profile (WP-58)
The base harness above never delivers a real notification (`NOTIFICATIONS_DISABLED: 'true'`,
no celery worker) — fine for the read/write ZGW seam, not for proving a live webhook round-trip.
An opt-in overlay adds the one celery worker needed, flips that flag, and points OpenZaak
straight at this repo's own BFF webhook (no real Notificaties API/NRC in this harness — see
[docs/reference/openzaak-integration.md](../../docs/reference/openzaak-integration.md)'s
"Notifications-enabled profile" section for why and how). Needs the repo root's own
`docker compose up` (or an equivalent `api` container) running too, since the celery worker
reaches the BFF by container name on that network:
```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
```
To go back to the fast, no-notifications default: `docker compose -f docker-compose.openzaak.yml
up -d --remove-orphans` (drops the celery worker, restores `NOTIFICATIONS_DISABLED: 'true'`).
## Testing the Angular UI against this harness
The base harness above and the app's own root `docker-compose.yml` are independent projects on
purpose (see the top of this file) — this is the opt-in bridge between them, for when you want
to click through the real UI and see an aanvraag land in a real OpenZaak instead of just
running `dotnet test`. One command from the repo root:
```bash
scripts/openzaak-ui-up.sh
```
It brings up the root app (so its docker network exists), brings up this harness plus
`docker-compose.openzaak.bff.yml` (gives this harness's `web` service a dotted alias,
`openzaak.local`, on its own network — the root project's `api` container joins THIS network,
in the opposite direction from the notifications overlay below, to avoid a real alias
collision: the root project's frontend service is also called `web`. The alias needs a dot
because Django's URLValidator rejects a bare hostname in a URL field; this environment's
rootless Podman also can't route container→host-port traffic through `host.docker.internal`,
so container-to-container is the only reliable path either way — see that file's header
comment for the full, empirically-confirmed reasoning), seeds the catalogus, additively
replaces the zrc authorization grant to match the alias (ZGW authorization is scoped by the
*exact* zaaktype URL string, not just the resource; see `scripts/openzaak-ui-up.sh`'s own
comment for why this is a replace, not an add), and brings the root app back up pointed at
OpenZaak (`docker-compose.openzaak.yml` at the repo root). A final self-check submits a
throwaway aanvraag and confirms it actually lands in OpenZaak, restarting `api` (up to 5
times) if not — see that script for a caveat about an intermittent per-container networking
flake this environment can hit under memory pressure (the script now warns if host swap is
already high going in; `ZGW_DEBUG_HTTP=1` on `api`, see `docker-compose.openzaak.yml`, logs
diagnostics to help nail the cause next time it reproduces). Prints the URLs to check
afterward and the teardown commands.
Two caveats, both non-fatal (WP-60 catches and flags rather than surfacing an error):
**only `herregistratie` has a seeded zaaktype** here, so submit that wizard to prove a real
write; and **no Documenten content is seeded**, so a document upload's ZGW half no-ops (pick
"per post" in the wizard's document step, or ignore it).
## Tear down
```bash
@@ -100,23 +161,47 @@ app change.
- `docker-compose.openzaak.yml` — postgres (postgis), redis, a one-shot `web-init` (runs
Django migrations then `setup_configuration` against `setup_configuration/data.yaml`), and
`web` (the OpenZaak API on `:8000`). Pinned to `openzaak/open-zaak:1.29.1`. No
celery/celery-beat/celery-flower/nginx — trimmed for a lean, fast-booting harness; add them
back only if a later WP needs a real async notification delivery round-trip here (WP-52's
webhook is already covered by fixture tests against no live instance).
celery/celery-beat/celery-flower/nginx — trimmed for a lean, fast-booting harness; layer
`docker-compose.openzaak.notificaties.yml` (WP-58) on top for a real async notification
delivery round-trip.
`NOTIFICATIONS_DISABLED=true` is required, not optional: without it, OpenZaak 500s (and
**rolls back the whole create**) on any notified resource — see the compose file's comment.
- `setup_configuration/data.yaml` — the declarative, scripted alternative to clicking through
the Django admin (upstream's own documented `setup_configuration` CLI mechanism): creates the
one `bigregister-test` client (`heeft_alle_autorisaties: true` — this instance never exists
for anything but this harness, so there's no least-privilege boundary worth modeling).
one `bigregister-test` client with `heeft_alle_autorisaties: false` — this YAML mechanism
(`vng_api_common`'s `ApplicatieConfigurationModel`) has no field for granular scopes at all,
so the client starts with zero Autorisaties; `bootstrap-catalogus.sh` grants the exact ones
it needs (WP-57).
- `bootstrap-catalogus.sh` — the business content (catalogus/zaaktype/zaak/…) `setup_configuration`
has no YAML for; every field value here was checked against OpenZaak's own OpenAPI spec and a
live run of this exact script, not guessed (two OpenZaak quirks it works around: a zaaktype
needs ≥1 resultaattype and 2 statustypen before it can be published, and its
`selectielijstklasse` and the zaaktype's `selectielijstProcestype` must reference the same
`procesType` on the public VNG selectielijst API). Idempotent (WP-56) — see "Bring it up" above.
- **Not here**: Documenten (DRC) / Notificaties (NRC) content — add if a later WP needs to prove
those round-trips against a live instance too (WP-51/52 are fixture-tested today).
Also grants `bigregister-test`'s Autorisaties via `manage.py shell` (WP-57, see the script's
top comment): `ztc` scopes (`catalogi.lezen`/`catalogi.schrijven`, this script's own
content-creation needs) up front, `zrc` scopes (`zaken.aanmaken`/`zaken.bijwerken`/
`zaken.lezen`, scoped to the one zaaktype the BFF and this script both use) once that
zaaktype exists. No `documenten`/DRC grant — `Zgw:InformatieobjecttypeUrls` is empty in this
harness's `appsettings.json`, so `OpenZaakDocumentSource` isn't reachable here yet; add the
grant (scoped to a real `informatieobjecttype`, which this script would also need to seed)
when a later WP wires DRC content into this harness.
- **Not here**: Documenten (DRC) content, or a real Notificaties API (NRC) — add DRC content if a
later WP needs to prove that round-trip against a live instance too (WP-51 is fixture-tested
today). A real NRC is a separate application (`open-notificaties`) this harness deliberately
doesn't stand up — WP-58's notifications-enabled profile (below) proves live delivery without
one, since this harness only ever has one subscriber.
- `docker-compose.openzaak.notificaties.yml` (WP-58) — opt-in overlay: one celery worker for
OpenZaak (async notification delivery needs it) + `NOTIFICATIONS_DISABLED: 'false'`, joined to
the repo root's own compose network so it can reach the `api` container by name (tried
`host.docker.internal:host-gateway` first; this environment's rootless Podman doesn't route
container→host-port traffic through it). See "Notifications-enabled profile" below.
- `bootstrap-notificaties.sh` (WP-58) — points OpenZaak's `NotificationsConfig` at the BFF's
webhook via a `zgw_consumers.Service` (`update_or_create`, idempotent) instead of provisioning
a real NRC `abonnement`; preflights that the BFF is reachable with the right secret first
(a misconfigured target here means every write to a notified resource 500s and rolls back).
- `verify-notificatie.sh` (WP-58) — the runnable end-to-end check: PATCHes the seeded zaak, polls
the BFF's own `/admin/audit` (WP-41) for the resulting `zgw:notificatie`/`allow` row.
- `docker-compose.openzaak.prod.yml` (WP-55) — production overrides layered on top of
`docker-compose.openzaak.yml`: real `SECRET_KEY`/DB password/site domain/allowed-hosts from
required env vars (fails fast if unset), password DB auth instead of `trust`, `IS_HTTPS: 'yes'`.
+39
View File
@@ -16,6 +16,16 @@
# repeatedly against a long-lived instance, not just once per fresh volume. Prints the seeded
# zaak's `identificatie` + `url` on success; also writes them to seeded.env (repo-ignored) for
# OpenZaakIntegrationTests.cs to assert against.
#
# WP-57: `bigregister-test` starts with ZERO Autorisaties (data.yaml sets
# heeft_alle_autorisaties: false) — the setup_configuration YAML has no field for granular
# scopes at all (confirmed from vng_api_common's own ApplicatieConfigurationModel), so this
# script grants them itself via `manage.py shell` (Django ORM, inside the `web` container) at
# the two points they become grantable: ztc scopes up front (no zaaktype dependency), zrc
# scopes once `zaaktype_url` exists below. Going through the ORM instead of the
# JWT-authenticated Autorisaties REST API sidesteps a real chicken-and-egg: a client with zero
# scopes cannot grant itself any scope over that API. Re-running this script re-grants the same
# scopes (idempotent, like everything else here).
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
@@ -61,6 +71,27 @@ oz() {
echo "$json"
}
# Grant (replace) an Autorisatie for $CLIENT_ID directly via the ORM (see the WP-57 note up
# top for why this bypasses the REST Autorisaties API). $1 = component, $2 = python list
# literal of scopes, $3.. = extra `Autorisatie(...)` kwargs as `name=value` (value already a
# valid Python literal, e.g. a quoted URL).
grant_scopes() {
local component="$1" scopes="$2"
shift 2
local extra="" kv
for kv in "$@"; do extra+=" $kv,"$'\n'; done
docker compose -f docker-compose.openzaak.yml exec -T --workdir /app/src web python manage.py shell <<PY
from vng_api_common.authorizations.models import Applicatie
app = Applicatie.objects.get(client_ids__contains=["$CLIENT_ID"])
app.autorisaties.filter(component="$component").delete()
app.autorisaties.create(
component="$component",
scopes=$scopes,
$extra)
PY
}
# $1 = list path+query (server-side-filtered to the natural key). Prints the first result's
# `url`, or nothing if the list is empty — the GET-before-POST idempotency check.
existing_url() {
@@ -86,6 +117,9 @@ until curl -sS -o /dev/null -w '%{http_code}' "$BASE/catalogi/api/v1/catalogusse
sleep 2
done
echo "Granting ztc scopes (catalogi.lezen, catalogi.schrijven — this script's own content-creation needs; the BFF only ever reads Catalogi)..."
grant_scopes ztc '["catalogi.lezen", "catalogi.schrijven"]'
echo "Catalogus..."
catalogus_url=$(existing_url "/catalogi/api/v1/catalogussen?domein=BIGR&rsin=$RSIN")
if [ -n "$catalogus_url" ]; then
@@ -134,6 +168,11 @@ print(json.dumps({
echo " created: $zaaktype_url"
fi
echo "Granting zrc scopes (zaken.aanmaken, zaken.bijwerken, zaken.lezen), scoped to $zaaktype_url — the one zaaktype this harness (and the BFF's Zgw:ZaaktypeUrls config) ever uses..."
grant_scopes zrc '["zaken.aanmaken", "zaken.bijwerken", "zaken.lezen"]' \
"zaaktype=\"$zaaktype_url\"" \
'max_vertrouwelijkheidaanduiding="openbaar"'
echo "Statustypen (publish needs a begin AND an end status)..."
statustype_url=$(existing_statustype_url "$zaaktype_url" 1)
if [ -n "$statustype_url" ]; then
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# WP-58 — points OpenZaak's own NotificationsConfig straight at this repo's BFF webhook
# (POST /api/v1/zgw/notificaties, WP-52) instead of standing up a real Notificaties API (NRC)
# + abonnement — see docker-compose.openzaak.notificaties.yml's ponytail note for why. Requires
# that overlay running (adds the celery worker + flips NOTIFICATIONS_DISABLED) AND the repo
# root's own `docker compose up` running (the overlay joins its `api` container's network —
# tried host.docker.internal first, but this harness's celery worker couldn't reach a
# host-bound port through it; see the overlay's comment) with
# Zgw__NotificatieAuthorization=$BFF_AUTH set on that `api` service.
#
# Idempotent: `update_or_create` on the Service's fixed slug, same shape as bootstrap-catalogus.sh.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
COMPOSE_FILES=(-f docker-compose.openzaak.yml -f docker-compose.openzaak.notificaties.yml)
# Container-to-container (the celery worker reaching the root project's `api` container by
# name, see the overlay file) — this script itself runs on the HOST though, so its own
# preflight check below hits the BFF at $BFF_LOCAL_ROOT (localhost, the published port) instead.
BFF_API_ROOT="${BFF_API_ROOT:-http://api:5000/api/v1/zgw/}"
BFF_LOCAL_ROOT="${BFF_LOCAL_ROOT:-http://localhost:5000/api/v1/zgw/}"
BFF_AUTH="${BFF_AUTH:-wp-58-local-harness-not-for-prod}"
echo "Preflight: is the BFF reachable at $BFF_LOCAL_ROOT with the shared secret configured?"
status=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: $BFF_AUTH" -H 'Content-Type: application/json' \
-d '{"kanaal":"preflight","hoofdObject":"http://example.com/preflight","resource":"status","resourceUrl":"http://example.com/preflight","actie":"create","aanmaakdatum":"2026-01-01T00:00:00Z","kenmerken":{}}' \
"${BFF_LOCAL_ROOT}notificaties")
if [ "$status" != "204" ]; then
echo "FAILED: expected 204 from the BFF's webhook, got $status. From the repo root:" >&2
echo " docker compose run --rm -d --name atomic-design-poc-api-1 --service-ports \\" >&2
echo " -e Zgw__NotificatieAuthorization='$BFF_AUTH' api" >&2
exit 1
fi
echo " ok (204)"
docker compose "${COMPOSE_FILES[@]}" exec -T --workdir /app/src web python manage.py shell <<PY
from notifications_api_common.models import NotificationsConfig
from zgw_consumers.constants import APITypes, AuthTypes
from zgw_consumers.models import Service
service, _ = Service.objects.update_or_create(
slug="bff-webhook",
defaults=dict(
label="BIG-register BFF webhook (WP-58)",
api_type=APITypes.orc,
api_root="$BFF_API_ROOT",
auth_type=AuthTypes.api_key,
header_key="Authorization",
header_value="$BFF_AUTH",
),
)
config = NotificationsConfig.get_solo()
config.notifications_api_service = service
config.save()
print(f"NotificationsConfig.notifications_api_service -> {service.api_root}")
PY
echo
echo "Notifications configured. Run ./verify-notificatie.sh to prove a live delivery."
@@ -0,0 +1,38 @@
# Opt-in overlay, layered ON TOP of docker-compose.openzaak.yml (never alone):
#
# docker compose -f docker-compose.openzaak.yml -f docker-compose.openzaak.bff.yml up -d
#
# Gives this harness's `web` (the OpenZaak API) an extra, dotted hostname alias
# (`openzaak.local`) on its OWN network, so the root project's `api` container (joined in via
# `docker-compose.openzaak.yml` at the repo root, as an EXTERNAL network) can reach it. See
# `scripts/openzaak-ui-up.sh` for the one-command version that brings both projects up
# together, seeds the catalogus, and grants the extra authorization scope this alias needs.
#
# Three real things this works around, each discovered empirically (curl against the
# running containers), not guessed:
#
# 1. Why container-to-container instead of `http://localhost:8000`: this dev environment's
# rootless Podman drops container→host-port traffic through `host.docker.internal`
# (confirmed for the WP-58 notifications overlay's celery worker — DNS resolves it, every
# TCP connect times out).
#
# 2. Why the ROOT project's `api` joins INTO this project's network (below), not the other way
# around: the root project's frontend service is also called `web`. Docker Compose always
# adds a service's own name as a network alias on every network it joins — so if THIS `web`
# joined the root project's network, "web" would resolve to two different containers there.
# Only `api` crosses into this network, under its own already-unique name.
#
# 3. Why the alias has a dot in it (`openzaak.local`, not e.g. `openzaak`): Django's built-in
# URLValidator rejects a bare, dotless hostname in a URL field (it special-cases exactly
# "localhost"; anything else needs a dot or to be a valid IP). OpenZaak's `zaaktype` field
# (and others) run through this validator — confirmed with a POST referencing
# `http://<dotless-alias>:8000/...` failing "Voer een geldige URL in" (enter a valid URL)
# before any authorization check even runs.
services:
web:
environment:
# Django rejects any request whose Host header isn't in ALLOWED_HOSTS.
ALLOWED_HOSTS: localhost,127.0.0.1,web,openzaak.local
networks:
default:
aliases: [openzaak.local]
@@ -0,0 +1,64 @@
# WP-58 — notifications-enabled overlay, layered ON TOP of docker-compose.openzaak.yml
# (never alone):
#
# docker compose -f docker-compose.openzaak.yml -f docker-compose.openzaak.notificaties.yml up -d
#
# The base file stays the WP-54 fast-iteration default (NOTIFICATIONS_DISABLED=true, no
# worker) so nobody testing the read/write seam has to pull/boot this. This overlay flips
# NOTIFICATIONS_DISABLED off and adds the one celery worker needed to actually deliver a
# notification (see base file's ponytail note).
#
# ponytail: a real ZGW deployment fans notifications out through a separate Notificaties API
# (NRC — its own app/image/DB; OpenZaak does not serve one) to N abonnement'd subscribers via
# kanaal-filtered routing. This harness only ever has ONE subscriber (this repo's own BFF), so
# bootstrap-notificaties.sh points OpenZaak's NotificationsConfig straight at the BFF's webhook
# instead — same delivery proof (a real write → a real HTTP POST → the BFF's audit trail), far
# less harness to stand up and keep alive. Add a real NRC (+ abonnement/kanaal routing) if a
# later WP needs more than one subscriber or real kanaal-filtered fan-out.
#
# No celery-beat here: send_notification is a plain async task (client.post on save), not a
# scheduled one — beat only matters on a real NRC's polling side, which this harness doesn't have.
services:
web-init:
environment:
NOTIFICATIONS_DISABLED: 'false'
web:
environment:
NOTIFICATIONS_DISABLED: 'false'
celery:
image: openzaak/open-zaak:1.29.1
command: /celery_worker.sh
environment:
DJANGO_SETTINGS_MODULE: openzaak.conf.docker
SECRET_KEY: wp-54-local-harness-not-for-prod
DB_HOST: db
DB_NAME: openzaak
DB_USER: openzaak
IS_HTTPS: 'no'
SITE_DOMAIN: localhost:8000
ALLOWED_HOSTS: localhost,127.0.0.1,web
CACHE_DEFAULT: redis:6379/0
CACHE_AXES: redis:6379/0
DISABLE_2FA: 'true'
CELERY_BROKER_URL: redis://redis:6379/0
CELERY_RESULT_BACKEND: redis://redis:6379/0
NOTIFICATIONS_DISABLED: 'false'
# On the default network (below) for db/redis; also joined to the repo root's
# `docker compose up` network so it can reach the BFF's `api` container by name — tried
# `host.docker.internal:host-gateway` first, but rootless Podman here drops traffic from
# the container bridge to a host-bound port (confirmed: DNS resolves host.docker.internal,
# every TCP connect attempt times out), so container-to-container is the reliable path.
networks:
default: {}
bff: {}
depends_on:
web-init:
condition: service_completed_successfully
redis:
condition: service_healthy
networks:
bff:
name: atomic-design-poc_default
external: true
@@ -4,8 +4,12 @@
# into it to produce the gitignored data.prod.yaml that docker-compose.openzaak.prod.yml
# mounts over the container's data.yaml.
#
# Least-privilege client scopes (heeft_alle_autorisaties: true below) are WP-57's job, not
# this WP's — left matching the dev harness on purpose.
# Least-privilege client scopes (WP-57): heeft_alle_autorisaties is false, matching the dev
# harness (setup_configuration has no YAML field for granular `autorisaties` — see
# data.yaml's comment). This template only covers infra config; a real deploy must grant this
# client's Autorisaties the same way bootstrap-catalogus.sh does for the dev harness — via
# `manage.py shell` (or the Autorisaties REST API from an already-privileged caller) against
# the production catalogus/zaaktype URLs, once, as part of standing up that environment.
sites_config_enable: true
sites_config:
items:
@@ -25,4 +29,4 @@ vng_api_common_applicaties:
client_ids:
- ${OPENZAAK_CLIENT_ID}
label: BIG-register BFF (production)
heeft_alle_autorisaties: true
heeft_alle_autorisaties: false
@@ -2,9 +2,12 @@
# documented CLI config mechanism — see docker-compose.openzaak.yml) instead of the Django
# admin. Creates the ONE application the bootstrap script + integration test authenticate as.
#
# ponytail: heeft_alle_autorisaties (all scopes) rather than a granular per-component/scope
# list — this instance only ever exists for this harness/test, never a shared or prod
# OpenZaak, so there's no least-privilege boundary worth modeling here.
# heeft_alle_autorisaties is false (WP-57, least privilege) — but
# `ApplicatieConfigurationModel` (vng_api_common's setup_configuration step) has no field for
# granular `autorisaties` at all, only this boolean. So this client starts with ZERO scopes;
# bootstrap-catalogus.sh grants the exact ones it needs via `manage.py shell` (Django ORM,
# not the JWT-authenticated Autorisaties REST API — a zero-scope client can't grant itself
# anything over REST, so this sidesteps that bootstrap chicken-and-egg entirely).
sites_config_enable: true
sites_config:
items:
@@ -24,4 +27,4 @@ vng_api_common_applicaties:
client_ids:
- bigregister-test
label: BIG-register BFF (WP-54 test harness)
heeft_alle_autorisaties: true
heeft_alle_autorisaties: false
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# WP-58 — proves the "real write -> real webhook delivery" round-trip end-to-end: PATCHes the
# zaak bootstrap-catalogus.sh seeded (a notified ZRC resource), then polls the BFF's own audit
# trail (WP-41) for the resulting `zgw:notificatie` row. Requires bootstrap-catalogus.sh and
# bootstrap-notificaties.sh to have already run.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
[ -f seeded.env ] || { echo "seeded.env missing — run ./bootstrap-catalogus.sh first" >&2; exit 1; }
# Not `source`d: seeded.env's ZAAKTYPE_LABEL value contains an unquoted space (fine for the
# line-oriented C# reader it's written for, not valid as sourceable shell).
ZAAK_URL=$(grep '^ZAAK_URL=' seeded.env | cut -d= -f2-)
BFF_BASE="${BFF_BASE:-http://localhost:5000}"
CLIENT_ID="bigregister-test"
SECRET="bigregister-test-secret"
b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
jwt() {
local header='{"alg":"HS256","typ":"JWT"}'
local payload
payload=$(printf '{"iss":"%s","iat":%d,"client_id":"%s","user_id":"%s","user_representation":"%s"}' \
"$CLIENT_ID" "$(date +%s)" "$CLIENT_ID" "$CLIENT_ID" "verify")
local h p signing_input sig
h=$(printf '%s' "$header" | b64url)
p=$(printf '%s' "$payload" | b64url)
signing_input="$h.$p"
sig=$(printf '%s' "$signing_input" | openssl dgst -sha256 -hmac "$SECRET" -binary | b64url)
printf '%s.%s' "$signing_input" "$sig"
}
echo "Triggering a real write: PATCH $ZAAK_URL (bijwerken — WP-57 granted zaken.aanmaken"
echo "for exactly ONE status, so a second status create 403s; a zaak update is the write this"
echo "client's narrowed scope can repeat)..."
response=$(curl -sS -X PATCH -H "Authorization: Bearer $(jwt)" -H 'Content-Type: application/json' \
-H 'Content-Crs: EPSG:4326' -H 'Accept-Crs: EPSG:4326' \
-d "$(printf '{"toelichting":"wp-58 verify %s"}' "$(date -u +%s)")" \
-w $'\n%{http_code}' "$ZAAK_URL")
http_code="${response##*$'\n'}"
if [[ ! "$http_code" =~ ^2 ]]; then
echo "FAILED: zaak PATCH -> $http_code: ${response%$'\n'*}" >&2
exit 1
fi
echo " updated"
echo "Waiting for the BFF's audit trail to show the delivered notification..."
for _ in $(seq 1 30); do
if curl -sS -H 'X-Role: admin' "$BFF_BASE/api/v1/admin/audit" \
| python3 -c "
import json, sys
rows = json.load(sys.stdin)
found = any(r['action'] == 'zgw:notificatie' and r['resource'] == '$ZAAK_URL' and r['decision'] == 'allow' for r in rows)
sys.exit(0 if found else 1)
"; then
echo " delivered: found a zgw:notificatie/allow row for $ZAAK_URL"
exit 0
fi
sleep 2
done
echo "FAILED: no delivered notification for $ZAAK_URL after 60s. Diagnostics:" >&2
docker compose -f docker-compose.openzaak.yml -f docker-compose.openzaak.notificaties.yml logs --tail=50 celery >&2
exit 1
@@ -33,6 +33,13 @@ public sealed class Aanvraag
/// endpoint does, via <see cref="ApplicationStore.SetZaakUrl"/>) to keep the seam's write
/// surface at "return data", not "reach into another store".</summary>
public string? ZaakUrl { get; set; }
/// <summary>WP-60: non-null means the ZGW side of this submit (or its document link) did not
/// complete — the local aanvraag is authoritative and is NOT rolled back (that risks an
/// orphan zaak if the failure landed after the zaak POST succeeded). The zaak, if it exists,
/// is re-findable by <c>identificatie == Referentie</c>. Cleared by a future repair path;
/// none exists yet (see openzaak-integration.md's "Write resilience" section).</summary>
public string? ZgwError { get; set; }
}
/// <summary>
@@ -193,4 +200,18 @@ public static class ApplicationStore
db.SaveChanges();
}
}
/// <summary>Flag (or clear, once a repair path exists) that this aanvraag's ZGW write did
/// not complete — see <see cref="Aanvraag.ZgwError"/>. No-op if the aanvraag is gone.</summary>
public static void SetZgwError(string id, string? error)
{
lock (_gate)
{
using var db = Db.Create();
var a = db.Applications.Find(id);
if (a is null) return;
a.ZgwError = error;
db.SaveChanges();
}
}
}
@@ -36,8 +36,16 @@ public sealed record AuditEntry(DateTimeOffset At, string Action, string Documen
/// </summary>
public static class DocumentStore
{
/// The single seeded user (the demo has no real auth; ownership = this id).
public const string DemoOwner = "19012345601";
/// The single seeded user (the demo has no real auth; ownership = this id) — a real,
/// elfproef-valid 9-digit BSN (src/app/shared/kernel/bsn.ts's own checksum), distinct from
/// SeedData.Registration.BigNummer ("19012345601", 11 digits — the seeded doctor's BIG-nummer,
/// a different Dutch identifier scheme). Previously this constant reused that BigNummer value
/// as a stand-in BSN, which is invalid Dutch-BSN shape: harmless against the local store, but
/// a real OpenZaak instance rejects it outright — GET /api/v1/applications 500s (`inpBsn` query
/// filter validation) and every submit's rol-creation POST fails (`inpBsn` max_length) once
/// Zgw:Enabled=true. Not "111222333" or "999888777" — both already mean a different fixture
/// identity (the OpenZaak-harness/unit-test caller, and ApplicationTests' "other citizen").
public const string DemoOwner = "123456782";
private static readonly object _gate = new();
@@ -0,0 +1,282 @@
// <auto-generated />
using System;
using BigRegister.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace BigRegister.Api.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260730155659_ZgwSyncError")]
partial class ZgwSyncError
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
modelBuilder.Entity("BigRegister.Api.Data.Aanvraag", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<bool>("AutoApprovable")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DocumentIds")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Draft")
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Reden")
.HasColumnType("TEXT");
b.Property<string>("Referentie")
.HasColumnType("TEXT");
b.Property<int>("StepCount")
.HasColumnType("INTEGER");
b.Property<int>("StepIndex")
.HasColumnType("INTEGER");
b.Property<bool>("Submitted")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("SubmittedAt")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("ZaakUrl")
.HasColumnType("TEXT");
b.Property<string>("ZgwError")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Applications");
});
modelBuilder.Entity("BigRegister.Api.Data.AuditEntry", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Actor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("At")
.HasColumnType("TEXT");
b.Property<string>("CategoryId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DocumentId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AuditEntries");
});
modelBuilder.Entity("BigRegister.Api.Data.AuthzAuditEntry", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("At")
.HasColumnType("TEXT");
b.Property<string>("CorrelationId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Decision")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Resource")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AuthzAudit");
});
modelBuilder.Entity("BigRegister.Api.Data.BriefEntity", b =>
{
b.Property<string>("BriefId")
.HasColumnType("TEXT");
b.Property<string>("ArchivedHtml")
.HasColumnType("TEXT");
b.Property<string>("Beroep")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DrafterId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Placeholders")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Sections")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("SentOrgTemplateVersion")
.HasColumnType("INTEGER");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SubOrgId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TemplateId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("BriefId");
b.HasIndex("Owner")
.IsUnique();
b.ToTable("Briefs");
});
modelBuilder.Entity("BigRegister.Api.Data.FeatureFlagEntity", b =>
{
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER");
b.HasKey("Key");
b.ToTable("FeatureFlags");
});
modelBuilder.Entity("BigRegister.Api.Data.OrgTemplateEntity", b =>
{
b.Property<string>("SubOrgId")
.HasColumnType("TEXT");
b.Property<string>("Draft")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("History")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("PublishedVersion")
.HasColumnType("INTEGER");
b.HasKey("SubOrgId");
b.ToTable("OrgTemplates");
});
modelBuilder.Entity("BigRegister.Api.Data.StoredDocument", b =>
{
b.Property<string>("DocumentId")
.HasColumnType("TEXT");
b.Property<string>("CategoryId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<byte[]>("Content")
.IsRequired()
.HasColumnType("BLOB");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DrcUrl")
.HasColumnType("TEXT");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("Linked")
.HasColumnType("INTEGER");
b.Property<string>("LocalId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("UploadedAt")
.HasColumnType("TEXT");
b.Property<string>("WizardId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("DocumentId");
b.ToTable("Documents");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BigRegister.Api.Data.Migrations
{
/// <inheritdoc />
public partial class ZgwSyncError : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ZgwError",
table: "Applications",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ZgwError",
table: "Applications");
}
}
}
@@ -67,6 +67,9 @@ namespace BigRegister.Api.Data.Migrations
b.Property<string>("ZaakUrl")
.HasColumnType("TEXT");
b.Property<string>("ZgwError")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Applications");
+54 -5
View File
@@ -58,9 +58,21 @@ if (zgw.Enabled)
{
builder.Services.AddSingleton(zgw);
builder.Services.AddSingleton<ZgwTokenProvider>();
builder.Services.AddHttpClient<IZaakSource, OpenZaakZaakSource>();
// WP-60: a bounded client timeout matters once ZgwHttpClient retries — without one, the
// sources' sync-over-async call (no CancellationToken threaded through) could block a
// thread-pool thread for HttpClient's 100s default times 3 attempts.
var zaakClientBuilder = builder.Services.AddHttpClient<IZaakSource, OpenZaakZaakSource>(c => c.Timeout = TimeSpan.FromSeconds(15));
// WP-51: the documents (Documenten API / DRC) seam — same pattern as IZaakSource above.
builder.Services.AddHttpClient<IDocumentSource, OpenZaakDocumentSource>();
var documentClientBuilder = builder.Services.AddHttpClient<IDocumentSource, OpenZaakDocumentSource>(c => c.Timeout = TimeSpan.FromSeconds(15));
// Opt-in diagnostic for the still-unexplained per-container flake (see
// scripts/openzaak-ui-up.sh's header comment) — off by default, zero cost unless set.
if (Environment.GetEnvironmentVariable("ZGW_DEBUG_HTTP") == "1")
{
builder.Services.AddTransient<ZgwDiagnosticHandler>();
zaakClientBuilder.AddHttpMessageHandler<ZgwDiagnosticHandler>();
documentClientBuilder.AddHttpMessageHandler<ZgwDiagnosticHandler>();
}
}
else
{
@@ -351,13 +363,38 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
// in OpenZaak and maps its result back into this same response shape (ADR-0001/ADR-0005:
// zero FE contract change either way). WP-53: the caller is threaded through so the minted
// ZGW JWT's user_id/user_representation reflect the acting citizen, not a static config value.
var (referentie, status, zaakUrl) = zaken.CreateZaak(submitted, DateTimeOffset.UtcNow, ctx.Caller());
if (zaakUrl is not null) ApplicationStore.SetZaakUrl(id, zaakUrl);
//
// WP-60: the local submit above already committed — it is never rolled back on a ZGW
// failure (an orphan zaak from a rolled-back-then-retried submit is worse than a flagged
// one, see openzaak-integration.md's "Write resilience" section). Each ZGW half is caught
// separately so a create-zaak failure doesn't also skip the (still-local) document link.
var referentie = submitted.Referentie!;
var status = submitted.ToStatusDto(DateTimeOffset.UtcNow);
string? zaakUrl = null;
try
{
(referentie, status, zaakUrl) = zaken.CreateZaak(submitted, DateTimeOffset.UtcNow, ctx.Caller());
if (zaakUrl is not null) ApplicationStore.SetZaakUrl(id, zaakUrl);
}
catch (Exception ex)
{
RecordZgwDivergence(ctx, id, referentie, ex);
}
// WP-51: link the submitted documents to the zaak — LocalDocumentSource is exactly the
// DocumentStore.Link call this used to make inline; OpenZaakDocumentSource additionally
// POSTs a zaakinformatieobject per document, now that the zaak (zaakUrl) exists.
if (documentIds is not null) documents.LinkToZaak(documentIds, zaakUrl, ctx.Caller());
if (documentIds is not null)
{
try
{
documents.LinkToZaak(documentIds, zaakUrl, ctx.Caller());
}
catch (Exception ex)
{
RecordZgwDivergence(ctx, id, referentie, ex);
}
}
return Results.Ok(new SubmitApplicationResponse(referentie, status));
})
@@ -676,6 +713,18 @@ void AuditAuthz(HttpContext ctx, string action, string resource, bool allowed, P
AuthzAuditStore.Record(action, resource, allowed, principal.Role.ToString(), cid);
}
// WP-60: the local write already committed — this records that its ZGW counterpart didn't,
// rather than letting the two sides diverge silently (openzaak-integration.md's "Write
// resilience" section). Same audit trail AuditAuthz writes to (/beheer/audit), so a
// divergence is visible next to every other decision, not a separate mechanism.
void RecordZgwDivergence(HttpContext ctx, string id, string referentie, Exception ex)
{
app.Logger.LogError(ex, "zgw divergence aanvraag={Id} reference={Reference}", id, referentie);
ApplicationStore.SetZgwError(id, ex.Message);
var cid = ctx.Items.TryGetValue("CorrelationId", out var v) ? (string)v! : "none";
AuthzAuditStore.Record("zgw:divergence", referentie, allowed: false, Authz.ResolvePrincipal(ctx).Role.ToString(), cid);
}
// Keep the last `keep` characters, mask the rest — mirrors the FE maskTail
// (src/app/shared/ui/debug-state/mask.ts) so wire redaction and the dev panel agree.
static string MaskTail(string value, int keep) =>
@@ -0,0 +1,12 @@
namespace BigRegister.Stamdata;
/// <summary>
/// One row of the document-confidentialiteit stamdata (config-as-code, ADR-0004): the ZGW
/// <c>vertrouwelijkheidaanduiding</c> to register a DRC document with, per upload category
/// (<see cref="BigRegister.Domain.Documents.DocumentCategory.CategoryId"/>). The first
/// property (<see cref="CategoryId"/>) is the table key by convention (see
/// <c>StamdataTable</c>). Non-temporal — a category's sensitivity doesn't change over time.
/// A category absent from this table falls back to <c>"openbaar"</c> (see
/// <c>OpenZaakDocumentSource</c>) rather than failing the upload.
/// </summary>
public sealed record DocumentConfidentialiteit(string CategoryId, string Vertrouwelijkheidaanduiding);
@@ -14,6 +14,7 @@ public static class StamdataCatalog
StamdataTable.Of<Beroep>("beroepen", "Beroepen (BIG)"),
StamdataTable.Of<Opleiding>("opleidingen", "Opleidingen → beroep"),
StamdataTable.Of<Specialisme>("specialismen", "Specialismen → beroep"),
StamdataTable.Of<DocumentConfidentialiteit>("documentconfidentialiteit", "Documenttype → vertrouwelijkheidaanduiding"),
// PolicyQuestions and future tables migrate here, same one-liner each.
};
@@ -0,0 +1,7 @@
[
{ "categoryId": "identiteit", "vertrouwelijkheidaanduiding": "vertrouwelijk" },
{ "categoryId": "diploma", "vertrouwelijkheidaanduiding": "openbaar" },
{ "categoryId": "taalvaardigheid", "vertrouwelijkheidaanduiding": "openbaar" },
{ "categoryId": "werkervaring", "vertrouwelijkheidaanduiding": "openbaar" },
{ "categoryId": "nascholing", "vertrouwelijkheidaanduiding": "openbaar" }
]
@@ -3,6 +3,8 @@ using System.Text.Json.Serialization;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using BigRegister.Domain.Authorization;
using BigRegister.Stamdata;
using Microsoft.Extensions.Logging;
namespace BigRegister.Api.Zgw;
@@ -19,10 +21,21 @@ namespace BigRegister.Api.Zgw;
/// <see cref="OpenZaakZaakSource"/> — creating a document needs write scope on Documenten;
/// linking one to a zaak needs write scope on Zaken (the zaakinformatieobject resource).
/// </summary>
public sealed class OpenZaakDocumentSource(HttpClient http, ZgwTokenProvider tokens, ZgwOptions options) : IDocumentSource
public sealed class OpenZaakDocumentSource(
HttpClient http, ZgwTokenProvider tokens, ZgwOptions options, ILogger<OpenZaakDocumentSource>? log = null)
: IDocumentSource
{
private readonly ZgwHttpClient zgw = new(http, tokens);
// WP-59: per-document-type confidentiality (stamdata, ADR-0004) — "openbaar" if the
// category isn't in the table, so an unconfigured category never fails the upload.
private static readonly IReadOnlyDictionary<string, string> ConfidentialiteitByCategory =
StamdataFile.Load<DocumentConfidentialiteit>("documentconfidentialiteit")
.ToDictionary(r => r.CategoryId, r => r.Vertrouwelijkheidaanduiding);
private static string ConfidentialiteitFor(string categoryId) =>
ConfidentialiteitByCategory.GetValueOrDefault(categoryId, "openbaar");
// ponytail: sync-over-async — IDocumentSource is sync to match the local store + the
// existing sync upload/submit endpoints, same reasoning as OpenZaakZaakSource.
public UploadResponse Upload(
@@ -31,40 +44,54 @@ public sealed class OpenZaakDocumentSource(HttpClient http, ZgwTokenProvider tok
UploadAsync(localId, categoryId, wizardId, fileName, contentType, content, caller)
.GetAwaiter().GetResult();
// WP-60: once DocumentStore.Add (below) has committed, the local document is the record of
// truth (per the class doc above) — a ZGW failure past that point is caught, logged, and
// leaves DrcUrl null rather than throwing. DrcUrl == null is already the meaningful "not
// registered in ZGW yet" detector LinkToZaak skips on, so no separate flag column is needed
// here the way ApplicationStore.ZgwError is for the zaak side (see openzaak-integration.md's
// "Write resilience" section for why the two write paths differ).
private async Task<UploadResponse> UploadAsync(
string localId, string categoryId, string wizardId, string fileName, string contentType,
byte[] content, CallerIdentity caller)
{
var doc = DocumentStore.Add(localId, categoryId, wizardId, fileName, contentType, content, caller.Bsn);
if (!options.InformatieobjecttypeUrls.TryGetValue(categoryId, out var informatieobjecttypeUrl))
throw new InvalidOperationException(
$"Zgw:InformatieobjecttypeUrls has no entry for category '{categoryId}'.");
try
{
if (!options.InformatieobjecttypeUrls.TryGetValue(categoryId, out var informatieobjecttypeUrl))
throw new InvalidOperationException(
$"Zgw:InformatieobjecttypeUrls has no entry for category '{categoryId}'.");
var eio = await zgw.PostAsync<Eio>($"{options.DrcBaseUrl}/enkelvoudiginformatieobjecten", new CreateEioRequest(
Bronorganisatie: options.Bronorganisatie,
Creatiedatum: DateOnly.FromDateTime(doc.UploadedAt.UtcDateTime),
Titel: fileName,
Auteur: options.UserRepresentation,
Taal: "nld",
Formaat: contentType,
Bestandsnaam: fileName,
Inhoud: Convert.ToBase64String(content),
Informatieobjecttype: informatieobjecttypeUrl,
Identificatie: doc.DocumentId,
// ponytail: hardcoded "openbaar" (public) — real usage would likely vary the
// confidentiality level per category (e.g. an identity document is more sensitive
// than a diploma); a fixed value is enough to prove the seam end-to-end.
Vertrouwelijkheidaanduiding: "openbaar"), caller);
var eio = await zgw.PostAsync<Eio>($"{options.DrcBaseUrl}/enkelvoudiginformatieobjecten", new CreateEioRequest(
Bronorganisatie: options.Bronorganisatie,
Creatiedatum: DateOnly.FromDateTime(doc.UploadedAt.UtcDateTime),
Titel: fileName,
Auteur: options.UserRepresentation,
Taal: "nld",
Formaat: contentType,
Bestandsnaam: fileName,
Inhoud: Convert.ToBase64String(content),
Informatieobjecttype: informatieobjecttypeUrl,
Identificatie: doc.DocumentId,
Vertrouwelijkheidaanduiding: ConfidentialiteitFor(categoryId)), caller);
DocumentStore.SetDrcUrl(doc.DocumentId, eio.Url);
}
catch (Exception ex)
{
log?.LogError(ex, "zgw divergence document={DocumentId} category={CategoryId}", doc.DocumentId, categoryId);
}
DocumentStore.SetDrcUrl(doc.DocumentId, eio.Url);
return new UploadResponse(doc.DocumentId, doc.LocalId);
}
/// <summary>Local link always happens (dual-write, same reasoning as upload); additionally,
/// once a zaak exists, POST a zaakinformatieobject for every document that has a DRC url —
/// documents uploaded before Zgw:Enabled was ever true (or under a config gap) simply have
/// no DrcUrl yet and are skipped, matching "nothing extra to link" for the local case.</summary>
/// no DrcUrl yet and are skipped, matching "nothing extra to link" for the local case.
/// WP-60: unlike Upload, a ZGW failure here still throws — DocumentStore.Link (the local
/// half) already ran above, so the caller (Program.cs's submit endpoint) catching this and
/// recording it as a flagged divergence is what closes the gap, not a try/catch in here.</summary>
public void LinkToZaak(IReadOnlyList<string> documentIds, string? zaakUrl, CallerIdentity caller)
{
DocumentStore.Link(documentIds);
@@ -86,10 +86,12 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
/// reason as <see cref="ListCases"/> (see the ponytail note there) — a submit is already a
/// single request/response round trip, so no extra concurrency concern.
///
/// ponytail: no compensating transaction — if any ZGW call here throws, the aanvraag is
/// already marked Submitted locally (ApplicationStore.Submit already ran) but has no zaak.
/// Acceptable for a first write slice against a demo backend; a production arc would need a
/// retry/reconciliation story (or an outbox) before this dual-write can be trusted.
/// WP-60: still no compensating transaction — if any call here throws (after
/// <see cref="ZgwHttpClient"/>'s retry gives up), the aanvraag stays Submitted locally with
/// no zaak; rolling it back risks an orphan zaak if the failure landed after the zaak POST
/// succeeded. The caller (Program.cs's submit endpoint) catches this and records it as a
/// flagged divergence (Aanvraag.ZgwError) instead of letting it fail (or diverge) silently —
/// see openzaak-integration.md's "Write resilience" section.
public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller) =>
CreateZaakAsync(aanvraag, now, caller).GetAwaiter().GetResult();
@@ -0,0 +1,25 @@
namespace BigRegister.Api.Zgw;
/// <summary>
/// Opt-in only (wired in <c>Program.cs</c> behind <c>ZGW_DEBUG_HTTP=1</c>) — chases the
/// still-unexplained flake where a freshly-(re)started `api` container has every outbound ZGW
/// POST fail with what looks like an empty body reaching OpenZaak (see
/// <c>scripts/openzaak-ui-up.sh</c>'s header comment). Logs the one signal that would actually
/// distinguish "client built an empty body" from "something ate it after send": the declared
/// Content-Length vs. the byte count actually read from the request right before it goes out.
/// A mismatch here would prove client-side corruption; agreement would point downstream instead.
/// </summary>
public sealed class ZgwDiagnosticHandler(ILogger<ZgwDiagnosticHandler> logger) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.Content is not null)
{
var bytes = await request.Content.ReadAsByteArrayAsync(cancellationToken);
logger.LogInformation(
"ZGW {Method} {Url}: Content-Length={ContentLength} actualBytes={Actual}",
request.Method, request.RequestUri, request.Content.Headers.ContentLength, bytes.Length);
}
return await base.SendAsync(request, cancellationToken);
}
}
@@ -1,3 +1,4 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using BigRegister.Domain.Authorization;
@@ -14,26 +15,77 @@ namespace BigRegister.Api.Zgw;
/// </summary>
internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens)
{
// WP-60: bounded retry for transport-shaped failures only (gateway restarts, timeouts) —
// never a substitute for reconciliation. 3 attempts, doubling from 200ms.
private const int MaxAttempts = 3;
private static readonly TimeSpan BaseDelay = TimeSpan.FromMilliseconds(200);
public async Task<T> GetAsync<T>(string url, CallerIdentity? caller = null)
{
using var req = new HttpRequestMessage(HttpMethod.Get, url);
Authorize(req, caller);
using var res = await http.SendAsync(req);
res.EnsureSuccessStatusCode();
using var res = await SendWithRetryAsync(() => new HttpRequestMessage(HttpMethod.Get, url), caller);
return (await res.Content.ReadFromJsonAsync<T>())
?? throw new InvalidOperationException($"ZGW GET {url} returned null body.");
}
public async Task<T> PostAsync<T>(string url, object body, CallerIdentity? caller = null)
{
using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(body) };
Authorize(req, caller);
using var res = await http.SendAsync(req);
res.EnsureSuccessStatusCode();
using var res = await SendWithRetryAsync(
() => new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(body) }, caller);
return (await res.Content.ReadFromJsonAsync<T>())
?? throw new InvalidOperationException($"ZGW POST {url} returned null body.");
}
/// <summary>
/// A fresh <see cref="HttpRequestMessage"/> (and JWT) per attempt — a sent request/content
/// cannot be resent. Only transport-shaped failures are retried (429/502/503/504/408, plus
/// connection errors and timeouts); 500 is deliberately excluded because it can follow a
/// partial commit on the two non-idempotent ZGW POSTs (<c>/statussen</c>, <c>/rollen</c>) and
/// retrying risks a duplicate write — the create-zaak/document POSTs are additionally
/// protected by OpenZaak's own uniqueness constraint on (bronorganisatie, identificatie).
/// A non-transient (or exhausted) failure throws with the status + a body snippet, which
/// <c>Program.cs</c>'s submit endpoint catches and records as a flagged divergence rather
/// than letting it diverge silently (see openzaak-integration.md's "Write resilience" section).
/// </summary>
private async Task<HttpResponseMessage> SendWithRetryAsync(Func<HttpRequestMessage> newRequest, CallerIdentity? caller)
{
for (var attempt = 1; ; attempt++)
{
using var req = newRequest();
Authorize(req, caller);
HttpResponseMessage res;
try
{
res = await http.SendAsync(req);
}
catch (Exception ex) when (attempt < MaxAttempts && ex is HttpRequestException or TaskCanceledException)
{
await Task.Delay(BaseDelay * (1 << (attempt - 1)));
continue;
}
if (res.IsSuccessStatusCode) return res;
if (attempt < MaxAttempts && IsTransient(res.StatusCode))
{
res.Dispose();
await Task.Delay(BaseDelay * (1 << (attempt - 1)));
continue;
}
var body = await res.Content.ReadAsStringAsync();
var snippet = body.Length > 500 ? body[..500] : body;
var message = $"ZGW {req.Method} {req.RequestUri} failed: {(int)res.StatusCode} {snippet}";
var status = res.StatusCode;
res.Dispose();
throw new HttpRequestException(message, null, status);
}
}
private static bool IsTransient(HttpStatusCode status) => status is
HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests or
HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout;
private void Authorize(HttpRequestMessage req, CallerIdentity? caller)
{
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", caller is null ? tokens.Mint() : tokens.Mint(caller));
@@ -1,3 +1,4 @@
using System.Net;
using BigRegister.Api.Data;
using BigRegister.Api.Zgw;
using BigRegister.Domain.Authorization;
@@ -58,17 +59,57 @@ public class OpenZaakDocumentSourceTests
Assert.Contains("123443210", body); // bronorganisatie
Assert.Contains("paspoort.pdf", body);
Assert.Contains(Convert.ToBase64String("%PDF-1.4 fake"u8.ToArray()), body); // inhoud
// WP-59: "identiteit" is mapped to "vertrouwelijk" in the confidentialiteit stamdata.
Assert.Contains("\"vertrouwelijkheidaanduiding\":\"vertrouwelijk\"", body);
}
[Fact]
public void Upload_throws_when_the_category_has_no_configured_informatieobjecttype()
public void Upload_falls_back_to_openbaar_for_a_category_absent_from_the_confidentialiteit_table()
{
var options = Options();
options.InformatieobjecttypeUrls["org-logo"] = InformatieobjecttypeUrl;
var handler = new ZgwStubHandler(url =>
"""{ "url": "https://oz.example/documenten/api/v1/enkelvoudiginformatieobjecten/eio-2" }""");
var source = new OpenZaakDocumentSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
source.Upload("local-2", "org-logo", "org-template", "logo.png", "image/png", [1, 2, 3], Caller);
var body = handler.BodyOf($"{DrcBase}/enkelvoudiginformatieobjecten");
Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", body);
}
// WP-60: once DocumentStore.Add has committed, a ZGW-side failure (config gap or transport)
// no longer throws — the local document is authoritative and DrcUrl stays null (the same
// detector LinkToZaak already skips on for pre-Zgw documents).
[Fact]
public void Upload_keeps_the_local_document_when_the_category_has_no_configured_informatieobjecttype()
{
var options = Options();
var handler = new ZgwStubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}"));
var source = new OpenZaakDocumentSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
Assert.Throws<InvalidOperationException>(() =>
source.Upload("local-1", "unknown-category", "registratie", "f.pdf", "application/pdf", [1, 2, 3], Caller));
var response = source.Upload("local-1", "unknown-category", "registratie", "f.pdf", "application/pdf", [1, 2, 3], Caller);
Assert.Equal("local-1", response.LocalId);
Assert.Empty(handler.Requests);
Assert.Null(DocumentStore.Get(response.DocumentId)!.DrcUrl);
}
[Fact]
public void Upload_keeps_the_local_document_and_does_not_throw_when_drc_rejects_it()
{
var options = Options();
var handler = new ZgwStubHandler(
url => throw new InvalidOperationException($"unexpected success body requested for {url}"),
(_, _) => HttpStatusCode.BadRequest);
var source = new OpenZaakDocumentSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
var response = source.Upload("local-1", "identiteit", "registratie", "paspoort.pdf", "application/pdf",
"%PDF-1.4 fake"u8.ToArray(), Caller);
Assert.Equal("local-1", response.LocalId);
Assert.Null(DocumentStore.Get(response.DocumentId)!.DrcUrl);
}
[Fact]
@@ -1,3 +1,4 @@
using System.Net;
using BigRegister.Api.Data;
using BigRegister.Api.Zgw;
using BigRegister.Domain.Authorization;
@@ -159,4 +160,95 @@ public class OpenZaakZaakSourceTests
Assert.Throws<InvalidOperationException>(() => source.CreateZaak(aanvraag, DateTimeOffset.UtcNow, caller));
}
// --- WP-60: bounded retry in ZgwHttpClient, exercised through the create-zaak write path ---
private static (ZgwOptions options, Aanvraag aanvraag, CallerIdentity caller) CreateZaakFixture()
{
const string zaaktypeUrl = $"{ZtBase}/zaaktypen/zt-registratie";
var options = new ZgwOptions
{
ZrcBaseUrl = ZrcBase,
ZtcBaseUrl = ZtBase,
ClientId = "c",
Secret = "s",
Bronorganisatie = "123443210",
VerantwoordelijkeOrganisatie = "123443210",
ZaaktypeUrls = new() { ["registratie"] = zaaktypeUrl },
};
var aanvraag = new Aanvraag { Id = "a1", Type = "registratie", Owner = "111222333", Referentie = "BIG-2026-000123" };
var caller = new CallerIdentity(aanvraag.Owner, "Dr. Test", PrincipalRole.Drafter);
return (options, aanvraag, caller);
}
private static string RespondFor(string zaaktypeUrl, string url) => url switch
{
_ when url == $"{ZrcBase}/zaken" => $$"""
{ "url": "{{ZrcBase}}/zaken/uuid-new", "identificatie": "BIG-2026-000123",
"zaaktype": "{{zaaktypeUrl}}", "startdatum": "2026-07-28",
"einddatum": null, "registratiedatum": "2026-07-28" }
""",
_ when url.StartsWith($"{ZtBase}/statustypen") => """
{ "count": 1, "next": null,
"results": [ { "url": "https://oz.example/catalogi/api/v1/statustypen/st-1", "volgnummer": 1 } ] }
""",
_ when url.StartsWith($"{ZtBase}/roltypen") => """
{ "count": 1, "next": null,
"results": [ { "url": "https://oz.example/catalogi/api/v1/roltypen/rt-initiator" } ] }
""",
_ when url == $"{ZrcBase}/statussen" => "{}",
_ when url == $"{ZrcBase}/rollen" => "{}",
_ => throw new InvalidOperationException($"unexpected ZGW call {url}"),
};
[Fact]
public void CreateZaak_retries_a_transient_failure_and_then_succeeds()
{
var (options, aanvraag, caller) = CreateZaakFixture();
var zaaktypeUrl = options.ZaaktypeUrls["registratie"];
var handler = new ZgwStubHandler(
url => RespondFor(zaaktypeUrl, url),
(url, attempt) => url == $"{ZrcBase}/zaken" && attempt == 0 ? HttpStatusCode.ServiceUnavailable : HttpStatusCode.OK);
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
var (referentie, _, zaakUrl) = source.CreateZaak(aanvraag, new DateTimeOffset(2026, 7, 28, 12, 0, 0, TimeSpan.Zero), caller);
Assert.Equal("BIG-2026-000123", referentie);
Assert.Equal($"{ZrcBase}/zaken/uuid-new", zaakUrl);
Assert.Equal(2, handler.Requests.Count(r => r == $"{ZrcBase}/zaken"));
}
[Fact]
public void CreateZaak_gives_up_after_three_attempts_on_a_persistent_transient_failure()
{
var (options, aanvraag, caller) = CreateZaakFixture();
var zaaktypeUrl = options.ZaaktypeUrls["registratie"];
var handler = new ZgwStubHandler(
url => RespondFor(zaaktypeUrl, url),
(url, _) => url == $"{ZrcBase}/zaken" ? HttpStatusCode.ServiceUnavailable : HttpStatusCode.OK);
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
var ex = Assert.Throws<HttpRequestException>(() => source.CreateZaak(aanvraag, DateTimeOffset.UtcNow, caller));
Assert.Contains("503", ex.Message);
Assert.Equal(3, handler.Requests.Count(r => r == $"{ZrcBase}/zaken"));
}
[Fact]
public void CreateZaak_does_not_retry_a_permanent_rejection()
{
var (options, aanvraag, caller) = CreateZaakFixture();
var zaaktypeUrl = options.ZaaktypeUrls["registratie"];
var handler = new ZgwStubHandler(
url => RespondFor(zaaktypeUrl, url),
(url, _) => url == $"{ZrcBase}/statussen" ? HttpStatusCode.BadRequest : HttpStatusCode.OK);
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
Assert.Throws<HttpRequestException>(() => source.CreateZaak(aanvraag, DateTimeOffset.UtcNow, caller));
// No retry on 400, and — the property that makes the whole design safe — no duplicate
// zaak was created by a retry that never should have happened.
Assert.Equal(1, handler.Requests.Count(r => r == $"{ZrcBase}/statussen"));
Assert.Equal(1, handler.Requests.Count(r => r == $"{ZrcBase}/zaken"));
}
}
@@ -1,5 +1,6 @@
using BigRegister.Api.Data;
using BigRegister.Domain.Diplomas;
using BigRegister.Domain.Documents;
using BigRegister.Stamdata;
namespace BigRegister.Tests;
@@ -22,6 +23,14 @@ public class StamdataValidationTests
private static readonly IReadOnlySet<string> BeroepCodes =
StamdataFile.Load<Beroep>("beroepen").Select(b => b.Code).ToHashSet(StringComparer.Ordinal);
// Every document category id that exists across any wizard (WP-59's confidentialiteit
// table points at these) — "org-logo" resolves too, even though it's deliberately absent
// from the confidentialiteit table itself (falls back to "openbaar").
private static readonly IReadOnlySet<string> DocumentCategoryIds = new[] { "registratie", "herregistratie", "org-template" }
.SelectMany(DocumentRules.AllCategoriesFor)
.Select(c => c.CategoryId)
.ToHashSet(StringComparer.Ordinal);
private static readonly IReadOnlyList<StamdataRef> References = new[]
{
new StamdataRef(
@@ -38,6 +47,12 @@ public class StamdataValidationTests
"Specialisme.beroep → beroepen.code",
StamdataFile.Load<Specialisme>("specialismen").Select(s => s.Beroep),
key => BeroepCodes.Contains(key)),
// WP-59: a confidentialiteit row for a category that no wizard ever asks for is dead
// config — fail the build rather than let it silently rot.
new StamdataRef(
"DocumentConfidentialiteit.CategoryId → a real document category",
StamdataFile.Load<DocumentConfidentialiteit>("documentconfidentialiteit").Select(d => d.CategoryId),
key => DocumentCategoryIds.Contains(key)),
};
[Fact]
@@ -0,0 +1,28 @@
using System.Net.Http.Json;
using BigRegister.Api.Zgw;
using Microsoft.Extensions.Logging.Abstractions;
namespace BigRegister.Tests;
/// <summary>
/// The opt-in diagnostic handler (see ZgwDiagnosticHandler's own doc comment) must be inert on
/// the request/response — it only observes. This doesn't catch the flake itself (that needs a
/// real repro with ZGW_DEBUG_HTTP=1), just proves the hook doesn't alter what's sent or break
/// the response passthrough.
/// </summary>
public class ZgwDiagnosticHandlerTests
{
[Fact]
public async Task Passes_request_and_response_through_unchanged()
{
var stub = new ZgwStubHandler(_ => """{ "ok": true }""");
var diagnostic = new ZgwDiagnosticHandler(NullLogger<ZgwDiagnosticHandler>.Instance) { InnerHandler = stub };
using var client = new HttpClient(diagnostic);
var response = await client.PostAsJsonAsync("https://oz.example/zaken", new { foo = "bar" });
Assert.True(response.IsSuccessStatusCode);
Assert.Single(stub.Requests);
Assert.Contains("\"foo\":\"bar\"", stub.BodyOf("https://oz.example/zaken"));
}
}
@@ -0,0 +1,120 @@
using System.Net;
using System.Net.Http.Json;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
namespace BigRegister.Tests;
/// <summary>
/// WP-60's required verification: a ZGW failure mid-submit must not leave the two write sides
/// silently diverged — it's flagged (<see cref="Aanvraag.ZgwError"/>, an audit row) instead.
/// Not an <see cref="IClassFixture{TFixture}"/> off <see cref="TestWebApplicationFactory"/>: that
/// fixture hardcodes <c>Zgw:Enabled=false</c>, so this builds its own factory the same way
/// <see cref="OpenZaakIntegrationTests"/> does, but with a stub primary handler
/// (<see cref="ZgwStubHandler"/>) instead of a live OpenZaak.
/// </summary>
public class ZgwDivergenceTests
{
private const string ZrcBase = "https://oz.example/zaken/api/v1";
private const string ZtBase = "https://oz.example/catalogi/api/v1";
private const string ZaaktypeUrl = $"{ZtBase}/zaaktypen/zt-1";
private static WebApplicationFactory<Program> Factory(ZgwStubHandler stub)
{
var dbPath = Path.Combine(Path.GetTempPath(), $"bigregister-zgw-divergence-{Guid.NewGuid():N}.db");
return new WebApplicationFactory<Program>().WithWebHostBuilder(builder => builder
.UseSetting("ConnectionStrings:AppDb", $"Data Source={dbPath}")
.UseSetting("Zgw:Enabled", "true")
.UseSetting("Zgw:ZrcBaseUrl", ZrcBase)
.UseSetting("Zgw:ZtcBaseUrl", ZtBase)
.UseSetting("Zgw:ClientId", "c")
.UseSetting("Zgw:Secret", "s")
.UseSetting("Zgw:Bronorganisatie", "123443210")
.UseSetting("Zgw:VerantwoordelijkeOrganisatie", "123443210")
.UseSetting("Zgw:ZaaktypeUrls:registratie", ZaaktypeUrl)
.ConfigureServices(services => services.ConfigureHttpClientDefaults(b =>
b.ConfigurePrimaryHttpMessageHandler(() => stub))));
}
/// <summary>Doesn't call GET /applications first (unlike ApplicationTests.Create) — under
/// Zgw:Enabled=true that route goes through IZaakSource too, which this test's stub doesn't
/// need to answer since every test here uses a fresh db and creates exactly one aanvraag.</summary>
private static async Task<string> CreateConcept(HttpClient client, string type = "registratie")
{
var res = await client.PostAsJsonAsync("/api/v1/applications", new { type });
res.EnsureSuccessStatusCode();
var body = (await res.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
return body.Id;
}
private static string SuccessBody(string url) => url switch
{
_ when url == $"{ZrcBase}/zaken" => $$"""
{ "url": "{{ZrcBase}}/zaken/uuid-new", "identificatie": "BIG-2026-000123",
"zaaktype": "{{ZaaktypeUrl}}", "startdatum": "2026-07-30",
"einddatum": null, "registratiedatum": "2026-07-30" }
""",
_ when url.StartsWith($"{ZtBase}/statustypen") => """
{ "count": 1, "next": null,
"results": [ { "url": "https://oz.example/catalogi/api/v1/statustypen/st-1", "volgnummer": 1 } ] }
""",
_ when url.StartsWith($"{ZtBase}/roltypen") => """
{ "count": 1, "next": null,
"results": [ { "url": "https://oz.example/catalogi/api/v1/roltypen/rt-initiator" } ] }
""",
_ when url == $"{ZrcBase}/statussen" => "{}",
_ when url == $"{ZrcBase}/rollen" => "{}",
_ => throw new InvalidOperationException($"unexpected ZGW call {url}"),
};
[Fact]
public async Task Submit_with_a_failing_zgw_flags_the_divergence_instead_of_diverging_silently()
{
var stub = new ZgwStubHandler(SuccessBody, (url, _) => url == $"{ZrcBase}/zaken" ? HttpStatusCode.ServiceUnavailable : HttpStatusCode.OK);
using var factory = Factory(stub);
using var client = factory.CreateClient();
var id = await CreateConcept(client);
var res = await client.PostAsJsonAsync($"/api/v1/applications/{id}/submit", new { diplomaHerkomst = "duo" });
// The local write is still authoritative: 200 with a real reference, not a 500.
res.EnsureSuccessStatusCode();
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
Assert.NotEmpty(body.Referentie);
var stored = ApplicationStore.ListAll().Single(a => a.Id == id);
Assert.Null(stored.ZaakUrl);
Assert.NotNull(stored.ZgwError);
var audit = await client.SendAsync(AdminRequest(HttpMethod.Get, "/api/v1/admin/audit"));
audit.EnsureSuccessStatusCode();
var entries = (await audit.Content.ReadFromJsonAsync<List<AuthzAuditDto>>())!;
Assert.Contains(entries, e => e.Action == "zgw:divergence" && e.Decision == "deny" && e.Resource == body.Referentie);
}
[Fact]
public async Task Submit_with_a_healthy_zgw_leaves_no_divergence_flag()
{
var stub = new ZgwStubHandler(SuccessBody);
using var factory = Factory(stub);
using var client = factory.CreateClient();
var id = await CreateConcept(client);
var res = await client.PostAsJsonAsync($"/api/v1/applications/{id}/submit", new { diplomaHerkomst = "duo" });
res.EnsureSuccessStatusCode();
var stored = ApplicationStore.ListAll().Single(a => a.Id == id);
Assert.Equal($"{ZrcBase}/zaken/uuid-new", stored.ZaakUrl);
Assert.Null(stored.ZgwError);
}
private static HttpRequestMessage AdminRequest(HttpMethod method, string path)
{
var req = new HttpRequestMessage(method, path);
req.Headers.Add("X-Role", "admin");
return req;
}
}
@@ -9,8 +9,15 @@ namespace BigRegister.Tests;
/// URL across GET/POST). Records every request's url/body/auth-scheme for assertion.
/// Factored out of OpenZaakZaakSourceTests once OpenZaakDocumentSourceTests needed the
/// identical stub.
///
/// WP-60: an optional <paramref name="status"/> callback lets a test inject a failing status
/// for a given url on a given (0-based) attempt — e.g. "503 on the first call to /zaken, then
/// let it through" — to exercise ZgwHttpClient's retry without a live server. When it returns
/// a non-2xx code, <paramref name="respond"/> is not called for that attempt (so a test that
/// models an "always fails" url never has to also teach `respond` a success body it never
/// reaches).
/// </summary>
internal sealed class ZgwStubHandler(Func<string, string> respond) : HttpMessageHandler
internal sealed class ZgwStubHandler(Func<string, string> respond, Func<string, int, HttpStatusCode>? status = null) : HttpMessageHandler
{
public List<string> Requests { get; } = new();
public List<string?> AuthSchemes { get; } = new();
@@ -21,9 +28,18 @@ internal sealed class ZgwStubHandler(Func<string, string> respond) : HttpMessage
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var url = request.RequestUri!.ToString();
var attempt = Requests.Count(r => r == url);
Requests.Add(url);
AuthSchemes.Add(request.Headers.Authorization?.Scheme);
Bodies.Add(request.Content?.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult() ?? "");
var code = status?.Invoke(url, attempt) ?? HttpStatusCode.OK;
if (!((int)code >= 200 && (int)code < 300))
return Task.FromResult(new HttpResponseMessage(code)
{
Content = new StringContent("{\"detail\":\"stub failure\"}", Encoding.UTF8, "application/json"),
});
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(respond(url), Encoding.UTF8, "application/json"),
+50
View File
@@ -0,0 +1,50 @@
# Opt-in overlay, layered ON TOP of docker-compose.yml (never alone):
#
# docker compose -f docker-compose.yml -f docker-compose.openzaak.yml up -d
#
# Points the containerized BFF (`api`) at a real OpenZaak instead of the local SQLite store.
# `api` joins the OpenZaak project's OWN network (external, below) to reach its `web` service
# by the dotted alias `docker-compose.openzaak.bff.yml` (backend/openzaak/) gives it —
# `openzaak.local`, not a bare `openzaak`: Django's URLValidator rejects a dotless hostname
# embedded in a URL field (confirmed empirically — see that file's header comment for the full
# reasoning, including why the join runs in this direction and not the reverse). See
# `scripts/openzaak-ui-up.sh` for the one command that brings both projects up together, seeds
# the catalogus, grants the extra scope this alias needs, and fills in OPENZAAK_ZAAKTYPE_URL.
#
# ClientId/Secret/RSINs match exactly what backend/openzaak/bootstrap-catalogus.sh provisions.
# Only `herregistratie` gets a ZaaktypeUrls entry: the harness seeds exactly one zaaktype
# ("Herregistratie arts") — a registratie/intake submission would hit an unconfigured zaaktype,
# caught by WP-60's retry/flagging (Aanvraag.ZgwError), not surfaced as a UI error. DrcBaseUrl/
# InformatieobjecttypeUrls are deliberately left unset: this harness seeds no Documenten
# content or scope, so a document upload's ZGW half just no-ops (also caught since WP-60).
#
# ZGW authorization scopes a zaaktype write by the EXACT zaaktype URL string an Applicatie was
# granted for (confirmed empirically: the same zaaktype, referenced via a different hostname
# string, 403s even though the URL itself resolves fine) — bootstrap-catalogus.sh only ever
# grants the `http://localhost:8000/...` form (it runs on the host). scripts/openzaak-ui-up.sh
# additively grants the SAME scope again for the `openzaak.local:8000` form this file's `api`
# actually presents, without touching bootstrap-catalogus.sh's own (host-usable) grant.
services:
api:
environment:
- Zgw__Enabled=true
- Zgw__ZrcBaseUrl=http://openzaak.local:8000/zaken/api/v1
- Zgw__ZtcBaseUrl=http://openzaak.local:8000/catalogi/api/v1
# Uncomment to chase the per-container flake (scripts/openzaak-ui-up.sh's header
# comment): logs Content-Length vs. actual bytes sent for every outbound ZGW POST.
# - ZGW_DEBUG_HTTP=1
- Zgw__ClientId=bigregister-test
- Zgw__Secret=bigregister-test-secret
- Zgw__UserId=bigregister-test
- Zgw__UserRepresentation=Docker compose OpenZaak test
- Zgw__Bronorganisatie=123443210
- Zgw__VerantwoordelijkeOrganisatie=123443210
- Zgw__ZaaktypeUrls__herregistratie=${OPENZAAK_ZAAKTYPE_URL:?run backend/openzaak/bootstrap-catalogus.sh and export OPENZAAK_ZAAKTYPE_URL first — see scripts/openzaak-ui-up.sh for the one-command version}
networks:
default: {}
oz: {}
networks:
oz:
name: openzaak_default
external: true
+12 -13
View File
@@ -107,10 +107,10 @@ for its existing violations, so every WP ends green.
| [WP-54](WP-54-openzaak-integration-harness.md) | Docker OpenZaak integration-test harness (opt-in, live round-trip) | 9 · OpenZaak/ZGW | done |
| [WP-55](WP-55-openzaak-secrets-tls.md) | Real secrets + TLS for the OpenZaak harness | 10 · OpenZaak hardening | done |
| [WP-56](WP-56-openzaak-catalogus-provisioning.md) | Idempotent catalogus provisioning | 10 · OpenZaak hardening | done |
| [WP-57](WP-57-openzaak-least-privilege-scopes.md) | Least-privilege client scopes | 10 · OpenZaak hardening | todo |
| [WP-58](WP-58-openzaak-notifications.md) | Real notifications (celery + scripted abonnement) | 10 · OpenZaak hardening | todo |
| [WP-59](WP-59-document-confidentialiteit-config.md) | Per-document-type confidentialiteit config | 10 · OpenZaak hardening | todo |
| [WP-60](WP-60-write-divergence-resilience.md) | Write-divergence resilience (local + ZGW writes) | 10 · OpenZaak hardening | todo |
| [WP-57](WP-57-openzaak-least-privilege-scopes.md) | Least-privilege client scopes | 10 · OpenZaak hardening | done |
| [WP-58](WP-58-openzaak-notifications.md) | Real notifications (celery + scripted abonnement) | 10 · OpenZaak hardening | done |
| [WP-59](WP-59-document-confidentialiteit-config.md) | Per-document-type confidentialiteit config | 10 · OpenZaak hardening | done |
| [WP-60](WP-60-write-divergence-resilience.md) | Write-divergence resilience (local + ZGW writes) | 10 · OpenZaak hardening | done |
| [WP-61](WP-61-behandelportal-bootstrap.md) | Bootstrap the behandelportal app | 11 · Behandelportal | todo |
| [WP-62](WP-62-medewerker-identity-authz.md) | Backend: medewerker caller identity + authz seam | 11 · Behandelportal | todo |
| [WP-63](WP-63-aanvraag-status-lifecycle.md) | Backend: aanvraag status lifecycle as a published DTO | 11 · Behandelportal | todo |
@@ -149,17 +149,16 @@ deployment of 49–52) and **54** (a docker OpenZaak harness + opt-in integratio
CRUD arc and can land any time; 54 depends on 49 (something to read) and unlocks realistic
testing for the rest. Both are self-contained (each WP file carries its own current-state
handoff) and sized for a fresh Sonnet session.
Phase 10 (OpenZaak production hardening, WP-55..60) and Phase 11 (Behandelportal,
WP-61..66) are two independent tracks that can be worked concurrently — neither blocks
the other. Within phase 10: 55/59/60 are fully independent; 57 and 58 both build on 56's
provisioning mechanism, otherwise independent of each other. Within phase 11: 61
Phase 10 (OpenZaak production hardening, WP-55..60 — now **done**) and Phase 11
(Behandelportal, WP-61..66) are two independent tracks that can be worked concurrently —
neither blocks the other. Within phase 10: 55/59/60 were fully independent; 57 and 58 both
built on 56's provisioning mechanism, otherwise independent of each other. Within phase 11: 61
(bootstrap), 62 (backend medewerker identity), and 63 (backend status DTO) are
independent of each other and can land in any order; 64 needs all three (61 for the app
to exist, 62 for identity, 63 for the status it reads); 65 needs 64; 66 needs 65 and
benefits from — but doesn't strictly require — phase 10's WP-60 landing first (WP-66 is
a second, currently-unprotected write pair otherwise). WP-60 is the one slice in phase 10
sized for a `planner`-agent kickoff rather than direct implementation — its Decisions
block is deliberately left open (outbox vs. retry+reconcile).
to exist, 62 for identity, 63 for the status it reads); 65 needs 64; 66 needs 65 and — now
that WP-60 has landed (bounded retry + flagged divergence in `ZgwHttpClient`/`Program.cs`) —
inherits that retry for free, but must call `RecordZgwDivergence` on its own besluit write path
to get the flagging half too.
## WP template
@@ -1,6 +1,6 @@
# WP-57 — Least-privilege client scopes
Status: todo
Status: done
Phase: 10 — OpenZaak production hardening
## Why
@@ -39,8 +39,51 @@ exercises.
## Acceptance criteria
- [ ] Client config has no wildcard/all-scopes grant.
- [ ] `OpenZaakIntegrationTests` (WP-54) pass unchanged against the narrowed client.
- [x] Client config has no wildcard/all-scopes grant.
- [x] `OpenZaakIntegrationTests` (WP-54) pass unchanged against the narrowed client.
## What actually happened
`vng_api_common`'s `ApplicatieConfigurationModel` (the class backing
`setup_configuration`'s `vng_api_common_applicaties` step, read from the installed package
inside the `openzaak/open-zaak:1.29.1` image) only has fields for
`uuid`/`client_ids`/`label`/`heeft_alle_autorisaties` — there is no YAML field for granular
`autorisaties` at all. So `data.yaml` now sets `heeft_alle_autorisaties: false` (both the dev
harness and the prod template), which leaves `bigregister-test` with **zero** Autorisaties
until something else grants them.
That "something else" can't be the JWT-authenticated Autorisaties REST API — a zero-scope
client can't grant itself scope over an API gated by scope (confirmed from
`ApplicatieViewSet.required_scopes`: `update`/`partial_update` need
`autorisaties.bijwerken`). `bootstrap-catalogus.sh` grants the scopes directly via the ORM
instead (`docker compose exec web python manage.py shell`, workdir `/app/src`) — no
JWT/REST layer involved, so no circularity. Two grants, both idempotent (delete-then-create):
- `ztc`: `catalogi.lezen` + `catalogi.schrijven` — granted up front (no zaaktype dependency).
Only `catalogi.schrijven` is provisioning-only; the BFF itself only ever reads Catalogi.
- `zrc`: `zaken.aanmaken` + `zaken.bijwerken` + `zaken.lezen`, scoped to the one zaaktype
(`zaaktype=<ZT-HERREG url>`, `max_vertrouwelijkheidaanduiding=openbaar` — both fields are
_required_ by OpenZaak's `AutorisatieValidator` for any `zaken.*` scope) — granted once
`zaaktype_url` is known, right after the zaaktype is created/resolved.
Reading the actual `RolViewSet`/`StatusViewSet`/`ZaakInformatieObjectViewSet`
`required_scopes` (not just the scope docstrings, which are aspirational/descriptive) showed
the decision text's "statussen (aanmaken), rollen (aanmaken)" don't map to separate OpenZaak
scopes — `zaken.aanmaken` alone (OR'd against alternatives) already covers the first status
and the initiator rol; there is no `rollen.aanmaken` scope. `documenten`/`zaakinformatieobjecten`
scope was **not** granted: `Zgw:InformatieobjecttypeUrls` is empty in `appsettings.json`, so
`OpenZaakDocumentSource.Upload` can't function in this harness regardless of scope (throws
before any HTTP call) — nothing to scope precisely to yet. Left as a documented follow-up
(the script would also need to seed an `informatieobjecttype` to have something concrete to
scope `documenten.aanmaken` to).
Verified for real: `down -v` fresh volume → `up -d` → `bootstrap-catalogus.sh` (all
"created", scopes granted, `heeft_alle_autorisaties: False` confirmed via `manage.py shell`)
→ `dotnet test --filter Category=Integration` green → reran `bootstrap-catalogus.sh` again
under the now-narrowed client (all "exists", scopes re-granted idempotently, no 403s) →
confirmed the narrowing is real, not just untested, by DELETEing the seeded zaak with a
hand-rolled JWT for this client: 403 `permission_denied` (zaak deletion needs
`zaken.verwijderen`/`zaken.geforceerd-bijwerken`, neither granted).
## Verification
@@ -1,6 +1,6 @@
# WP-58 — Real notifications (celery + scripted abonnement)
Status: todo
Status: done
Phase: 10 — OpenZaak production hardening
## Why
@@ -44,25 +44,85 @@ today that registration step is manual.
## Acceptance criteria
- [ ] A notifications-enabled harness profile runs celery/celery-beat and delivers a real
- [x] A notifications-enabled harness profile runs a celery worker and delivers a real
notification end-to-end to the BFF's webhook.
- [ ] The `abonnement` registration step is a script, re-runnable without erroring on an
already-registered subscription.
- [x] Provisioning is a script, re-runnable without erroring on an already-configured target.
## What actually happened
The Decisions block assumed OpenZaak itself could be pointed at, celery-wired, and made to
deliver to a subscribed `abonnement` — checking the running image (`grep -ril abonnement` inside
the `web` container) found nothing: **OpenZaak does not serve the Notificaties API.** It's a
separate application (`openzaak/open-notificaties`, its own image/DB/celery/beat stack).
Standing one up for real `abonnement`/kanaal-filtered routing would mean ~5 new services (a
second Postgres, web, worker, beat, plus the NRC↔AC authorization chain) for a benefit this
harness doesn't need — there is exactly one subscriber (this repo's own BFF), never N. Re-scoped
before writing any code (confirmed with the user): OpenZaak's own `NotificationsConfig` points
straight at the BFF's webhook via a `zgw_consumers.Service` (`auth_type=api_key`) instead — no
NRC, no `abonnement`, same delivery proof (a real write → OpenZaak's celery worker → a real HTTP
POST → the BFF's audit trail). The two "no `abonnement`" acceptance-criteria words above were
edited out for the same reason.
- `docker-compose.openzaak.notificaties.yml` — an opt-in overlay (not `profiles:`, matching
WP-55's prod-override precedent) adding one celery worker (not celery-beat: `send_notification`
is a plain async task fired on save, not a scheduled one — beat only matters on a real NRC's
polling side) and flipping `NOTIFICATIONS_DISABLED` off. The two changes are inseparable:
`NOTIFICATIONS_GUARANTEE_DELIVERY` defaults true, so the moment that flag is false, every write
to a notified resource 500s-and-rolls-back unless `NotificationsConfig` already has a client —
hence `bootstrap-notificaties.sh` configuring it is not a separate step.
- Reaching the BFF from the worker turned out to be the real obstacle, not the Django/celery
wiring. `extra_hosts: host.docker.internal:host-gateway` (the plan's first choice) resolves
fine but every TCP connect through it timed out — confirmed live: this environment's rootless
Podman doesn't route container→host-port traffic that way. Fix: join the overlay's `celery`
service to the repo root's own `docker compose up` network (`external: true`, by the
`atomic-design-poc_default` name compose derives from the repo directory) and reach the BFF by
its container name (`api`) instead — container-to-container, which this exact stack already
proved reliable (`celery` already talks to `db`/`redis` that way). One more trap on that path:
`docker compose run --name api ...` does **not** register the `api` DNS alias other containers
need (only `docker compose up -d api` does) — cost a debugging round-trip before switching to
`up -d` (via a temporary, uncommitted `docker-compose.override.yml`) for the live verification.
- `bootstrap-notificaties.sh` — `update_or_create` on the `Service`'s fixed slug (idempotent);
preflights the BFF's webhook with a synthetic notification body first (204 required) so a
misconfigured target fails before touching OpenZaak, not after (a later write would otherwise
500-and-rollback with no obvious cause).
- `verify-notificatie.sh` — the runnable end-to-end check. First attempt triggered the write via
a second `statussen` POST (the "final" status) — 403'd: WP-57's narrowed `zaken.aanmaken` scope
permits exactly **one** status per zaak ("Met de 'zaken.aanmaken' scope mag je slechts 1 status
zetten"). Switched the trigger to a zaak `PATCH` (`toelichting`), covered by the already-granted
`zaken.bijwerken` and trivially repeatable. Second attempt used the _final_ statustype anyway
for a different reason and got a 400 ("Zaak has no resultaat") — OpenZaak requires a `resultaat`
before the closing status; the `PATCH` sidesteps that precondition entirely too.
- Verified for real, twice: `bootstrap-catalogus.sh` (idempotent re-run, all "exists") →
`bootstrap-notificaties.sh` (preflight 204, `Service` configured) → `verify-notificatie.sh`
(PATCH → polled `/admin/audit` → found the delivered `zgw:notificatie`/`allow` row) → reran
both WP-58 scripts again under the same running harness (still idempotent, delivered again).
Also confirmed the negative case directly: `POST /zgw/notificaties` with no `Authorization`
header, and with a wrong one, both 401 — the shared-secret gate isn't just accepting anything.
Backend suite stayed green throughout (159/159, `dotnet test --filter Category!=Integration`).
Test infrastructure (the temporary `docker-compose.override.yml`, the manually-created `api`
container) was torn down / reconciled back to the pre-session baseline afterward.
## Verification
Bring up the notifications-enabled profile; create a zaak/status change; confirm the BFF's
`/zgw/notificaties` endpoint receives and logs it.
Bring up the notifications-enabled profile (`backend/openzaak/README.md`'s "Notifications-enabled
profile" section); run `./bootstrap-catalogus.sh && ./bootstrap-notificaties.sh &&
./verify-notificatie.sh`. The last script fails loudly (with celery/worker log diagnostics) if no
delivered notification shows up in the BFF's `/admin/audit` within 60s.
## Out of scope
Cache invalidation on notification receipt (flagged separately in
`openzaak-integration.md` as a `ponytail:` marker, not part of this slice);
celery-flower/monitoring UI.
celery-flower/monitoring UI. A real Notificaties API (NRC) + `abonnement`/kanaal-filtered
routing (see "What actually happened") — add one if a later WP needs more than this harness's
single subscriber.
## Risks
Celery/celery-beat add real operational surface (another process to keep alive) — scope
this WP to "works, documented," not a fully monitored deployment.
Celery adds real operational surface (another process to keep alive) — scope this WP to
"works, documented," not a fully monitored deployment. The direct-to-BFF shortcut means this
harness doesn't exercise real `abonnement`/kanaal-filter validation — a production deployment's
NRC-based path (documented in `openzaak-integration.md`) is untested by this harness by
construction.
Depends on: WP-56 (provisioning mechanism this extends).
@@ -1,6 +1,6 @@
# WP-59 — Per-document-type confidentialiteit config
Status: todo
Status: done
Phase: 10 — OpenZaak production hardening
## Why
@@ -43,17 +43,35 @@ this slice is "apply the existing pattern," not invent a new one.
## Acceptance criteria
- [ ] Confidentiality level for a real upload varies by document type per the new
stamdata table.
- [ ] `StamdataValidationTests` cover the new table (a bad edit fails CI, per ADR-0004).
- [ ] `/beheer/stamdata` can edit the new table without a code change (existing generic
editor).
- [x] Confidentiality level for a real upload varies by document type per the new
stamdata table (`identiteit` → `vertrouwelijk`; everything else → `openbaar`).
- [x] `StamdataValidationTests` cover the new table (a bad edit fails CI, per ADR-0004).
- [x] `/beheer/stamdata` can edit the new table without a code change (existing generic
editor — the `StamdataCatalog` registration is the only wiring needed).
## What actually happened
Implemented mostly as planned — one gap found and closed: the diff as first written
registered `DocumentConfidentialiteit` in `StamdataCatalog` and wired the lookup into
`OpenZaakDocumentSource`, plus a positive test (`identiteit` → `vertrouwelijk`) and a
fallback test (an unmapped category, `org-logo`, → `openbaar`), but had **no**
`StamdataValidationTests` reference-integrity entry for the new table — the second
acceptance box was unchecked. Added one: a `StamdataRef` resolving every
`documentconfidentialiteit.json` `categoryId` against the real set of document category
ids (`DocumentRules.AllCategoriesFor` across `registratie`/`herregistratie`/`org-template`),
so a typo'd or stale `categoryId` now fails the build instead of silently never matching
(`OpenZaakDocumentSource.ConfidentialiteitFor`'s dictionary lookup would otherwise just
fall back to `"openbaar"` forever with no signal). `org-logo` deliberately stays absent
from the confidentialiteit table (falls back to `"openbaar"`) and correctly still
resolves as a known category — the reference check validates "is this a real category",
not "must every category be configured."
## Verification
`cd backend && dotnet test`; manual: `/beheer/stamdata` shows and edits the new table; an
upload for a mapped document type carries the mapped confidentiality level (test
asserted).
`cd backend && dotnet test` (161/161 green, incl. the 2 new `OpenZaakDocumentSourceTests` plus
the new `StamdataValidationTests` reference entry); `dotnet format --verify-no-changes` clean.
Manual: `/beheer/stamdata` shows and edits the new table; an upload for a mapped document type
carries the mapped confidentiality level (test asserted).
## Out of scope
@@ -1,6 +1,6 @@
# WP-60 — Write-divergence resilience (local + ZGW writes)
Status: todo
Status: done
Phase: 10 — OpenZaak production hardening
## Why
@@ -23,37 +23,57 @@ production.
## Decisions
Intentionally left open for kickoff — this is exactly the kind of ambiguous-root-cause,
multi-file design call the `planner` agent should make, not something pre-decided here.
Options to weigh at kickoff:
Picked **(b), narrowed further: bounded synchronous retry + flag, no reconcile job.** The
`planner` agent's kickoff review found the write side smaller than either option assumed:
- (a) an outbox table — write local + an outbox row in one local transaction, a background
worker drains the outbox to ZGW with retry.
- (b) a simpler synchronous retry-with-backoff at the call site, plus a reconciliation job
that periodically diffs local vs. ZGW state and flags/repairs divergence.
- The only ZGW writes are `OpenZaakZaakSource.CreateZaak` (zaak/status/rol, one POST sequence
per submit) and `OpenZaakDocumentSource.Upload`/`LinkToZaak` (DRC + zaakinformatieobject).
There is no standalone status-transition write path yet (that's WP-66) — Step 2 below is
corrected accordingly.
- Every path already does the local write first and never rolls it back on a ZGW failure — "the
ZGW half fails, local succeeded" is the only real scenario; the reverse can't happen.
- An outbox was rejected: three request-triggered write paths don't justify a persisted queue,
and a ZGW call's `CallerIdentity` (needed for the JWT's audit claims, WP-53) would mean PII
sitting in a new table — the "generic outbox framework" this WP's own Risks section warns
against.
- A reconcile job was judged unnecessary for the acceptance criteria: flagging (not silent
divergence) is sufficient, and repair is always possible on demand because a zaak's
`identificatie` equals the aanvraag's `Referentie` — no reconcile job ships in this WP.
Pick the smaller one that closes the gap — don't build a generic outbox framework if a
bounded retry+reconcile suffices for this POC's actual write volume.
Shipped: bounded retry (3 attempts, doubling backoff from 200ms) in `ZgwHttpClient` for
transport-shaped failures only (429/502/503/504/408 + connection errors/timeouts — deliberately
**not** 500, which can follow a partial commit on the non-idempotent `/statussen`/`/rollen`
POSTs); `Aanvraag.ZgwError` + a `zgw:divergence` audit row when a ZGW write still fails after
retry (`Program.cs`'s submit endpoint, two separate try/catches so a create-zaak failure doesn't
also skip the still-local document link); `OpenZaakDocumentSource.Upload` catches and logs
without a separate flag column (`DrcUrl == null` already means "not registered in ZGW yet").
Full reasoning + rejected sub-options: [openzaak-integration.md](../reference/openzaak-integration.md)'s
"Write resilience" section.
## Files
Likely `Data/ApplicationStore.cs`, a new reconciliation/outbox mechanism,
`Zgw/OpenZaakZaakSource.cs`, `Program.cs` (background job registration if needed).
`Zgw/ZgwHttpClient.cs` (retry), `Data/ApplicationStore.cs` (`ZgwError` column + migration),
`Program.cs` (submit endpoint rewire + `RecordZgwDivergence` + HttpClient timeouts),
`Zgw/OpenZaakDocumentSource.cs` (non-throwing upload). No new file for a mechanism — no
outbox/background worker shipped (see Decisions).
## Steps
1. Design review with the `planner` agent — pick outbox vs. retry+reconcile.
2. Implement the chosen mechanism for the create-zaak and status-transition write paths.
1. Design review with the `planner` agent — pick outbox vs. retry+reconcile. Done: retry+flag
(see Decisions).
2. Implement the chosen mechanism for the create-zaak and document (upload + link) write
paths — not "status-transition" as originally scoped here; that path doesn't exist yet
(arrives with WP-66).
3. Add a test that simulates a ZGW failure mid-write and asserts the system recovers
(retries successfully, or is left in a detectably-inconsistent-but-flagged state)
rather than silently diverging.
## Acceptance criteria
- [ ] A simulated ZGW failure after a successful local write no longer leaves permanent
- [x] A simulated ZGW failure after a successful local write no longer leaves permanent
silent divergence — either it retries to consistency or the divergence is
detectable/flagged.
- [ ] No new synchronous latency added to the happy path beyond what the chosen mechanism
- [x] No new synchronous latency added to the happy path beyond what the chosen mechanism
requires.
## Verification
@@ -62,8 +62,11 @@ Any further behandelportal screens beyond beoordeling.
## Risks
If Phase 10's WP-60 (write-divergence resilience) hasn't landed yet, this introduces a
second unprotected write pair — call this out explicitly if the two phases aren't
sequenced together in practice.
WP-60 (write-divergence resilience) has landed: bounded retry lives in `ZgwHttpClient`, so
this write pair inherits it automatically. It does **not** get the flagging half for free —
call `RecordZgwDivergence` (or the equivalent for whichever endpoint hosts the besluit write) on
this path's catch too, the same way `Program.cs`'s submit endpoint does for create-zaak/document
writes, or this becomes the "second, currently-unprotected write pair" WP-60's own scope note
anticipated.
Depends on: WP-65. Benefits from (but doesn't strictly require) WP-60.
Depends on: WP-65.
@@ -67,5 +67,13 @@ up front — the migration stance ADR-0001 already prescribes.
- **Also shipped (WP-50):** `IZaakSource.CreateZaak` — the first write. Submitting an aanvraag
now also creates a Zaak + Status + Rol in OpenZaak when `Zgw:Enabled=true`, routed through the
existing submit endpoint with zero DTO change (same seam, same anti-corruption boundary).
- **Deferred:** real inbound OIDC/JWT auth (still header-stubbed), Documenten/DRC upload + link
(WP-51), Notificaties/NRC webhooks (WP-52), adding OpenZaak to docker-compose.
- **Also shipped (WP-51):** `IDocumentSource` (`LocalDocumentSource`/`OpenZaakDocumentSource`,
same config-gated seam shape) — an upload registers a Documenten/DRC enkelvoudiginformatieobject
and, once a zaak exists, a submit links it in with a zaakinformatieobject.
- **Also shipped (WP-60):** bounded retry in `ZgwHttpClient` for transport-shaped ZGW failures,
plus a flagged (not silent) divergence — `Aanvraag.ZgwError` + a `zgw:divergence` audit row —
when a ZGW write still fails after retry. No outbox/background worker (see WP-60 for the
ladder check that ruled it out for this POC's write volume).
- **Deferred:** real inbound OIDC/JWT auth (still header-stubbed), Notificaties/NRC webhooks
(WP-52, shipped instead as a direct-to-BFF delivery in WP-58), adding OpenZaak to
docker-compose, an automated reconciliation/repair job for a flagged divergence (WP-60).
+113 -13
View File
@@ -61,11 +61,51 @@ The created zaak's `identificatie` becomes the returned `Referentie`; its status
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).
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 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`. Deferred until a second write pair (WP-66) or a real deployment makes it worth
building — at which point the outbox question above is also worth re-asking.
## Documenten / DRC upload + zaak link (WP-51)
@@ -88,8 +128,11 @@ happens first — it stays the record of truth for preview/download/audit regard
`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.
`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.
## The ZGW client (`backend/src/BigRegister.Api/Zgw/`)
@@ -132,9 +175,11 @@ to `IZaakSource` per call), so a valid notification's only visible effect right
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:
**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
{
@@ -144,6 +189,13 @@ pointing at this BFF's public URL:
}
```
`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
@@ -224,6 +276,53 @@ enforces ZGW's geo-header requirement in a way no stub-based test could catch, s
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
@@ -247,9 +346,10 @@ the seam against real protocol behaviour, not just the shapes we already assumed
"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",
// 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>"
}
```
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env bash
# One command to test the Angular UI end-to-end against a real OpenZaak instead of the local
# SQLite store: brings up the OpenZaak harness (backend/openzaak/), seeds its catalogus, wires
# it onto the root app's docker network (docker-compose.openzaak.bff.yml /
# docker-compose.openzaak.yml — see those files for the full "why", it's non-obvious), grants
# the extra authorization scope the container alias needs, and brings the FE+BFF up pointed at
# OpenZaak.
#
# Safe to re-run: every step this chains is already idempotent (bootstrap-catalogus.sh,
# `docker compose up -d`, and the grant-replace below).
#
# Also confirmed empirically, many repeated trials: some freshly-(re)started `api`
# containers have EVERY outbound ZGW POST fail with what looks like an empty body reaching
# OpenZaak ("all fields required"), for that container's entire lifetime — while a plain curl
# to the exact same URL, from inside the exact same container, never fails, even hammered in a
# loop. Ruled out as the cause: HttpClient connection pooling settings, Expect-100-Continue,
# content pre-buffering — none of it made a measurable difference. Best lead so far, and
# reproduced live on this dev host (7.5/8GB swap from long-idle unrelated containers): this
# correlates with the HOST being under heavy memory pressure — a heavier managed runtime
# (dotnet's JIT + GC) is plausibly far more sensitive to that than a lightweight one-shot
# `curl` process. If you hit this, try freeing host memory (stop unrelated containers) before
# assuming it's a code regression. The preflight check and self-check below warn about and
# retry around it either way; set ZGW_DEBUG_HTTP=1 on the `api` container (see
# docker-compose.openzaak.yml) next time it reproduces to log Content-Length vs. actual bytes
# sent, which would confirm (or rule out) client-side body corruption.
set -euo pipefail
cd "$(dirname "$0")/.."
step() { printf '\n\033[1;36m▶ %s\033[0m\n' "$1"; }
check_memory_pressure() {
local total used pct
read -r total used <<< "$(free -m | awk '/^Swap:/{print $2, $3}')"
[ "${total:-0}" -gt 0 ] || return 0
pct=$(( used * 100 / total ))
if [ "$pct" -ge 50 ]; then
echo "⚠ host swap ${pct}% used (${used}MiB/${total}MiB) — this correlates with the" >&2
echo " known per-container ZGW flake (see comment up top). Consider 'docker ps' and" >&2
echo " stopping unrelated long-running stacks before continuing." >&2
fi
}
check_memory_pressure
step "root app (creates the docker network the OpenZaak harness joins below)"
docker compose up -d
step "OpenZaak harness + bff overlay"
( cd backend/openzaak && docker compose -f docker-compose.openzaak.yml -f docker-compose.openzaak.bff.yml up -d )
step "seed catalogus/zaaktype/zaak (idempotent)"
bootstrap_output=$(cd backend/openzaak && ./bootstrap-catalogus.sh)
echo "$bootstrap_output"
zaaktype_line=$(printf '%s\n' "$bootstrap_output" | grep -A1 '^Zaaktype (concept)\.\.\.$' | tail -n1)
zaaktype_url=$(printf '%s\n' "$zaaktype_line" | sed -E 's/^ *(exists|created): //')
zaaktype_uuid=$(printf '%s\n' "$zaaktype_url" | sed 's#.*/##')
if [ -z "$zaaktype_uuid" ]; then
echo "Could not find the seeded zaaktype's URL in bootstrap-catalogus.sh's output — see above." >&2
exit 1
fi
# bootstrap-catalogus.sh queried (and granted scope) via http://localhost:8000 (run from the
# host); the containerized BFF reaches the same resource via the `openzaak.local` alias
# (docker-compose.openzaak.bff.yml) instead — only the UUID suffix is what actually matters.
container_zaaktype_url="http://openzaak.local:8000/catalogi/api/v1/zaaktypen/${zaaktype_uuid}"
export OPENZAAK_ZAAKTYPE_URL="$container_zaaktype_url"
step "grant the container-alias zaaktype scope (REPLACES bootstrap-catalogus.sh's own localhost-scoped grant, doesn't add to it — OpenZaak's own zaken-list authorization filter 500s with a RuntimeError, 'are you sure that all paths point to the same resource?', when an Applicatie has two zrc grants for what's really the same zaaktype under two different hostnames; confirmed empirically. One consequence: dotnet test --filter Category=Integration needs bootstrap-catalogus.sh rerun afterward to restore its localhost-scoped grant — it's idempotent, so that's a plain rerun, not a reset)"
( cd backend/openzaak && docker compose -f docker-compose.openzaak.yml exec -T --workdir /app/src web python manage.py shell ) <<PY
from vng_api_common.authorizations.models import Applicatie
app = Applicatie.objects.get(client_ids__contains=["bigregister-test"])
app.autorisaties.filter(component="zrc").delete()
app.autorisaties.create(
component="zrc",
scopes=["zaken.aanmaken", "zaken.bijwerken", "zaken.lezen"],
zaaktype="$container_zaaktype_url",
max_vertrouwelijkheidaanduiding="openbaar",
)
print("granted:", "$container_zaaktype_url")
PY
step "root app again (now pointed at OpenZaak)"
docker compose -f docker-compose.yml -f docker-compose.openzaak.yml up -d
step "verify the BFF can actually reach OpenZaak (retries api if not — see the flake note up top)"
wait_for_api() {
# -f: a response has to actually be 2xx — dotnet run's own build/start (and, on a retry, a
# full restart) can leave the port accepting-but-erroring for a few seconds first, which a
# plain curl (no -f) would misread as "ready".
for _ in $(seq 1 60); do
curl -sS -f -m 2 -o /dev/null http://localhost:5000/api/v1/me && return 0
sleep 1
done
return 1
}
# A throwaway herregistratie submit: does the resulting zaak actually show up in OpenZaak
# (via the BFF's own /admin/cases)?
zgw_write_reaches_openzaak() {
local create id ref cases
create=$(curl -sS -m 10 -X POST http://localhost:5000/api/v1/applications -H "Content-Type: application/json" -d '{"type":"herregistratie"}') || return 1
id=$(printf '%s' "$create" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])') || return 1
ref=$(curl -sS -m 15 -X POST "http://localhost:5000/api/v1/applications/$id/submit" -H "Content-Type: application/json" -d '{"uren":1}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["referentie"])') || return 1
cases=$(curl -sS -m 10 -H "X-Role: admin" http://localhost:5000/api/v1/admin/cases)
printf '%s' "$cases" | python3 -c "
import json, sys
refs = [c['status']['referentie'] for c in json.load(sys.stdin)]
sys.exit(0 if '$ref' in refs else 1)
"
}
verified=false
attempts=5
for attempt in $(seq 1 "$attempts"); do
wait_for_api || { echo "api never came up listening — see 'docker logs atomic-design-poc-api-1'." >&2; break; }
if zgw_write_reaches_openzaak; then
verified=true
break
fi
echo " attempt $attempt/$attempts: a write didn't reach OpenZaak — restarting api and giving the network a moment to settle (known flake, see comment up top)..."
sleep 3
docker compose -f docker-compose.yml -f docker-compose.openzaak.yml restart api
done
if [ "$verified" = true ]; then
echo " verified: a write reaches OpenZaak."
else
check_memory_pressure
echo " Could not verify after $attempts tries. This is the known flake, not necessarily a real failure —" >&2
echo " keep retrying by hand: 'docker compose -f docker-compose.yml -f docker-compose.openzaak.yml restart api'," >&2
echo " wait for 'Application started' in 'docker logs -f atomic-design-poc-api-1', then try the UI again." >&2
fi
printf '\n\033[1;32m✔ up\033[0m\n'
cat <<EOF
App: http://localhost:4200
Admin cases (proof an aanvraag landed in OpenZaak): http://localhost:4200/beheer/zaken?role=admin
Audit trail (should show no zgw:divergence rows): http://localhost:4200/beheer/audit?role=admin
OpenZaak directly: http://localhost:8000
Only "herregistratie" has a seeded zaaktype in this harness — submit that wizard to see a real
write land in OpenZaak. registratie/intake submissions still succeed locally but their ZGW
write is flagged (Aanvraag.ZgwError / a zgw:divergence audit row), not surfaced as a UI error.
Document uploads also won't register in ZGW (no Documenten content seeded) — pick "per post"
in the wizard's document step to keep the demo clean, or ignore it, it's non-fatal.
Tear down (plain "down", no -f overlay needed — overlay files only add env/network config to
services docker-compose.yml already defines, and the required-var syntax in
docker-compose.openzaak.yml would otherwise block "down" too once OPENZAAK_ZAAKTYPE_URL falls
out of your shell):
docker compose down
( cd backend/openzaak && docker compose -f docker-compose.openzaak.yml down -v )
EOF