From 3e983bd2ccfd01c15cd6bab5c28d05588b3c42de Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 30 Jul 2026 15:33:16 +0200 Subject: [PATCH] feat(openzaak): real notification delivery to the BFF webhook (WP-58) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/openzaak/README.md | 49 ++++++++++-- backend/openzaak/bootstrap-notificaties.sh | 59 ++++++++++++++ .../docker-compose.openzaak.notificaties.yml | 64 +++++++++++++++ backend/openzaak/verify-notificatie.sh | 63 +++++++++++++++ docs/project/backlog/README.md | 2 +- .../WP-57-openzaak-least-privilege-scopes.md | 2 +- .../backlog/WP-58-openzaak-notifications.md | 78 ++++++++++++++++--- docs/reference/openzaak-integration.md | 69 ++++++++++++++-- 8 files changed, 364 insertions(+), 22 deletions(-) create mode 100755 backend/openzaak/bootstrap-notificaties.sh create mode 100644 backend/openzaak/docker-compose.openzaak.notificaties.yml create mode 100755 backend/openzaak/verify-notificatie.sh diff --git a/backend/openzaak/README.md b/backend/openzaak/README.md index 5e2177c..ce0ed4b 100644 --- a/backend/openzaak/README.md +++ b/backend/openzaak/README.md @@ -55,6 +55,31 @@ 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='' 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='' ./bootstrap-notificaties.sh +BFF_AUTH='' ./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'`). + ## Tear down ```bash @@ -100,9 +125,9 @@ 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 @@ -125,8 +150,22 @@ app change. 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) / 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). +- **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'`. diff --git a/backend/openzaak/bootstrap-notificaties.sh b/backend/openzaak/bootstrap-notificaties.sh new file mode 100755 index 0000000..4eb762a --- /dev/null +++ b/backend/openzaak/bootstrap-notificaties.sh @@ -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 < {service.api_root}") +PY + +echo +echo "Notifications configured. Run ./verify-notificatie.sh to prove a live delivery." diff --git a/backend/openzaak/docker-compose.openzaak.notificaties.yml b/backend/openzaak/docker-compose.openzaak.notificaties.yml new file mode 100644 index 0000000..9210b1e --- /dev/null +++ b/backend/openzaak/docker-compose.openzaak.notificaties.yml @@ -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 diff --git a/backend/openzaak/verify-notificatie.sh b/backend/openzaak/verify-notificatie.sh new file mode 100755 index 0000000..5b0ef4f --- /dev/null +++ b/backend/openzaak/verify-notificatie.sh @@ -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 diff --git a/docs/project/backlog/README.md b/docs/project/backlog/README.md index 0ee6bc3..4eae109 100644 --- a/docs/project/backlog/README.md +++ b/docs/project/backlog/README.md @@ -108,7 +108,7 @@ for its existing violations, so every WP ends green. | [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 | done | -| [WP-58](WP-58-openzaak-notifications.md) | Real notifications (celery + scripted abonnement) | 10 · OpenZaak hardening | todo | +| [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 | todo | | [WP-60](WP-60-write-divergence-resilience.md) | Write-divergence resilience (local + ZGW writes) | 10 · OpenZaak hardening | todo | | [WP-61](WP-61-behandelportal-bootstrap.md) | Bootstrap the behandelportal app | 11 · Behandelportal | todo | diff --git a/docs/project/backlog/WP-57-openzaak-least-privilege-scopes.md b/docs/project/backlog/WP-57-openzaak-least-privilege-scopes.md index 534f880..e416b33 100644 --- a/docs/project/backlog/WP-57-openzaak-least-privilege-scopes.md +++ b/docs/project/backlog/WP-57-openzaak-least-privilege-scopes.md @@ -63,7 +63,7 @@ JWT/REST layer involved, so no circularity. Two grants, both idempotent (delete- 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=`, `max_vertrouwelijkheidaanduiding=openbaar` — both fields are - *required* by OpenZaak's `AutorisatieValidator` for any `zaken.*` scope) — granted once + _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` diff --git a/docs/project/backlog/WP-58-openzaak-notifications.md b/docs/project/backlog/WP-58-openzaak-notifications.md index 2fa8d2e..63b7a3e 100644 --- a/docs/project/backlog/WP-58-openzaak-notifications.md +++ b/docs/project/backlog/WP-58-openzaak-notifications.md @@ -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). diff --git a/docs/reference/openzaak-integration.md b/docs/reference/openzaak-integration.md index 9b676c1..f31715c 100644 --- a/docs/reference/openzaak-integration.md +++ b/docs/reference/openzaak-integration.md @@ -132,9 +132,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 +146,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 +233,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='' 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='' ./bootstrap-notificaties.sh +BFF_AUTH='' ./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 +303,10 @@ the seam against real protocol behaviour, not just the shapes we already assumed "identiteit": "https://open-zaak.example/catalogi/api/v1/informatieobjecttypen/", "diploma": "https://open-zaak.example/catalogi/api/v1/informatieobjecttypen/" }, - // 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": "" } ```