From 0409eb42c5c7ae4dcd559f4d3edacd48084d08b8 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Wed, 3 Jun 2026 13:42:44 +0000 Subject: [PATCH] feat(infra): seed BIG catalogus + JWT client for OpenZaak (refs #2) (#41) --- Makefile | 9 +- .../architecture/adr-0002-catalogus-design.md | 53 +++++++ docs/runbooks/openzaak.md | 32 +++- infra/openzaak/docker-compose.yml | 9 +- infra/openzaak/seed_catalogus.py | 137 ++++++++++++++++++ infra/openzaak/setup_configuration/data.yaml | 22 +++ 6 files changed, 256 insertions(+), 6 deletions(-) create mode 100644 docs/architecture/adr-0002-catalogus-design.md create mode 100644 infra/openzaak/seed_catalogus.py create mode 100644 infra/openzaak/setup_configuration/data.yaml diff --git a/Makefile b/Makefile index 28a45dc..9cdacbe 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK) endif endif -.PHONY: ci lint build unit smoke down changelog openzaak-up openzaak-smoke openzaak-down help +.PHONY: ci lint build unit smoke down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down help ## ci: run the full pipeline — lint, build, unit, smoke (mirrors Gitea Actions) ci: lint build unit smoke @@ -71,6 +71,13 @@ openzaak-smoke: echo "GET /zaken/api/v1/ -> $$root (expect 200)"; test "$$root" = "200"; \ echo "OpenZaak smoke OK"' +## openzaak-seed: bring OpenZaak up and seed the BIG catalogus (idempotent) +openzaak-seed: openzaak-up + @bash -c 'for i in $$(seq 1 50); do \ + c=$$(curl -s -o /dev/null -w "%{http_code}" $(OZ_BASE)/catalogi/api/v1/ || true); \ + [ "$$c" = "200" ] && break; sleep 3; done; echo "OpenZaak ready ($$c)"' + python3 infra/openzaak/seed_catalogus.py + ## openzaak-down: stop and remove the OpenZaak stack (wipes data) openzaak-down: docker compose -f $(OZ_COMPOSE) down --volumes diff --git a/docs/architecture/adr-0002-catalogus-design.md b/docs/architecture/adr-0002-catalogus-design.md new file mode 100644 index 0000000..4dbc11f --- /dev/null +++ b/docs/architecture/adr-0002-catalogus-design.md @@ -0,0 +1,53 @@ +# ADR-0002: BIG catalogus design and OpenZaak seeding + +- **Status:** Accepted +- **Date:** 2026-06-03 +- **Deciders:** Respellion engineering +- **Relates to:** S-01 (#2) + +## Context + +S-01 needs a reproducible `BIG` catalogus in OpenZaak with a **lean** `BIG-registratie` +zaaktype (only schema-mandatory fields) plus a `bsn` eigenschap, and a JWT client that +can list zaaktypen. We had to decide *how* to provision this idempotently at startup. + +Findings from the OpenZaak image (`openzaak/open-zaak:latest`): +- `setup_configuration` (run by the init container) is declarative and idempotent, with + steps for **JWT secrets** and **applicaties** (`vng_api_common_credentials`, + `vng_api_common_applicaties`) — but **no step for catalogi/zaaktypen**. +- Catalogus/zaaktype/eigenschap can only be created through the **ZTC REST API**. +- Publishing a zaaktype requires ≥1 roltype, ≥1 resultaattype and ≥2 statustypen. + +## Decision + +1. **Provision the JWT client declaratively** via `infra/openzaak/setup_configuration/data.yaml`: + a `JWTSecret` (`big-reference-seed` / dev secret) and an `Applicatie` with + `heeft_alle_autorisaties: true`. Idempotent, runs in the init container. +2. **Seed the catalogus/zaaktype/eigenschap via the ZTC API** with an idempotent, + stdlib-only script (`infra/openzaak/seed_catalogus.py`, `make openzaak-seed`). It mints + a ZGW JWT from the provisioned client and matches existing objects (by `domein` / + `identificatie` / `naam`, querying `status=alles` so concepts are seen) before creating. +3. **Keep the zaaktype a CONCEPT (not published).** Publishing pulls in roltypen, + statustypen and resultaattypen, which go beyond "schema-mandatory"; those arrive with + the workflow/zaak slices that actually need a published type. Listing uses `status=alles`. +4. **Disable outbound notifications** (`NOTIFICATIONS_DISABLED=true`) until Open Notificaties + (NRC) lands in S-01-c — otherwise every ZTC write 500s trying to notify. +5. **Fixed dev values:** RSIN `517439943` (elfproef-valid test value); the JWT secret is + dev-only and documented as such. + +## Consequences + +- **Reproducible & version-robust:** the API-driven seed doesn't depend on fixture PKs or + a catalogi `setup_configuration` step that may change between versions. +- **Teaches the pattern:** the seed talks to OpenZaak exactly the way the ACL will later — + through the documented ZGW API, with a JWT (ADR-0001). +- The seed is a script, but a **data loader is explicitly anticipated** (PRD §8); it lives + under `infra/openzaak/`, not as ad-hoc tooling. +- **Follow-ups:** re-enable notifications when NRC is up (S-01-c); publish the zaaktype (add + the related types) when a slice needs to create real zaken; pin the OpenZaak image tag. + +## Alternatives considered + +- **Fully declarative in `data.yaml`** — rejected: no catalogi/zaaktype step exists. +- **Django `loaddata` fixture** — rejected: brittle, tied to model PKs and the exact image + version; bypasses the API the rest of the system uses. diff --git a/docs/runbooks/openzaak.md b/docs/runbooks/openzaak.md index 7744dc2..c1e7765 100644 --- a/docs/runbooks/openzaak.md +++ b/docs/runbooks/openzaak.md @@ -9,9 +9,32 @@ migrations, the OpenZaak API, and a celery worker. ```bash make openzaak-up # start the stack (first run pulls images + migrates: 1-3 min) make openzaak-smoke # start + assert it's up with auth enforced (403/302/200) +make openzaak-seed # start + seed the BIG catalogus (idempotent) make openzaak-down # stop and wipe data ``` +## Seed the BIG catalogus + +`make openzaak-seed` brings the stack up and runs `infra/openzaak/seed_catalogus.py`, +which creates (idempotently, via the ZTC API): + +- catalogus **BIG** +- a lean **BIG-REGISTRATIE** zaaktype (concept; only schema-mandatory fields) +- a **bsn** eigenschap on it + +then confirms the JWT client can list it. See **ADR-0002** for the design (why the +zaaktype stays a concept, why notifications are disabled, why the API not a fixture). + +**JWT client** (provisioned declaratively by `setup_configuration/data.yaml`, **dev only**): + +| | | +|---|---| +| client_id | `big-reference-seed` | +| secret | `insecure-dev-secret-change-me` | +| authorizations | `heeft_alle_autorisaties` (all) | + +The seed mints a ZGW JWT (HS256) from these and calls `/catalogi/api/v1/...`. + `make openzaak-smoke` polls until the API responds, then asserts: | Check | Expected | @@ -43,8 +66,9 @@ The Makefile auto-points `DOCKER_HOST` at the Podman socket when it exists, so t - **Not in `make ci`.** The OpenZaak smoke is a separate, heavier check (large image pull + migrations); it is intentionally kept out of `make ci` so the core gate stays fast. Run `make openzaak-smoke` when you touch the OpenZaak stack. -- **No catalogus/zaaktypen yet.** Seeding the `BIG` catalogus + `BIG-registratie` - zaaktype and a JWT client is the next slice (**S-01-b**); authenticated zaaktype - listing isn't testable until then. +- **Notifications disabled.** `NOTIFICATIONS_DISABLED=true` until Open Notificaties + (NRC) lands in S-01-c — otherwise ZTC writes 500 trying to notify. Re-enable then. +- **Zaaktype is a concept**, not published (publishing needs roltypen/statustypen/ + resultaattypen — beyond the lean seed). List with `?status=alles`. - **Image tag.** Currently `openzaak/open-zaak:latest` via `${OPENZAAK_TAG}`; pin to - a known-good tag as part of the catalogus-design ADR. + a known-good tag (ADR-0002 follow-up). diff --git a/infra/openzaak/docker-compose.yml b/infra/openzaak/docker-compose.yml index a620416..6d2a00e 100644 --- a/infra/openzaak/docker-compose.yml +++ b/infra/openzaak/docker-compose.yml @@ -44,11 +44,18 @@ services: CELERY_BROKER_URL: redis://oz-redis:6379/1 CELERY_RESULT_BACKEND: redis://oz-redis:6379/1 DISABLE_2FA: "true" + # Notifications go to Open Notificaties (NRC), which arrives in S-01-c. + # Until then, disable outbound notifications so writes don't 500. + NOTIFICATIONS_DISABLED: "true" OPENZAAK_SUPERUSER_USERNAME: admin DJANGO_SUPERUSER_PASSWORD: admin OPENZAAK_SUPERUSER_EMAIL: admin@localhost - RUN_SETUP_CONFIG: "false" + RUN_SETUP_CONFIG: "true" command: /setup_configuration.sh + volumes: + # :z relabels for SELinux; the dir/file must be world-readable for the + # container user (rootless Podman uid mapping). See docs/runbooks/openzaak.md. + - ./setup_configuration:/app/setup_configuration:ro,z depends_on: oz-db: condition: service_healthy diff --git a/infra/openzaak/seed_catalogus.py b/infra/openzaak/seed_catalogus.py new file mode 100644 index 0000000..1c913c0 --- /dev/null +++ b/infra/openzaak/seed_catalogus.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Idempotent seed of the BIG catalogus into OpenZaak via the ZTC API. + +Creates (if absent): + - catalogus "BIG" + - a lean "BIG-registratie" zaaktype (only schema-mandatory fields) + - a "bsn" eigenschap on that zaaktype + - then publishes the zaaktype. + +Auth uses the JWT client provisioned by setup_configuration (see ADR-0002). +Stdlib only — no pip deps. Re-running is safe (matches existing by identifier). +""" +import base64, hashlib, hmac, json, os, sys, time, urllib.error, urllib.request + +BASE = os.environ.get("OZ_BASE", "http://localhost:8000") +CLIENT_ID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed") +SECRET = os.environ.get("OZ_SECRET", "insecure-dev-secret-change-me") +ZTC = f"{BASE}/catalogi/api/v1" +RSIN = "517439943" # elfproef-valid test RSIN + + +def token(): + b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=") + hdr = {"alg": "HS256", "typ": "JWT"} + pl = {"iss": CLIENT_ID, "iat": int(time.time()), "client_id": CLIENT_ID, + "user_id": "seed", "user_representation": "seed"} + seg = b64(json.dumps(hdr, separators=(",", ":")).encode()) + b"." + \ + b64(json.dumps(pl, separators=(",", ":")).encode()) + sig = b64(hmac.new(SECRET.encode(), seg, hashlib.sha256).digest()) + return (seg + b"." + sig).decode() + + +def api(method, path, body=None): + url = path if path.startswith("http") else f"{ZTC}{path}" + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, method=method, headers={ + "Authorization": "Bearer " + token(), + "Content-Type": "application/json", + "Accept": "application/json", + }) + try: + with urllib.request.urlopen(req, timeout=30) as r: + return r.status, json.loads(r.read() or "null") + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read() or "null") + + +def find(path): + status, body = api("GET", path) + if status != 200: + sys.exit(f"GET {path} -> {status}: {body}") + return body.get("results", []) + + +def main(): + # 1. Catalogus + existing = [c for c in find(f"/catalogussen?domein=BIG") if c.get("domein") == "BIG"] + if existing: + cat = existing[0] + print(f"skip catalogus BIG ({cat['url']})") + else: + st, cat = api("POST", "/catalogussen", { + "domein": "BIG", "rsin": RSIN, + "contactpersoonBeheerNaam": "BIG Beheer", + }) + if st != 201: + sys.exit(f"create catalogus -> {st}: {cat}") + print(f"create catalogus BIG ({cat['url']})") + + # 2. Zaaktype (concept) + # status=alles so concept zaaktypen are matched too (else we'd duplicate). + zts = [z for z in find(f"/zaaktypen?catalogus={cat['url']}&status=alles") + if z.get("identificatie") == "BIG-REGISTRATIE"] + if zts: + zt = zts[0] + print(f"skip zaaktype BIG-REGISTRATIE ({zt['url']}) concept={zt.get('concept')}") + else: + st, zt = api("POST", "/zaaktypen", { + "identificatie": "BIG-REGISTRATIE", + "omschrijving": "BIG-registratie", + "vertrouwelijkheidaanduiding": "openbaar", + "doel": "Registratie van een zorgprofessional in het BIG-register", + "aanleiding": "Aanvraag tot registratie", + "indicatieInternOfExtern": "extern", + "handelingInitiator": "indienen", + "onderwerp": "BIG-registratie", + "handelingBehandelaar": "behandelen", + "doorlooptijd": "P30D", + "opschortingEnAanhoudingMogelijk": False, + "verlengingMogelijk": False, + "publicatieIndicatie": False, + "productenOfDiensten": [], + "referentieproces": {"naam": "BIG-registratie"}, + "catalogus": cat["url"], + "besluittypen": [], + "gerelateerdeZaaktypen": [], + "beginGeldigheid": "2026-01-01", + "versiedatum": "2026-01-01", + "verantwoordelijke": RSIN, + }) + if st != 201: + sys.exit(f"create zaaktype -> {st}: {json.dumps(zt, indent=2)}") + print(f"create zaaktype BIG-REGISTRATIE ({zt['url']})") + + # 3. bsn eigenschap (only addable while concept) + eigs = [e for e in find(f"/eigenschappen?zaaktype={zt['url']}&status=alles") + if e.get("naam") == "bsn"] + if eigs: + print("skip eigenschap bsn") + elif zt.get("concept", True): + st, eig = api("POST", "/eigenschappen", { + "naam": "bsn", + "definitie": "Burgerservicenummer van de zorgprofessional", + "zaaktype": zt["url"], + "specificatie": {"groep": "aanvrager", "formaat": "tekst", + "lengte": "9", "kardinaliteit": "1", "waardenverzameling": []}, + }) + if st != 201: + sys.exit(f"create eigenschap -> {st}: {json.dumps(eig, indent=2)}") + print("create eigenschap bsn") + else: + print("warn zaaktype already published; cannot add bsn eigenschap") + + # Intentionally NOT published. Publishing requires roltypen, resultaattypen + # and statustypen, which go beyond the "lean / schema-mandatory" zaaktype this + # slice asks for; they arrive with the workflow/zaak slices. See ADR-0002. + + # 4. Verify the JWT client can list the zaaktype (concepts included). + zaaktypen = find(f"/zaaktypen?catalogus={cat['url']}&status=alles") + names = [z.get("identificatie") for z in zaaktypen] + print(f"zaaktypen in BIG: {names}") + assert "BIG-REGISTRATIE" in names, "BIG-REGISTRATIE not listed" + print("OK — BIG catalogus seeded (BIG-REGISTRATIE concept + bsn eigenschap)") + + +if __name__ == "__main__": + main() diff --git a/infra/openzaak/setup_configuration/data.yaml b/infra/openzaak/setup_configuration/data.yaml new file mode 100644 index 0000000..6040ea9 --- /dev/null +++ b/infra/openzaak/setup_configuration/data.yaml @@ -0,0 +1,22 @@ +# OpenZaak setup_configuration (idempotent, declarative). +# Provisions the JWT client the seed + ACL use to call OpenZaak's APIs. +# Dev-only credentials — not for production. +# +# Steps come from vng_api_common.contrib.setup_configuration (see ADR-0002). + +vng_api_common_credentials_config_enable: true +vng_api_common_credentials: + items: + - identifier: big-reference-seed + secret: insecure-dev-secret-change-me + +vng_api_common_applicaties_config_enable: true +vng_api_common_applicaties: + items: + # uuid must be given explicitly as a string (the step's auto-default is a + # UUID object that fails its own validation). + - uuid: "11111111-1111-4111-8111-111111111111" + client_ids: + - big-reference-seed + label: BIG reference seed client + heeft_alle_autorisaties: true