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:
@@ -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 |
|
||||
|
||||
@@ -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=<ZT-HERREG url>`, `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`
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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='<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 +303,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>"
|
||||
}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user