Compare commits
5
Commits
89ad3490b0
...
ba24784586
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba24784586 | ||
|
|
3ff80c124f | ||
|
|
67abc58052 | ||
|
|
3e983bd2cc | ||
|
|
1e87997ea0 |
@@ -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'`.
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+59
@@ -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
|
||||
|
||||
Executable
+63
@@ -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();
|
||||
|
||||
|
||||
+282
@@ -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");
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
@@ -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).
|
||||
|
||||
@@ -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>"
|
||||
}
|
||||
```
|
||||
|
||||
Executable
+150
@@ -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
|
||||
Reference in New Issue
Block a user