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>
This commit is contained in:
eho
2026-07-30 15:33:16 +02:00
co-authored by Claude Sonnet 5
parent 1e87997ea0
commit 3e983bd2cc
8 changed files with 364 additions and 22 deletions
+44 -5
View File
@@ -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='<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'`).
## 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'`.
+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,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
+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