Compare commits

..
Author SHA1 Message Date
not 2d783448b7 fix(e2e): assert the register record where a real approval happens (refs #149)
CI / build (pull_request) Successful in 1m6s
CI / lint (pull_request) Successful in 1m19s
CI / unit (pull_request) Successful in 1m24s
CI / verify-stack (pull_request) Successful in 7m49s
CI / frontend (pull_request) Successful in 2m57s
CI / mutation (pull_request) Successful in 6m7s
verify-domain was the wrong home for the assertion, and CI was right to fail it.
That check completes the Beoordelen task straight through Flowable REST — on
purpose, it exists to exercise the Workflow Client's REST contract — which
bypasses the domain `decide` path that calls the ACL. No approval reached the
ACL there, so no record was ever written.

The Playwright happy path is the only check that drives a real approval
(behandel portal → BFF → domain → ACL), and it already knows its own reference.
Assert there instead: exactly one RegisterRecord for that reference,
INGESCHREVEN, carrying nothing outside the public-safe schema. Drops
register-record-check.py and the verify-domain block.

The helper was run under real Playwright against a live Objecten before
committing — one record found, none for an unknown reference.
2026-08-14 10:43:34 +02:00
not 10b784cc05 fix(infra): reach Objecten by service name in the register-record check (refs #149)
CI / build (pull_request) Successful in 1m8s
CI / lint (pull_request) Successful in 1m23s
CI / unit (pull_request) Successful in 1m23s
CI / frontend (pull_request) Successful in 2m59s
CI / mutation (pull_request) Successful in 6m8s
CI / verify-stack (pull_request) Failing after 7m24s
CI caught my own check falling into the constraint ADR-0028 documents: it looked
Objecttypen up by container IP, so the objecttype URL came back IP-addressed and
Objecten rejected it as "not one of the available choices". Reach both by
service name — compose DNS resolves them, and neither request has OpenZaak's
URL-validity constraint that made IPs necessary elsewhere in this script.

The 400 also spent the full 60s timeout disguised as "transport:" because
HTTPError is a URLError subclass. Handle it separately: a 4xx now fails
immediately with the response body, which is where the real reason was.

Verified both ways against a live Objecten: absent record → exit 1 with the
reason, present record → exit 0.
2026-08-14 10:20:26 +02:00
not 2bb7d9c165 test(acl): ObjectenGateway integration test against live Objecten (refs #149)
CI / build (pull_request) Successful in 4m20s
CI / lint (pull_request) Successful in 4m38s
CI / unit (pull_request) Successful in 1m27s
CI / frontend (pull_request) Successful in 4m1s
CI / mutation (pull_request) Successful in 6m52s
CI / verify-stack (pull_request) Failing after 18m4s
Drives the real gateway against a running Objecten + Objecttypen pair: two
writes for the same id leave exactly one object carrying the second write's
status, and nothing outside the public-safe schema. Runs under verify-acl,
inside the compose network — which it must, because Objecttypen echoes the
request Host into the objecttype `url` and Objecten only accepts the one
matching its configured api_root. ADR-0028 records that constraint.
2026-08-14 09:44:15 +02:00
not 4a047c618c fix(infra): let Objecten actually accept the register record (refs #149)
Replaying the gateway's calls against a live Objecten + Objecttypen pair turned
up two blockers CI would only have found after the fact:

- Objecten rejects an objecttype it has not been configured with, and it
  identifies one by uuid — assigned at seed time by a one-shot that runs after
  Objecten's static setup_configuration. Pin the uuid on both sides instead.
- Objecten notifies on every write and notifications_api_common *raises* when
  that config is absent, so every POST 500'd after rolling the object back.
  Objecten → NRC has no broker, worker, kanaal or abonnement yet, so disable
  notifications rather than wire a client that drops every message; S-19b turns
  them on for real.

With both in place the full exchange verifies end to end: lookup → version →
search → create → update (still one object), and a record carrying a bsn is
rejected by the schema. ADR-0028 records both.
2026-08-14 09:41:00 +02:00
not 43b45ad756 fix(acl): read each objecttype version instead of the versions collection (refs #149)
The version resolve assumed `GET {objecttype}/versions` returns a bare list.
Every other collection in the Objecttypen API returns a paginated envelope, and
nothing in the repo exercises that endpoint, so the shape was a guess. Follow
the path infra/registerrecord-check.py already proves against the real API
instead: read the `versions` URLs off the objecttype and fetch each for its
status. Costs a request per version, once per gateway instance.

ACL mutation score 92.23% (baseline 91.37%).
2026-08-14 09:33:15 +02:00
not 5502e4c099 test(acl): raise the Objecten gateway above the mutation ratchet (refs #149)
The new gateway landed at 77.6%, dragging the ACL score under its 90 break
threshold. The gaps were all real behaviour nobody was asserting: a failed or
empty read being mistaken for "nothing there yet" and followed by a blind
write, a `results`-less response taking down the resolve with an
ArgumentNullException, the CRS headers going to Objecttypen (which is not a geo
API), and the write body being sent chunked. ACL score 86.63% → 92.08%.
2026-08-14 09:29:03 +02:00
not 3705a18e18 docs: ADR-0028 + demo note — Objecten holds the register (refs #149)
ADR-0028 records why the register record lives in Objecten rather than as zaak
eigenschappen, why the ACL owns the hop, and how two non-atomic writes are made
to converge instead. Also retires the PRD §15 out-of-scope line the slice
supersedes.
2026-08-14 09:23:41 +02:00
not 400bdcafc4 test(infra): assert the approval wrote the register record to Objecten (refs #149)
verify-domain already drives a full approval; it now also asserts Objecten holds
exactly one RegisterRecord for that registration — matched on its own reference,
because the shared verify stack carries records from earlier runs. The check
covers the three things that can silently go wrong: the record is missing (the
ACL's Objecten hop never ran), duplicated (the upsert is not idempotent), or
carries a field outside the public-safe schema.
2026-08-14 09:22:09 +02:00
not c67ee7d3f5 refactor(acl): resolve the objecttype's highest published version (refs #149)
Counting the `versions` URLs assumed a contiguous, all-published list. Read the
objecttype's versions collection instead and take the highest one whose status
is `published`, so a draft version — whose schema is still being shaped — is
never written against.
2026-08-14 09:19:50 +02:00
not d14f379358 feat(acl): write the RegisterRecord to Objecten on approval (refs #149)
ApproveZaakAsync now does two writes: the ZGW eindstatus (the process) and the
register record in Objecten (the register). The record is keyed on the zaak
UUID — the same key the read projection rows carry — and its reference is the
zaak's identificatie, so nothing personal crosses into the world-readable
register (ADR-0027).

ObjectenGateway resolves the objecttype by name (its URL and version are
assigned at seed time, as with ADR-0021), searches for an existing object by
data attribute, then POSTs or PATCHes. Resolution is lazy, so the ACL needs no
depends_on on Objecten and does not crash-loop when it boots first.
2026-08-14 09:18:34 +02:00
not 66f8322580 test(acl): approval writes the RegisterRecord to Objecten (refs #149)
Ports and failing tests for the Objecten hop, ahead of the implementation:

- IRegisterRecordGateway + RegisterRecord — the Application-side port; the
  record mirrors the objecttype schema registered in S-18c (ADR-0027).
- AclService takes the port but does not yet call it, so the approval test
  fails on an empty upsert list.
- ObjectenGateway is a shell throwing NotImplementedException; its tests pin
  the contract: resolve the objecttype by name, search by data attribute,
  POST when absent / PATCH when present, static Token auth per API, the CRS
  headers the geo API requires, and a surfaced error body.

Also splits S-19 (#20) into #149/#150 in BACKLOG.md — the approval-side write
and the projection re-sourcing are independently deployable (CLAUDE.md §13).
2026-08-14 09:16:23 +02:00
not d37d4c96c6 S-18c · RegisterRecord objecttype defined + registered (closes #141) (#146)
CI / build (push) Successful in 1m29s
CI / lint (push) Successful in 1m37s
CI / unit (push) Successful in 2m13s
CI / frontend (push) Successful in 4m31s
CI / mutation (push) Successful in 7m0s
CI / verify-stack (push) Successful in 8m52s
## What & why

S-18c, the **final** slice of the S-18 (#19) split (after S-18a #142, S-18b #143). Defines the **RegisterRecord** objecttype — the schema S-19 (#20) will write canonical register records against on approval — and registers it in the Objecttypen API at startup.

Closes #141

### What

- **Schema** (`infra/objecttypen-registerrecord/registerrecord.schema.json`): public-safe by construction — `id`, `status` (enum `INGEDIEND`/`INGESCHREVEN`), `reference` only, `additionalProperties: false`, `dataClassification: open`. Mirrors the BFF's `OpenbaarEntry` — **no `bsn`/`naam`** (ADR-0027).
- **Registration**: a `registerrecord-init` compose one-shot (stdlib Python on the stack network) POSTs the objecttype + a **published** version over the API once Objecttypen is healthy. The Objecttypen `setup_configuration` (3.4.2) only provisions tokens — no declarative objecttype step — so this follows the ADR-0020 self-seed pattern. **Idempotent**: if a `RegisterRecord` with a version already exists it is a no-op.
- **Wiring**: schema + `register.py` streamed into the external `rr-registerrecord-config` volume by `seed-config.sh registerrecord` (main) / bind-mounted (local); added to `SEED`, `CFG_VOLS`, and the CI log-dump. `registerrecord-init` is a one-shot (not in `WAIT_SVCS`).
- **Smoke**: `verify-registerrecord` (`run-registerrecord-check.sh` + `registerrecord-check.py`) asserts the objecttype exists, has a **published** version, and that version's schema carries `id`/`status`/`reference`; added as a verify-stack step + a row in the #136 summary.
- **ADR-0027**: records the public-safe schema decision (mirror the BFF public view, not the internal projection; API-seeded one-shot). The slice issue #141 flagged the schema as ADR-worthy, so no separate adr-proposal issue was opened.

## Verified locally (end to end, real compose)

Seeded `rr-registerrecord-config`, brought Objecttypen up, ran `registerrecord-init` → `registered RegisterRecord <uuid> v1 (published)`. `make verify-registerrecord` → **OK — RegisterRecord v1 published, fields=['id', 'reference', 'status']**. Re-running the one-shot → **no-op** (idempotent). `docker compose config` clean on both files; schema + script + ci.yaml validated.

## Definition of Done

- [x] Failing smoke committed first (`test(infra): …`, "no objecttype named RegisterRecord"); implementation makes it pass.
- [x] Conventional Commits referencing #141.
- [x] CI green (verify-stack registerrecord step — validated locally; runner already unstarved by #145).
- [x] `docker compose up` reaches health (one-shot registers after Objecttypen healthy).
- [x] Docs: ADR-0027 + demo note.
- [x] Closed by the merging PR (`closes #141`).

This closes out the S-18 (#19) split — Objecttypen (S-18a) + Objecten (S-18b) + RegisterRecord (S-18c) are all up. Next: **S-19 (#20)** — ACL writes the register record to Objecten on approval, against this schema.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #146
2026-07-27 15:14:16 +00:00
not 159f014c1e perf: cap OpenZaak + NRC uWSGI workers — shrink verify-stack footprint (closes #147) (#148)
CI / build (push) Successful in 1m50s
CI / lint (push) Successful in 1m54s
CI / unit (push) Successful in 2m14s
CI / frontend (push) Successful in 4m45s
CI / mutation (push) Successful in 10m9s
CI / verify-stack (push) Failing after 11m19s
## What & why

Closes #147. Follow-up to #144/#145. As the stack grew to **37 services** on one runner, `verify-stack` is under memory pressure. #145 capped Objecten/Objecttypen; this caps the two biggest remaining uncapped Django apps.

**OpenZaak** and **NRC** (`nrc-web`) are Maykin/vng uWSGI images running the image default of **4 processes × 4 threads** — ~4 full-Django worker processes (~800 MB) each, idle, serving only single-request smoke checks.

### What

- `UWSGI_PROCESSES: "1"` + `UWSGI_THREADS: "2"` on the `&oz-env` and `&nrc-env` anchors, in both compose files. Frees ~1.2 GB. The anchors are shared with the `-init` (setup_configuration) and `-celery` containers, which ignore the var — they don't run uwsgi.

### Not included (considered, deferred to #147 notes)

JVM heap caps on Keycloak/Flowable; compose profiles to boot per-check subsets.

## Verified locally

OpenZaak brought up healthy with the cap; uwsgi processes **6 → 3** (master + http-router + 1 worker); `/admin/` still 302. `docker compose config` clean on both files. (Full NRC bring-up needs OpenZaak + the seed chain — same image family/lever, validated via OpenZaak.)

## Definition of Done

- [x] Linked issue (#147).
- [x] Conventional Commit referencing #147.
- [x] Verified locally (OpenZaak healthy + worker count dropped + still serving).
- [x] Closed by the merging PR (`closes #147`).

No ADR: config-only tuning of existing services, same class as #145.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #148
2026-07-27 14:32:13 +00:00
not dd54688f86 fix: cap Objecten/Objecttypen uWSGI to 1 worker — unstarve verify-stack e2e (closes #144) (#145)
CI / build (push) Successful in 1m32s
CI / lint (push) Successful in 1m41s
CI / unit (push) Successful in 2m21s
CI / frontend (push) Successful in 4m56s
CI / mutation (push) Successful in 7m54s
CI / verify-stack (push) Failing after 16m47s
## What & why

**P0 — red `main`.** Fixes #144: `verify-stack` fails on the Playwright e2e step (main runs 2177 after #142, 2190 after #143), while the PR runs passed.

Closes #144

### Root cause

The Maykin **Objecttypen** (S-18a) and **Objecten** (S-18b) images run their `web` under uWSGI with **4 processes × 4 threads by default** (`UWSGI_PROCESSES:-4`). Two web services × 4 idle Django workers (~200 MB each) sat idle during the e2e step and starved the single shared self-hosted runner — Keycloak and the portals stopped responding (the login `#username` never appeared) and Chromium hit `Target crashed`. The runner margin was already thin; the second chain tipped it over (main green through run 2159, red from 2177).

### Fix

Cap `UWSGI_PROCESSES: "1"` + `UWSGI_THREADS: "2"` on both `objecten` and `objecttypen` in both compose files. These APIs only serve single-request smoke checks and are idle during e2e, so 1 worker is plenty — it frees ~1–1.5 GB. The `-init` containers ignore it (they run `setup_configuration`, not uwsgi).

## Verified locally

Brought the objecten chain up with the cap: both services reach healthy, worker count drops from 6 (master + http + 4 workers) to 3 (master + http + 1 worker) per service, and `make verify-objecten` / `make verify-objecttypen` both still → **OK — no-auth 401, token 200**. `docker compose config` clean on both files.

## Definition of Done

- [x] Linked issue (#144).
- [x] Conventional Commit referencing #144.
- [x] Verified locally (both APIs healthy + smoke green with 1 worker).
- [x] Closed by the merging PR (`closes #144`).

No ADR: config-only tuning of existing services — no boundary, dependency, or coupling change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #145
2026-07-27 13:07:55 +00:00
not 0a97fa4bf7 S-18b · Objecten API up in compose, wired to Objecttypen (closes #140) (#143)
CI / build (push) Successful in 1m29s
CI / lint (push) Successful in 1m45s
CI / unit (push) Successful in 2m6s
CI / frontend (push) Successful in 4m21s
CI / mutation (push) Successful in 7m0s
CI / verify-stack (push) Failing after 30m21s
## What & why

S-18b, second of the S-18 (#19) split (after S-18a #139/#142). Stands up the upstream Maykin **Objecten API** in the compose stack and wires it to the Objecttypen API — the authoritative object store the ACL will write register records to (S-19).

Closes #140

### What

- **Compose** (main + local): `objecten-db` (**PostGIS** — objects carry geometry), `objecten-redis`, `objecten-init` (RUN_SETUP_CONFIG → migrate + provision token + register the Objecttypen service), `objecten` web (health on `/admin/`, host `:8021`). Verbatim upstream image `maykinmedia/objects-api` pinned to `3.4.0` (nearest release to objecttypes-api `3.4.2`; the two speak over the stable Objecttypes API v2).
- **Seed**: `infra/seed-config.sh objecten` streams `infra/objecten/setup_configuration/data.yaml` into the external `rr-objecten-config` volume — same pattern as S-18a. The data.yaml (1) registers **Objecttypen** as a trusted `zgw_consumers` service (`api_type: orc`, api-key auth with the S-18a dev token) so an object can reference its objecttype, and (2) provisions a dev **static API token** so peers (the ACL, S-19) can write objects.
- **Wiring**: added to `WAIT_SVCS`, `CFG_VOLS`, the `SEED` invocations, `seed-config.sh`, and the CI log-dump. `objecten-init` waits on `objecttypen` being healthy so the service registration is meaningful end to end.
- **Smoke**: `verify-objecten` (`infra/run-objecten-check.sh` + `objecten-check.py`) asserts unauth → 401, token → 200 on `/api/v2/objects`; added as a verify-stack step + a row in the #136 check-summary table.

## Verified locally (end to end, real compose)

Seeded + brought up the real `infra/docker-compose.yml` objecten chain (pulls in objecttypen via `depends_on`): `objecten-init` ran setup_configuration — `token_configuration_success` **and** "Successfully executed step: Configuration to connect with external services" — the web reached healthy, and `make verify-objecten` → **"OK — no-auth 401, token 200"**. Confirmed the registered service via the Objecten django shell:

```
objecttypen | orc | http://objecttypen:8000/api/v2/ | api_key
```

YAML (both compose files + ci.yaml) + shell + python all validated; `docker compose config` clean on both files.

## Definition of Done

- [x] Failing smoke committed first (`test(infra): …`, "no running objecten container"); implementation makes it pass.
- [x] Conventional Commits referencing #140.
- [ ] CI green (verify-stack objecten step).
- [x] `docker compose up` reaches health (objecten healthy on first poll locally).
- [x] Demo note in `docs/demo-script.md`.
- [x] Closed by the merging PR (`closes #140`).

No new ADR: follows the established verbatim-image + seed-config CG-module pattern (S-18a/ADR-0023-era).

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #143
2026-07-27 09:53:01 +00:00
not 23ea91de32 feat(infra): Objecttypen API up in compose with a seeded static token (closes #139) (#142)
CI / build (push) Successful in 1m17s
CI / lint (push) Successful in 1m29s
CI / unit (push) Successful in 1m34s
CI / frontend (push) Successful in 3m24s
CI / mutation (push) Successful in 6m33s
CI / verify-stack (push) Failing after 12m2s
## What & why

S-18a, first of the S-18 (#19) split. Stands up the upstream Maykin **Objecttypen API** in the compose stack — the objecttype catalogue the register record (S-18b/S-18c, S-19) will build on.

Closes #139

### What

- **Compose** (main + local): `objecttypen-db` (Postgres), `objecttypen-redis`, `objecttypen-init` (RUN_SETUP_CONFIG → migrate + provision token), `objecttypen` web (health on `/admin/`, host `:8020`). Verbatim upstream image `maykinmedia/objecttypes-api` pinned to `3.4.2`.
- **Seed**: `infra/seed-config.sh objecttypen` streams `infra/objecttypen/setup_configuration/data.yaml` into the external `rr-objecttypen-config` volume — same pattern as OpenZaak/NRC. The data.yaml provisions a dev **static API token** (`tokenauth` setup_configuration step) so peers (Objecten, ACL) can authenticate.
- **Wiring**: added to `WAIT_SVCS`, `CFG_VOLS`, the `SEED` invocations, and the CI log-dump.
- **Smoke**: `verify-objecttypen` (`infra/run-objecttypen-check.sh` + `objecttypen-check.py`) asserts unauth → 401, token → 200; added as a verify-stack step + a row in the #136 check-summary table.

### Split note

#19 was oversized (two CG modules + config + objecttype) → split (§13) into **S-18a** (this), **S-18b** (#140, Objecten wired to Objecttypen), **S-18c** (#141, RegisterRecord objecttype).

## Verified locally (end to end, real compose)

Seeded + brought up the real `infra/docker-compose.yml` objecttypen chain: `objecttypen-init` ran setup_configuration (`token_configuration_success`), the web reached healthy, and `make verify-objecttypen` → **"OK — no-auth 401, token 200"**. YAML (both compose files + ci.yaml) + shell + python all validated.

## Definition of Done

- [x] Smoke check validates the outcome (live, against the running stack).
- [x] Conventional Commits referencing #139.
- [ ] CI green — see note.
- [x] `docker compose up` reaches health (objecttypen healthy on first poll locally).
- [x] Demo note in `docs/demo-script.md`.

## Note on CI

Additive (a new service + its own smoke step). The fast jobs are unaffected. The **verify-stack** job still can't go green until the pre-existing 1.27/act_runner-2.0.0 bring-up P0 is resolved (fails on plain `main` too) — but the objecttypen bring-up itself is validated locally above. No new ADR: this follows the established verbatim-image + seed-config CG-module pattern (ADR-0023-era).

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #142
2026-07-27 08:51:14 +00:00
not 0494730223 feat(portal-beheer): ACL default-fill configuration editor (closes #131) (#138)
CI / build (push) Successful in 1m34s
CI / lint (push) Successful in 1m36s
CI / unit (push) Successful in 1m45s
CI / frontend (push) Successful in 3m37s
CI / mutation (push) Successful in 6m25s
CI / verify-stack (push) Successful in 7m56s
## What & why

S-15b, second of the S-15 (#16) split, on top of S-15a (#133). A beheerder edits the ACL's ZGW **default-fill** values from the beheer portal, and the next zaak is stamped with the new values — no restart.

Closes #131

### The vertical

portal → BFF `GET/PUT /beheer/default-fill` (medewerker realm + `beheerder` role) → ACL `GET/PUT /default-fill` → a runtime-mutable in-memory store the ACL reads **per zaak**.

- **ACL**: `IDefaultFillStore` / `InMemoryDefaultFillStore` (thread-safe, seeded from `Acl:Defaults`); `AclService` reads `fill.Current` per zaak (not cached at construction); `GET`/`PUT /default-fill` with required-field validation.
- **BFF**: `IAclClient` gains `GetDefaultFillAsync`/`UpdateDefaultFillAsync`; `GET`/`PUT /beheer/default-fill` behind the `beheerder` policy. OpenAPI + generated client regenerated.
- **Frontend**: a *Default-fill* editor page in the beheer app (load → edit → save, with saved/failure states) + nav between Catalogus and Default-fill.

### Scope decision → ADR-0026

Only the **three ZGW fill fields** (bronorganisatie, verantwoordelijke organisatie, vertrouwelijkheidaanduiding) are editable. The S-27 catalog-resolution keys stay **static config** — editing them would desync the zaaktype-URL cache (ADR-0021), and they're catalogus wiring, not "default fill". The store is **in-memory** (seeded from config): an edit reverts on restart. That's the reference-app-appropriate ceiling (no DB added to the stateless ACL); upgrade path documented. Recorded in **ADR-0026**.

## Verified locally

lint (`dotnet format`) ✓ · .NET unit — acl 60 / bff 45 / domain 152 / event-subscriber 19 / acceptance 17 ✓ · frontend lint+test (8 projects) ✓ · beheer build ✓. Clean full-solution build (caught + fixed the acceptance `AclService` ctor drift). TDD red→green per layer (ACL store, ACL endpoints, BFF, frontend).

## Definition of Done

- [x] Failing test committed before each implementation (red→green per layer).
- [x] Conventional Commits referencing #131.
- [ ] CI green — see note below.
- [x] Docs: ADR-0026 + S-15b demo note.
- [x] Demo note in `docs/demo-script.md`.

## Note on CI

The bulk validates in the fast jobs (lint/build/unit/frontend/mutation). The **verify-stack e2e** (incl. the new `default-fill.spec.ts`) can't go green until the pre-existing **verify-stack bring-up failure on the 1.27/2.0.0 runner** is resolved (that fails on plain `main` too — unrelated to this PR). Additive change; no existing e2e touched.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #138
2026-07-24 14:22:22 +00:00
not fff88ca23d ci: richer step reports via Gitea 1.27 job summaries (closes #136) (#137)
CI / build (push) Successful in 1m16s
CI / lint (push) Successful in 1m29s
CI / unit (push) Successful in 1m44s
CI / frontend (push) Successful in 3m47s
CI / mutation (push) Successful in 6m40s
CI / verify-stack (push) Successful in 8m15s
## What & why

Use the standard `$GITHUB_STEP_SUMMARY` (Gitea 1.27 + act_runner 2.0.0) to surface on the run page what was previously buried in logs or download-only artifacts. All five quick wins from #136, **reporting-only** — no job's pass/fail gating changes.

Closes #136

### Items

1. **Mutation scores** — added the `markdown` reporter to each `stryker-config.json`; the `mutation` job concatenates each service's `mutation-report.md` into the summary (`if: always()`). Also reveals where `make mutation` stopped on a ratchet break.
2. **Per-frontend tests** — the 4 apps' `test` targets emit vitest JSON to `test-output/{projectName}.json` (Nx token interpolation); `infra/vitest-summary.py` renders a per-frontend table.
3. **Per-service unit tests** — `make unit` now writes TRX; `infra/trx-summary.py` renders a per-service table (service name derived from the `services/<name>/` path, so `domain` shows, not `big.tests`).
4. **e2e per-spec results** — Playwright writes `playwright-report.json`; `run-e2e-check.sh` copies it out of the container (capturing the exit code first); `infra/playwright-summary.py` renders a per-spec table. Turns a red e2e into a one-glance "which spec".
5. **verify-stack check table** — each live-stack check has an `id`; a final `if: always()` step tabulates each check's //⏭️.

Docs: `gitea-actions-gotchas.md` §8 (version requirement + `$GITHUB_STEP_SUMMARY` guard + step-level `always()` note).

### Notes

- Every summary write is guarded with `[ -n "${GITHUB_STEP_SUMMARY:-}" ]`, so it no-ops on an unsupported runner / locally.
- New helper scripts are stdlib-only Python, matching the existing `infra/*.py` check scripts (no new dependency — a few lines of parsing rather than a test-logger package).
- `TestResults/` and `test-output/` gitignored.
- This is also the first PR-run exercising the #135 verify-stack fix end to end.

## Verified locally

`make unit` (TRX) ✓ · 4 apps' vitest JSON ✓ · ACL Stryker markdown report ✓ · all four parsers + the two summary shell blocks ✓ · `ci.yaml` + `run-e2e-check.sh` syntax ✓. The rendered summaries themselves only appear on the run page — this PR's CI run is the end-to-end check.

## Definition of Done

- [x] Each item writes to `$GITHUB_STEP_SUMMARY` (guarded), renders on the run page.
- [x] No change to any job's pass/fail gating.
- [x] Conventional Commits referencing #136 (one per item + docs).
- [ ] CI green; summaries visible on the run.
- [x] Runbook note (gotchas §8).

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #137
2026-07-24 13:27:53 +00:00
not 849bf4723b ci: unstick verify-stack on Gitea 1.27 + act_runner 2.0.0 (closes #134) (#135)
CI / lint (push) Successful in 1m34s
CI / build (push) Successful in 1m30s
CI / unit (push) Successful in 1m47s
CI / frontend (push) Successful in 3m36s
CI / mutation (push) Successful in 7m27s
CI / verify-stack (push) Canceled after 0s
## What & why

After the Gitea 1.27 + act_runner 2.0.0 upgrade, `verify-stack` never starts: the run sits in `waiting` forever with no logs for that job, while the other five jobs pass — so `main` stays pending/red (P0). See #134.

Closes #134

### Root cause

`verify-stack` was the only job gated by a status-function `if` on top of `needs`:

```yaml
verify-stack:
  needs: [mutation]
  if: ${{ !cancelled() }}
```

Gitea 1.27 reworked cancellation/aggregation so that `always()`/`cancelled()`-gated `needs` jobs route through a new transitional **`Cancelling`** state + server↔runner **capability negotiation** ("Requires Gitea Runner 2.0.0"). On this 1.27 + 2.0.0 pairing that handshake doesn't resolve, so the job is never dispatched and never leaves `waiting`. Plain jobs (no `if`/`needs`) are unaffected — exactly the observed pattern. It worked pre-upgrade (old runner).

### Fix

Drop the `if: ${{ !cancelled() }}`; keep `needs: [mutation]`. Default `if: success()` dispatches normally and still serialises the two memory-heavy jobs (OOM avoidance, #126).

**Trade-off:** the `!cancelled()` (added in #127) let verify-stack run even when the mutation ratchet fails. Now a failing mutation skips verify-stack; the fix-and-re-push re-run exercises it, so the signal isn't lost — just deferred to the green-mutation run. If we later want both signals on one run, serialise via a `concurrency` group rather than `needs` + `always()`.

Documented as §7 in `docs/runbooks/gitea-actions-gotchas.md`.

## Note on the stuck run

Run 582 (the #133 merge) will **not** clear itself and must be force-cancelled from the Actions UI (plain cancel can also stall on this version, gitea#35782). This PR's own run is the first real test of the fix — if `verify-stack` dispatches and runs here, the fix holds.

## Definition of Done

- [x] Linked issue (#134).
- [x] Conventional Commit referencing the issue.
- [ ] CI green — this PR's run is the verification (verify-stack must dispatch).
- [x] Runbook updated (gotchas §7).
- [ ] Closed by the merging PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #135
2026-07-24 11:59:27 +00:00
not 4698c869f3 feat(portal-beheer): beheer portal + read-only catalogus viewer (closes #130) (#133)
CI / build (push) Successful in 4m31s
CI / lint (push) Successful in 4m48s
CI / unit (push) Successful in 1m50s
CI / frontend (push) Successful in 4m44s
CI / mutation (push) Successful in 7m12s
CI / verify-stack (push) Canceled after 0s
## What & why

S-15a, the first of the S-15 (#16) split. A new **beheer** portal (medewerker realm, like behandel) shows the ZTC catalogus — the published zaaktypen — **read-only**. A beheerder logs in and sees the seeded BIG-REGISTRATIE zaaktype.

Closes #130

### The vertical

portal → BFF `GET /beheer/catalogi/zaaktypen` (medewerker realm + `beheerder` role) → ACL `GET /catalogi/zaaktypen` → ZGW Catalogi API.

- **ACL**: new read-only `GET /catalogi/zaaktypen` listing published zaaktypen (reuses the ADR-0021 Catalogi client; public-safe `identificatie`/`omschrijving`).
- **BFF**: new typed `IAclClient` + `Downstream:Acl:BaseUrl`, and `GET /beheer/catalogi/zaaktypen` behind a new `beheerder` policy (reuses the medewerker bearer scheme + realm-role lifting). OpenAPI spec + generated Angular client regenerated.
- **Keycloak**: `beheerder` realm role + `bram-beheerder` test user in the medewerker realm.
- **Frontend**: new `apps/beheer` Angular app (copied from behandel) with a read-only catalogus page; `SECURE_API_ROUTES=['/beheer/']`.
- **Infra**: `beheer` compose service (port 8143), added to `WAIT_SVCS` + CI log-dump; a Playwright e2e (beheerder login → catalogus shows BIG-REGISTRATIE).

### New boundary → ADR-0025

The BFF now reaches the **ACL directly** for the catalogus read — a new service-to-service edge (§14). The catalogus is neither a domain nor a projection concern, and §8.1 means only the ACL may read ZGW; routing through the domain would pollute it with a non-domain passthrough. §8.1/§8.3 stay intact. Recorded in **ADR-0025**.

## Definition of Done

- [x] Failing test committed before each implementation (red→green per layer: ACL, BFF, frontend).
- [x] Conventional Commits referencing #130.
- [ ] CI green — pending Gitea Actions run.
- [x] `docker compose up` brings up `beheer` (health-gated in `WAIT_SVCS`).
- [x] Docs — ADR-0025 + demo-script S-15a note.
- [x] Demo note in `docs/demo-script.md`.

## Verified locally

lint (`dotnet format`) ✓ · .NET unit (Acl 57 / Big 152 / EventSubscriber 19 / Bff 40) ✓ · frontend lint+test (8 projects) ✓ · frontend build (4 apps) ✓. Mutation ratchet: added a gateway unit test for the new `ListZaaktypenAsync` mapping so the ACL score holds. verify-stack (compose smoke + e2e) runs in CI.

## Notes for reviewers

- The BFF drops the ZGW URL from `BeheerZaaktype` (public-safe: identificatie + omschrijving only).
- The catalogus e2e asserts on the stable seeded `BIG-REGISTRATIE` (not a per-test reference), safe on the shared verify stack.
- Follow-ups: **S-15b** (#131) default-fill CRUD, **S-15c** (#132) medewerker-realm MFA.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #133
2026-07-24 11:08:17 +00:00
not d5dfbdc0b2 feat(obs): Prometheus metrics on /metrics + golden-signal Grafana dashboard (closes #124) (#129)
CI / build (push) Successful in 1m47s
CI / lint (push) Successful in 1m59s
CI / unit (push) Successful in 1m57s
CI / frontend (push) Successful in 3m58s
CI / mutation (push) Successful in 6m57s
CI / verify-stack (push) Successful in 8m29s
## What & why

S-16c, the last of the S-16 (#17) split, on top of the backplane (#122) and distributed tracing (#123). The five .NET services now expose OpenTelemetry **metrics** in Prometheus format at `/metrics`; Prometheus scrapes each (one job per service); and Grafana ships a pre-built **Request path — golden signals** dashboard (traffic / errors / latency / saturation), split by service.

Closes #124

### How

- Each service adds `.WithMetrics(AddAspNetCoreInstrumentation + AddHttpClientInstrumentation + AddMeter("System.Runtime") + AddPrometheusExporter)` and maps `/metrics`. Same shape as the S-16b tracing wiring already in these `Program.cs` files.
- `infra/observability/prometheus/prometheus.yml`: one scrape job per service (`acl`, `domain`, `bff`, `event-subscriber`, `projection-api`), reached by compose service name.
- `infra/observability/grafana/provisioning/dashboards/`: dashboard provider + `golden-signals.json` (baked into the Grafana image by the existing `COPY provisioning/`).
- `verify-metrics` (new CI verify-stack step + Makefile target): generates BFF traffic and asserts Prometheus scraped the golden-signal metric from every service. Mirrors `verify-tracing`.

### Dependency (CLAUDE.md §13/§14)

Adds `OpenTelemetry.Exporter.Prometheus.AspNetCore` `1.17.0-beta.1` (matched to the `1.17.0` core already in use). It gives the OTel-native `/metrics` pull endpoint; replacing it would mean hand-rolling Prometheus exposition over a `MeterListener`; the risk is that it is a **prerelease** package (the whole OTel .NET Prometheus line is `-beta`) — pinned, wired only in `Program.cs`, and gated by `verify-metrics`. Recorded in **ADR-0024**.

## Definition of Done

- [x] Linked Gitea issue (#124).
- [x] Failing test committed before the implementation (`test(bff): /metrics exposes http-server request duration`).
- [x] Implementation makes the test pass.
- [ ] CI green — pending Gitea Actions run.
- [x] `docker compose up` reaches green health within 3 min (backplane images unchanged in shape; not on the health gate, ADR-0023).
- [x] Docs updated — demo-script S-16c entry.
- [x] ADR added — ADR-0024.
- [x] Demo note in `docs/demo-script.md`.

## Notes for reviewers

- `/health` polls are counted as traffic (metrics aren't path-filtered, unlike traces). Fine for a demo dashboard and honest — real load stacks on top.
- `projection-api` has no Stryker config (unchanged); the four mutated services carry the metrics wiring in `Program.cs`, same as the merged S-16b tracing code.
- Metric names verified against a live service: `http_server_request_duration_seconds{,_bucket,_count}`, label `http_response_status_code`, `dotnet_process_cpu_time_seconds_total`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #129
2026-07-24 08:31:41 +00:00
not 6771fccf47 ci: parallelise jobs at runner capacity >1, keep heavy jobs apart (closes #127) (#128)
CI / build (push) Successful in 2m3s
CI / lint (push) Successful in 2m13s
CI / unit (push) Successful in 2m30s
CI / frontend (push) Successful in 4m29s
CI / mutation (push) Successful in 7m4s
CI / verify-stack (push) Successful in 8m41s
## What & why

The runner's `capacity` was raised to 2. The six CI jobs have no `needs:` between them, so they already schedule concurrently now — this PR makes that safe and tidy rather than enabling it.

- **Keep the two memory-heavy jobs apart.** `verify-stack` now `needs: [mutation]` — not a data dependency, but so Stryker and the full-stack-bring-up + Playwright browser never run at once on the one host and re-trigger the e2e OOM (#126, commit d5e5fa2). `if: ${{ !cancelled() }}` keeps verify-stack running even when the mutation ratchet fails, so we don't lose its signal, while still honouring cancellation.
- **Light jobs stay dependency-free** (lint / build / unit / frontend) → they parallelise up to runner capacity.
- **Supersede stale runs** via a workflow `concurrency` group, so a new push cancels the previous run and frees the slot instead of piling up.

Net effect at capacity 2: the light jobs pair up (and overlap `mutation`), then `verify-stack` runs alone — shorter wall-clock, no heavy-heavy collision.

Closes #127

## Definition of Done

- [x] Linked issue (#127).
- [x] Conventional Commit referencing it.
- [ ] CI green — this PR **is** the test: it exercises `needs`, `if: !cancelled()`, and the `concurrency` group on Gitea. Watch that (a) verify-stack starts only after mutation, (b) verify-stack still runs, (c) the workflow parses (concurrency accepted).
- [x] No app/docs/ADR impact (CI-only).

## Notes for reviewers

- **One thing to watch on this first run:** if this Gitea version doesn't support the top-level `concurrency` key, drop that hunk — the `needs`/`if` guard is the load-bearing part and is plain job-graph syntax.
- **Cross-run collisions** (two different PRs' `verify-stack` on the 2-capacity runner) aren't controllable via intra-workflow `needs`. If that becomes a problem, the clean fix is a second runner (or a dedicated capacity-1 label for the stack job) rather than ordering — out of scope here.

Reviewed-on: #128
2026-07-23 15:18:16 +00:00
not 88338396f6 feat(obs): distributed traces across the .NET services (S-16b, closes #123) (#126)
CI / verify-stack (push) Successful in 12m13s
CI / build (push) Successful in 1m50s
CI / lint (push) Successful in 1m58s
CI / unit (push) Successful in 2m8s
CI / frontend (push) Successful in 4m29s
CI / mutation (push) Successful in 11m51s
## What & why

S-16b, second of the S-16 split, on top of the #125 backplane. The five .NET services now emit OpenTelemetry traces so a request is **one connected trace** across them.

- Each host wires `AddOpenTelemetry().WithTracing(...)` with `AddAspNetCoreInstrumentation` (incoming) + `AddHttpClientInstrumentation` (outgoing) + `AddOtlpExporter` to **Tempo**.
- Because every cross-service call already goes through a typed `HttpClient` (§8 boundaries), the W3C `traceparent` propagates with no manual code — bff → domain → acl → openzaak and bff → projection-api stitch into a single trace.
- Service name + OTLP endpoint come from `OTEL_*` env set per app service in compose. `/health` is filtered out so liveness polls don't flood the traces.

No new ADR — ADR-0023 already records the stack + the two documented gaps (browser-side tracing is out of scope, so the trace begins at the BFF; the async Flowable-poll boundary is a separate trace).

Closes #123

## Definition of Done

- [x] Failing test committed first (`verify-tracing` fails with no instrumentation).
- [x] Implementation makes it pass — **validated locally end to end**: a real connected trace spanning `bff` + `projection-api` was found in Tempo (BFF→projection→db + Tempo subset, no OpenZaak/egress).
- [x] Conventional Commits referencing the issue (`refs #123`).
- [ ] CI green — awaiting Gitea Actions (verify-tracing added to verify-stack after verify-bff).
- [x] `docker compose up` health unaffected — services boot healthy even when Tempo is unreachable (exporter no-ops; verified).
- [x] Docs — demo-script + BACKLOG.
- [x] ADR — none needed (covered by ADR-0023).

## Notes for reviewers

- **Per-service wiring, no shared lib:** the block is duplicated across the five hosts by design — services don't share code across boundaries here (§8), same as the duplicated typed clients.
- **Packages:** OpenTelemetry.Extensions.Hosting / Instrumentation.AspNetCore / Instrumentation.Http / Exporter.OpenTelemetryProtocol, all 1.17.0, pinned per-csproj (no central props file).
- **The check** generates anonymous BFF→projection traffic (no auth, no OpenZaak), then queries Tempo (TraceQL search → fetch trace → assert both service.names present) from a python:3-slim container in-network — same idiom as run-projection-check.sh.
- **Next:** #124 (S-16c) adds `/metrics` + Prometheus scrape targets + golden-signal Grafana dashboards.

Reviewed-on: #126
2026-07-23 14:38:26 +00:00
not 4274fd30d1 feat(infra): observability backplane — Tempo + Prometheus + Grafana (S-16a, closes #122) (#125)
CI / mutation (push) Successful in 6m22s
CI / verify-stack (push) Successful in 11m53s
CI / lint (push) Successful in 1m24s
CI / build (push) Successful in 1m6s
CI / unit (push) Successful in 1m23s
CI / frontend (push) Successful in 2m54s
## What & why

S-16a, the first of the **S-16 split** (#17 closed → #122/#123/#124, §13). Stands up a local, CI-friendly observability backplane so traces (S-16b) and metrics (S-16c) have somewhere to land, viewable in one Grafana.

- **Grafana Tempo** — OTLP trace ingest (gRPC 4317 / HTTP 4318), local storage.
- **Prometheus** — scrapes itself for now; service `/metrics` targets arrive in S-16c.
- **Grafana** — Tempo + Prometheus datasources auto-provisioned with fixed uids (`tempo`, `prometheus`), exposed on :3000.

All three are small **built images** with config baked in (`infra/observability/`), on the existing `cg` network. **No OTLP collector** (Tempo ingests OTLP directly; Prometheus scrapes) and **no config-volume seeding** — the tools aren't verbatim CG peer modules, so a 3-line `COPY` Dockerfile is the simpler path that still reaches sibling containers on the CI runner (**ADR-0023**).

### Verified, not assumed

`make verify-observability` (new CI `verify-stack` step, run early) asks Grafana to reach both datasources — Prometheus via its health method, Tempo via the datasource proxy (Tempo's plugin implements no health method) — so it proves the datasources are wired, not merely that containers booted. Validated locally against the three containers (no external egress): Grafana healthy, both datasources reachable.

Closes #122

## Definition of Done

- [x] Failing test committed first (`verify-observability` fails with no backplane).
- [x] Implementation makes it pass; verified locally.
- [x] Conventional Commits referencing the issue (`refs #122`).
- [ ] CI green — awaiting Gitea Actions (verify-stack now includes the observability step; `docker compose config` validates locally).
- [ ] `docker compose up` reaches green health within 3 min — new containers are lightweight and off the health-gate list.
- [x] Docs — ADR-0023, demo-script, BACKLOG sync.
- [x] ADR added — `docs/architecture/adr-0023-observability-stack.md`.
- [x] Demo note in `docs/demo-script.md`.

## Notes for reviewers

- **No app changes** — this is pure infra; the five services are untouched (instrumentation is #123/#124).
- **Ports:** Grafana 3000 (admin/admin, anonymous viewer on), Prometheus 9090; Tempo internal to `cg`.
- **CI:** the three containers are added to the failure log-dump list; deliberately **not** added to `WAIT_SVCS` (the check polls Grafana itself, so no in-image healthcheck tool is needed). Trades ~3 small image builds per run.
- **Next:** #123 wires OTLP export + `AddAspNetCoreInstrumentation`/`AddHttpClientInstrumentation` into the five hosts so a request becomes one connected trace in Tempo.

Reviewed-on: #125
2026-07-23 12:26:22 +00:00
not 4fe9915816 feat(domain): herregistratie reminder sweep on a Quartz cron (S-17, closes #18) (#121)
CI / verify-stack (push) Successful in 8m14s
CI / lint (push) Successful in 1m20s
CI / build (push) Successful in 59s
CI / unit (push) Successful in 1m16s
CI / frontend (push) Successful in 2m38s
CI / mutation (push) Successful in 5m53s
## What & why

S-17: a BIG inscription is valid for a fixed term; before it lapses the zorgprofessional must herregistreren. This adds a **daily herregistratie reminder sweep**.

- **Domain:** `Approve(ingeschrevenOp)` now stamps the inscription moment; `HerregistratieVoor` derives the deadline (inscription + 5-year validity); `HerregistratieReminderDue(asOf)` is the single rule (inside the 90-day window, inscribed, not yet reminded); `MarkHerregistratieReminderVerstuurd()` is idempotent.
- **Store:** `FindDueForHerregistratieReminderAsync(asOf)` — the sweep's candidate set, filtered on the aggregate's own rule (no duplicated policy).
- **Application:** `HerregistratieReminderSweep` — pure over the store + an injected `TimeProvider`; flags + persists each due inscription, returns the reminded ids.
- **Infra/API:** `HerregistratieReminderJob` (Quartz `IJob`) fires the sweep on a daily cron (03:00, overridable via `Quartz__Cron`) and logs the count. `GET /registrations/{id}` surfaces `herregistratieVoor` + `herregistratieReminderVerstuurd`.

**Decisions (both raised with you before coding):** use Quartz.NET as the PRD names it — a genuine cron concern, distinct from the queue-draining pumps, which stay as-is (**ADR-0022**, proposal #120); and the reminder's observable effect is a flag on the aggregate + a log line (no outbound notification infra in v1). No coupling rule (§8) is touched — Quartz is internal to the Domain Service.

Closes #18
Closes #120

## Definition of Done

- [x] Linked Gitea issue (above).
- [x] Failing test committed before the implementation (red→green per layer: domain rule, store query, sweep).
- [x] Implementation makes the test pass; refactor commit for the 90-day knob.
- [x] Conventional Commits referencing the issue (`refs #18`).
- [ ] CI green — awaiting Gitea Actions.
- [ ] `docker compose up` reaches green health checks within 3 minutes — API boots locally with Quartz initialised; verified in CI compose smoke.
- [x] Docs updated — ADR-0022, demo-script, BACKLOG.
- [x] ADR added — `docs/architecture/adr-0022-quartz-scheduler.md`.
- [x] Demo note in `docs/demo-script.md`.

## Notes for reviewers

- **Ripple:** `Approve()` gained the inscription moment, so the two approving handlers (`ApproveRegistration`, `BeoordeelRegistratie`) now take an injected `TimeProvider`; existing tests pass a fixed clock. All three `IRegistrationStore` implementers (prod, unit fake, acceptance) got the new query.
- **Calibration knobs:** validity (5y) and reminder lead time (90d) are domain constants marked with `ponytail:` comments; promotion path to beheer config (S-15) noted in the ADR.
- **Mutation:** the Quartz job shell is excluded from Stryker, mirroring the pumps; all rule/sweep/query logic is covered.
- Local: 152 domain unit tests green; API boots with the Quartz scheduler and `/health` green.

Reviewed-on: #121
2026-07-23 10:31:56 +00:00
not 5f8ab4dbcd feat: self-service resume of an existing registration after refresh (S-26, closes #111) (#119)
CI / lint (push) Successful in 1m19s
CI / build (push) Successful in 1m7s
CI / unit (push) Successful in 1m16s
CI / frontend (push) Successful in 2m41s
CI / mutation (push) Successful in 5m57s
CI / verify-stack (push) Successful in 8m24s
## What & why

After submitting, the self-service portal held the registration only in in-memory signals, so a **page refresh stranded an in-flight registration** — the reference and its "Documenten aanleveren" / "Trek aanvraag in" actions were lost, with no way back (the reference wasn't in the URL and there was no read endpoint). This is the gap a citizen hit in testing.

Now the portal **resumes on load**:
- **Domain:** `IRegistrationStore.FindOpenByBsnAsync` (the citizen's non-terminal INGEDIEND/IN_BEHANDELING registration) + `GET /registrations/current?bsn=`.
- **BFF:** owner-scoped `GET /self-service/registrations` (bsn from the DigiD token) → the current registration, or **204** when none. Regenerated `services/bff/openapi.json`.
- **Frontend:** `registration-page` calls it on init and restores the submitted view (reference + actions); 204 shows the submit form as before. api-client regenerated (orval).

Closes #111

## Definition of Done

- [x] Linked issue (#111).
- [x] TDD — store `FindOpenByBsnAsync` tests, BFF endpoint tests, an Angular component test (resume-on-load), a Playwright e2e (submit → reload → restored).
- [x] Conventional Commits referencing #111.
- [ ] CI green — validated locally (below); runner CI running.
- [x] `docker compose up` reaches green health — fresh stack + full e2e (3 specs) green.
- [x] Docs — `docs/synthetic-data.md` (new e2e users).
- [ ] ADR — N/A (follows existing BFF/domain patterns; no boundary change).
- [ ] Demo note — the flow is unchanged for the demo; no new demo-script section (happy to add one if wanted).

## Verified locally

- Unit: Big 141 (+7 store tests), Bff 36 (+3 endpoint tests), all suites green.
- Frontend: 12 self-service component tests (incl. resume-on-load); lint + build green.
- **e2e (fresh CI stack): all 3 specs pass** — `registration`, `resume`, `withdrawal` (29.5s, single worker).
- Mutation: domain **91.04%**, bff **100%** (break 90%). `make lint` clean.

## Notes for reviewers

- **Shared-stack isolation:** resume-on-load restores any open registration for the logged-in bsn, so the self-service e2e specs can no longer share `jan-burger` (the verify-* API checks submit as `jan-burger`/`123456782` before the e2e). Each spec now has its own DigiD citizen (`emma`/`sanne`/`lars`-burger); `jan-burger` stays the documented citizen for the verify checks. This is the fix for the two intermittent e2e failures seen during development.
- **Scope:** resumes the current **in-flight** registration only (terminal ones aren't resumed), per the issue's out-of-scope note.

Reviewed-on: #119
2026-07-23 07:22:08 +00:00
135 changed files with 5526 additions and 81 deletions
+133 -5
View File
@@ -9,6 +9,12 @@ on:
permissions:
contents: read
# Supersede stale runs: a new push to the same branch/PR cancels the previous run, so the runner's
# concurrency slots aren't spent on commits nobody is waiting for (refs #127).
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Self-hosted runner — see docs/runbooks/ci.md for the runner setup.
# `uses:` are absolute, tag-pinned URLs (CLAUDE.md §8.7 / §15).
@@ -64,6 +70,12 @@ jobs:
restore-keys: |
nuget-${{ runner.os }}-
- run: make unit
# Job summary (#136): a per-service pass/fail table from the TRX `make unit` wrote.
- name: Unit test summary
if: always()
run: |
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
python3 infra/trx-summary.py TestResults >> "$GITHUB_STEP_SUMMARY"
# Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build.
frontend:
@@ -78,6 +90,12 @@ jobs:
node-version: '24'
cache: 'pnpm'
- run: make frontend
# Job summary (#136): a per-frontend (app) pass/fail table from the vitest JSON each app wrote.
- name: Frontend test summary
if: always()
run: |
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
python3 infra/vitest-summary.py test-output >> "$GITHUB_STEP_SUMMARY"
mutation:
runs-on: ubuntu-latest
@@ -93,6 +111,29 @@ jobs:
restore-keys: |
nuget-${{ runner.os }}-
- run: make mutation
# Job summary (#136): render each service's Stryker Markdown report on the run page (Gitea
# 1.27 $GITHUB_STEP_SUMMARY). `if: always()` so a ratchet break still reports — and because
# `make mutation` stops at the first break, the summary also shows exactly where it stopped.
# Guarded so it no-ops on a runner/server without summary support. Strips the report's UTF-8 BOM.
- name: Mutation score summary
if: always()
run: |
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
{
echo "## 🧬 Mutation testing"
echo
for svc in acl event-subscriber domain bff; do
echo "### $svc"
echo
report=$(ls services/"$svc"/StrykerOutput/*/reports/mutation-report.md 2>/dev/null | sort | tail -1)
if [ -n "$report" ]; then
sed '1s/^\xef\xbb\xbf//' "$report"
else
echo "_No report — \`make mutation\` stopped before \`$svc\` (earlier ratchet break)._"
fi
echo
done
} >> "$GITHUB_STEP_SUMMARY"
# Publish the Stryker HTML reports. `if: always()` uploads them even when the
# ratchet fails — that is exactly when you want to inspect the survivors.
# `continue-on-error` keeps the upload best-effort: the mutation *gate* is the
@@ -129,35 +170,122 @@ jobs:
path: services/bff/StrykerOutput/**/reports/mutation-report.html
if-no-files-found: warn
# One stage for every check that needs the live stack. On the single self-hosted
# runner jobs run sequentially, so booting OpenZaak once (instead of once per job)
# is the cheapest layout (issue #58). No setup-dotnet: the ACL test runs in a built
# image and everything reaches services by container IP. Needs Docker + egress
# One stage for every check that needs the live stack. Booting OpenZaak once (instead
# of once per job) is the cheapest layout (issue #58). No setup-dotnet: the ACL test runs
# in a built image and everything reaches services by container IP. Needs Docker + egress
# (base images, nuget, selectielijst.openzaak.nl).
#
# `needs: [mutation]` is NOT a data dependency — it serialises the two memory-heavy jobs so
# they never co-schedule now the runner has capacity >1. A concurrent Stryker run + full-stack
# bring-up + Playwright browser on one host is what OOMs the e2e (commit d5e5fa2, #126). The
# light .NET/frontend jobs have no `needs`, so they still parallelise up to runner capacity.
#
# No `if: ${{ !cancelled() }}` here (removed in #134): on Gitea 1.27 + act_runner 2.0.0, a job
# gated by a status-function `if` (always()/cancelled()) on top of `needs` routes through the new
# transitional "Cancelling" state + capability negotiation and never leaves `waiting` — it's never
# dispatched (gitea-actions-gotchas.md §7). Default `if: success()` dispatches normally. Cost: a
# failing mutation ratchet now skips verify-stack instead of running it anyway; the fix-and-re-push
# re-run exercises verify-stack, so we still get the signal.
verify-stack:
needs: [mutation]
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
# Bring the full stack up + wait for health — this also is the DoD "compose up
# reaches green health" smoke (it replaces the old compose-smoke job).
# Each check carries an `id` so the summary step below can report its per-check outcome (#136).
# A failed check skips the rest (no step `if:`), so the table shows exactly where it stopped.
- name: Bring up the full stack & wait for health
id: up
run: make verify-up
- name: Observability backplane (Grafana + Tempo + Prometheus datasources)
id: obs
run: OBS_TIMEOUT=180 make verify-observability
- name: Objecttypen API up + token authenticates
id: objecttypen
run: OBJECTTYPEN_TIMEOUT=120 make verify-objecttypen
- name: Objecten API up + token authenticates + trusts Objecttypen
id: objecten
run: OBJECTEN_TIMEOUT=120 make verify-objecten
- name: RegisterRecord objecttype registered + published
id: registerrecord
run: REGISTERRECORD_TIMEOUT=120 make verify-registerrecord
- name: ACL ↔ OpenZaak integration tests
id: acl
run: make verify-acl
- name: OpenZaak → NRC notification delivery
id: nrc
run: make verify-nrc
- name: OpenZaak → NRC → Event Subscriber → projection-api
id: projection
run: make verify-projection
- name: Domain → Flowable → ACL → OpenZaak
id: domain
run: make verify-domain
- name: BFF → Keycloak + domain + projection
id: bff
run: make verify-bff
- name: Distributed traces reach Tempo (one connected trace across services)
id: tracing
run: TRACING_TIMEOUT=120 make verify-tracing
- name: Golden-signal metrics scraped by Prometheus (/metrics on every service)
id: metrics
run: METRICS_TIMEOUT=120 make verify-metrics
- name: Self-service e2e (Playwright, login → submit → success)
id: e2e
run: make verify-e2e
# Job summary (#136): a pass/fail table of every live-stack check, so a red verify-stack shows
# which check failed at a glance. `if: always()` (step-level — safe on runner 2.0.0, unlike the
# job-level status-function `if` of #134) so it renders even after a check fails.
- name: verify-stack check summary
if: always()
env:
UP: ${{ steps.up.outcome }}
OBS: ${{ steps.obs.outcome }}
OBJECTTYPEN: ${{ steps.objecttypen.outcome }}
OBJECTEN: ${{ steps.objecten.outcome }}
REGISTERRECORD: ${{ steps.registerrecord.outcome }}
ACL: ${{ steps.acl.outcome }}
NRC: ${{ steps.nrc.outcome }}
PROJECTION: ${{ steps.projection.outcome }}
DOMAIN: ${{ steps.domain.outcome }}
BFF: ${{ steps.bff.outcome }}
TRACING: ${{ steps.tracing.outcome }}
METRICS: ${{ steps.metrics.outcome }}
E2E: ${{ steps.e2e.outcome }}
run: |
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
icon() { case "$1" in success) echo "✅";; failure) echo "❌";; skipped) echo "⏭️";; cancelled) echo "🚫";; *) echo "❔ ${1:-—}";; esac; }
{
echo "## 🔌 verify-stack checks"
echo
echo "| Check | Result |"
echo "| ----- | :----: |"
echo "| Bring up + health | $(icon "$UP") |"
echo "| Observability backplane | $(icon "$OBS") |"
echo "| Objecttypen API + token | $(icon "$OBJECTTYPEN") |"
echo "| Objecten API + token | $(icon "$OBJECTEN") |"
echo "| RegisterRecord objecttype | $(icon "$REGISTERRECORD") |"
echo "| ACL ↔ OpenZaak | $(icon "$ACL") |"
echo "| OpenZaak → NRC | $(icon "$NRC") |"
echo "| NRC → Event Subscriber → projection | $(icon "$PROJECTION") |"
echo "| Domain → Flowable → ACL → OpenZaak | $(icon "$DOMAIN") |"
echo "| BFF → Keycloak + domain + projection | $(icon "$BFF") |"
echo "| Distributed traces (Tempo) | $(icon "$TRACING") |"
echo "| Golden-signal metrics (Prometheus) | $(icon "$METRICS") |"
echo "| Self-service e2e (Playwright) | $(icon "$E2E") |"
} >> "$GITHUB_STEP_SUMMARY"
# Job summary (#136): per-spec Playwright results, from the JSON report run-e2e-check.sh copied
# out of the e2e container. Turns a red e2e into a one-glance "which spec" instead of a log dive.
- name: e2e spec summary
if: always()
run: |
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
python3 infra/playwright-summary.py tests/e2e/playwright-report.json >> "$GITHUB_STEP_SUMMARY"
# Log dump must precede teardown (which removes the containers).
- name: Dump container logs on failure
if: failure()
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel 2>&1 || true
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel beheer objecttypen-db objecttypen-redis objecttypen-init objecttypen objecten-db objecten-redis objecten-init objecten registerrecord-init tempo prometheus grafana 2>&1 || true
- name: Tear down
if: always()
run: make down
+4
View File
@@ -57,3 +57,7 @@ vitest.config.*.timestamp*
tests/e2e/node_modules/
tests/e2e/test-results/
tests/e2e/playwright-report/
__pycache__/
TestResults/
test-output/
tests/e2e/playwright-report.json
+29 -6
View File
@@ -249,32 +249,55 @@ Split (issue #11 closed) into two independently-demoable slices per §13 — the
## Iteration 3 — Maintenance portal and observability *(milestone: `Iteration 3 — Beheer & Observability`)*
### S-15 · Beheer-portal — catalogus & default-fill rules
### S-15 · Beheer-portal — catalogus & default-fill rules *(split — #16 closed)*
**Outcome:** Beheer portal lets an admin view ZTC catalogi (read-only first), and manage the ACL's default-fill configuration via a CRUD UI. MFA on the medewerker realm enforced.
### S-16 · OpenTelemetry traces + Grafana dashboard
Split into independently deployable sub-slices (CLAUDE.md §13):
- **S-15a** (#130) · Beheer portal skeleton + read-only catalogi viewer — new beheer Angular app (medewerker-realm login) showing ZTC catalogi/zaaktypen read-only, via a BFF `/beheer/*` read endpoint proxying a read-only ACL Catalogi endpoint (§8.1, reuses the ADR-0021 Catalogi client).
- **S-15b** (#131) · ACL default-fill configuration CRUD — the `Acl__Defaults__*` config (ADR-0003) becomes a managed store with CRUD via the BFF + a portal UI. Depends on S-15a.
- **S-15c** (#132) · Enforce MFA (OTP) on the Keycloak medewerker realm.
### S-16 · OpenTelemetry traces + Grafana dashboard *(split — #17 closed)*
**Outcome:** Traces span portal → BFF → Domain → ACL → OpenZaak and portal → BFF → Domain → Flowable. Grafana dashboards pre-built for golden signals.
### S-17 · Quartz.NET scheduler — herregistratie reminder sweep
Split into independently deployable sub-slices (CLAUDE.md §13):
**Outcome:** Nightly job that finds entries within 90 days of expiry and emits a domain event. (No outbound notification in v1 — logged.)
- **S-16a** (#122) · Observability backplane — Grafana Tempo + Prometheus + Grafana in compose, datasources auto-provisioned (ADR-0023). No collector; config baked into built images.
- **S-16b** (#123) · Distributed traces across the five .NET services (OTLP → Tempo; traceparent propagates via the typed HttpClients). Depends on S-16a. ✅
- **S-16c** (#124) · Prometheus metrics + golden-signal Grafana dashboards. Depends on S-16a. ✅
### S-17 · Quartz.NET scheduler — herregistratie reminder sweep ✅
**Outcome:** Daily Quartz.NET cron job finds inscriptions within 90 days of their herregistratie deadline and reminds each (flag on the aggregate + log). No outbound notification and no domain event in v1 — the reminder is the persisted flag, surfaced on the read model (ADR-0022, #120). Quartz fires time-triggered sweeps; the existing pumps stay as queue-drainers.
---
## Iteration 4 — Objecten and the authoritative register *(milestone: `Iteration 4 — Objecten`)*
### S-18 · Objecten + Objecttypen up in compose; Register objecttype defined
### S-18 · Objecten + Objecttypen up in compose; Register objecttype defined *(split — #19 closed)*
**Outcome:** Objecten and Objecttypen running. A `RegisterRecord` objecttype defined with the public-safe schema.
### S-19 · ACL extension: write register-record to Objecten on approval
Split into independently deployable sub-slices (CLAUDE.md §13):
- **S-18a** (#139, ✅) · Objecttypen API up in compose (own DB + seeded config + health + static token).
- **S-18b** (#140, ✅) · Objecten API up in compose, wired to Objecttypen. Depends on S-18a.
- **S-18c** (#141, ✅) · RegisterRecord objecttype defined + registered (public-safe JSON schema). Depends on S-18a/b.
### S-19 · ACL extension: write register-record to Objecten on approval *(split — #20 closed)*
**Outcome:** Approval path writes the canonical register record to Objecten, not OpenZaak eigenschappen. Projection now sourced from Objecten events.
**ADR required:** "Why Objecten holds the register, OpenZaak holds the process."
Split into independently deployable sub-slices (CLAUDE.md §13):
- **S-19a** (#149, ✅) · ACL writes the `RegisterRecord` to Objecten on approval, idempotently, alongside the ZGW eindstatus. Carries the ADR (ADR-0028).
- **S-19b** (#150) · Read projection sourced from Objecten instead of NRC zaak events. Depends on S-19a.
---
## Iteration 5 — Data governance module *(milestone: `Iteration 5 — Data Governance`)*
+39 -8
View File
@@ -10,7 +10,7 @@ COMPOSE := infra/docker-compose.yml
# Long-running services with a healthcheck — the smoke polls these for readiness
# (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init)
# are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel beheer objecttypen objecten
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
# into external named volumes via `docker cp` (infra/seed-config.sh) instead of
# bind-mounted, because bind mounts don't reach sibling containers on the
@@ -18,7 +18,7 @@ WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api se
# volumes are `external`, so compose won't remove them — CFG_VOLS lists them for
# explicit teardown. See docs/runbooks/gitea-actions-gotchas.md.
SEED := bash infra/seed-config.sh
CFG_VOLS := rr-oz-config rr-nrc-config rr-kc-realms rr-fl-bpmn
CFG_VOLS := rr-oz-config rr-nrc-config rr-kc-realms rr-fl-bpmn rr-objecttypen-config rr-objecten-config rr-registerrecord-config
# Local-only stack: same services but config is bind-mounted (no seed step), so a
# plain `docker compose -f infra/docker-compose.local.yml up` works on any local
# engine. This is the no-make / Windows-friendly path. See that file's header.
@@ -43,7 +43,7 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
endif
endif
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-observability verify-tracing verify-metrics verify-objecttypen verify-objecten verify-registerrecord verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
## ci: run the full pipeline — lint, build, unit, mutation, frontend, verify (mirrors Gitea Actions)
## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
@@ -70,8 +70,9 @@ build:
dotnet build $(SLN) -c Release
## unit: run unit tests (excludes the container-backed Integration lane)
# TRX per test project (→ TestResults/) feeds the CI per-service summary (#136); harmless locally.
unit:
dotnet test $(SLN) -c Release --filter "Category!=Integration"
dotnet test $(SLN) -c Release --filter "Category!=Integration" --logger trx --results-directory TestResults
## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below baseline)
# Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore`
@@ -93,14 +94,14 @@ mutation:
# podman-compose, and needing no `--wait` flag or host port access. The one-shots
# (oz-init, flowable-init) aren't polled; they just need to have run.
smoke:
$(SEED) oz nrc kc fl
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
docker compose -f $(COMPOSE) up -d --build
bash -c 'WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS); rc=$$?; docker compose -f $(COMPOSE) down --volumes; docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; exit $$rc'
## up: seed config volumes and start the full stack (use instead of bare
## `docker compose up`, which can't self-seed the external config volumes)
up:
$(SEED) oz nrc kc fl
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
docker compose -f $(COMPOSE) up -d --build
## down: stop and remove the local stack (incl. the external config volumes)
@@ -138,7 +139,7 @@ changelog:
## verify-up: bring the FULL stack up and wait for health (CI verify-stack step 1;
## subsumes the old compose-smoke health gate — the DoD "up reaches green" check).
verify-up:
$(SEED) oz nrc kc fl
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
docker compose -f $(COMPOSE) up -d --build
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
@@ -170,11 +171,41 @@ verify-bff:
verify-e2e:
bash infra/run-e2e-check.sh
## verify-observability: assert the observability backplane (Grafana + provisioned Tempo &
## Prometheus datasources) is live, against the already-running stack (S-16a).
verify-observability:
bash infra/run-observability-check.sh
## verify-tracing: assert one connected distributed trace spans the .NET services in Tempo
## (S-16b), against the already-running stack.
verify-tracing:
bash infra/run-tracing-check.sh
## verify-metrics: assert the services expose /metrics and Prometheus scrapes the golden
## signals (S-16c), against the already-running stack.
verify-metrics:
bash infra/run-metrics-check.sh
## verify-objecttypen: assert the Objecttypen API is up + its static token authenticates
## (S-18a), against the already-running stack.
verify-objecttypen:
bash infra/run-objecttypen-check.sh
## verify-objecten: assert the Objecten API is up + its static token authenticates and it
## trusts the Objecttypen API (S-18b), against the already-running stack.
verify-objecten:
bash infra/run-objecten-check.sh
## verify-registerrecord: assert the RegisterRecord objecttype is registered + published in the
## Objecttypen API (S-18c), against the already-running stack.
verify-registerrecord:
bash infra/run-registerrecord-check.sh
## verify: local mirror of the CI verify-stack job — full stack up once, all checks,
## tear down (always). For fast single-concern local iteration use `integration`
## (oz-only) or `verify-notifications` (oz+nrc) instead.
verify:
$(SEED) oz nrc kc fl
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
docker compose -f $(COMPOSE) up -d --build
@bash -c 'set -e; rc=0; \
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS) \
+3 -1
View File
@@ -64,7 +64,9 @@
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false
"watch": false,
"reporters": ["default", "json"],
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
}
},
"serve-static": {
+27
View File
@@ -0,0 +1,27 @@
# Multi-stage build for the beheer portal (Angular → nginx).
# Build context is the repo root (the app needs the pnpm workspace + libs). See infra/docker-compose.yml.
FROM node:24-slim AS build
WORKDIR /src
RUN corepack enable && corepack prepare pnpm@11.5.2 --activate
# Restore first (cached unless the manifests change).
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml nx.json tsconfig.base.json eslint.config.mjs ./
RUN pnpm install --frozen-lockfile
# Sources (only what the app + its libs need).
COPY apps/beheer apps/beheer
COPY libs libs
RUN pnpm nx build beheer
FROM nginx:1.27-alpine AS runtime
COPY apps/beheer/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist/apps/beheer/browser /usr/share/nginx/html
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
# service name, so the token issuer matches the BFF's medewerker authority (host-consistent, ADR-0013).
RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/nginx/html/config.json
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
# the nginx image's /docker-entrypoint.d before nginx starts.
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
EXPOSE 80
+34
View File
@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];
+24
View File
@@ -0,0 +1,24 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
# even before the BFF is up and picks up restarts — instead of failing to load the config.
resolver 127.0.0.11 ipv6=off valid=30s;
# Same-origin API: proxy the beheer endpoint group to the bff service. The api-client uses
# relative URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS, and the
# medewerker token (same-origin) is attached by the app's interceptor (ADR-0013).
location /beheer/ {
set $bff http://bff:8080;
proxy_pass $bff;
proxy_set_header Host $host;
}
# SPA fallback — Angular client-side routing.
location / {
try_files $uri $uri/ /index.html;
}
}
+82
View File
@@ -0,0 +1,82 @@
{
"name": "beheer",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/beheer/src",
"tags": [],
"targets": {
"build": {
"executor": "@angular/build:application",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"outputPath": "dist/apps/beheer",
"browser": "apps/beheer/src/main.ts",
"tsConfig": "apps/beheer/tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "apps/beheer/public"
}
],
"styles": ["apps/beheer/src/styles.css"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1mb",
"maximumError": "2mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kb",
"maximumError": "8kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
}
},
"serve": {
"continuous": true,
"executor": "@angular/build:dev-server",
"defaultConfiguration": "development",
"configurations": {
"production": {
"buildTarget": "beheer:build:production"
},
"development": {
"buildTarget": "beheer:build:development"
}
}
},
"lint": {
"executor": "@nx/eslint:lint"
},
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false,
"reporters": ["default", "json"],
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
}
},
"serve-static": {
"continuous": true,
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "beheer:build",
"staticFilePath": "dist/apps/beheer/browser",
"spa": true
}
}
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"authority": "http://localhost:8180/realms/medewerker"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+65
View File
@@ -0,0 +1,65 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { BffApiV1Service } from 'api-client';
import { authInterceptor } from 'auth';
import { AbstractSecurityStorage, ConfigurationService } from 'angular-auth-oidc-client';
import { SECURE_API_ROUTES } from './app.config';
// Guards the medewerker token wiring end-to-end. The api-client calls the BFF with RELATIVE URLs, and
// the angular-auth-oidc-client interceptor attaches the token only when `req.url` starts with a
// configured secureRoute. A regression to an absolute origin makes the relative URL never match, so
// the beheer calls go out unauthenticated and the BFF answers 401. This drives the REAL interceptor
// and the REAL api-client against the REAL production route value (SECURE_API_ROUTES); only the config
// source and token storage are faked, so the assertion turns on the actual route-matching.
describe('beheer medewerker token wiring', () => {
let http: HttpTestingController;
let bff: BffApiV1Service;
const token = 'medewerker-access-token';
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor()])),
provideHttpClientTesting(),
{
provide: ConfigurationService,
useValue: {
hasAtLeastOneConfig: () => true,
getAllConfigurations: () => [{ configId: 'medewerker', secureRoutes: SECURE_API_ROUTES }],
},
},
{
// A signed-in session: the storage the interceptor's token lookup reads from.
provide: AbstractSecurityStorage,
useValue: {
read: () => JSON.stringify({ authzData: token, authnResult: { id_token: 'id-token' } }),
write: () => undefined,
remove: () => undefined,
clear: () => undefined,
},
},
],
});
http = TestBed.inject(HttpTestingController);
bff = TestBed.inject(BffApiV1Service);
});
afterEach(() => http.verify());
it('attaches the bearer token to the relative catalogus call', () => {
bff.getBeheerCatalogiZaaktypen().subscribe();
const req = http.expectOne('/beheer/catalogi/zaaktypen');
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
req.flush([]);
});
it('leaves the anonymous openbaar register call unauthenticated', () => {
bff.getOpenbaarRegister().subscribe();
const req = http.expectOne((r) => r.url === '/openbaar/register');
expect(req.request.headers.has('Authorization')).toBe(false);
req.flush([]);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { authInterceptor, provideMedewerkerAuth } from 'auth';
import { appRoutes } from './app.routes';
/** Environment-specific settings fetched from /config.json at startup (see main.ts). */
export interface RuntimeConfig {
/** The Keycloak `medewerker` realm issuer as the browser reaches it (dev: localhost; compose: keycloak:8080). */
authority: string;
}
/**
* Route prefixes whose requests carry the medewerker token. These MUST match the **relative** URLs
* the api-client actually calls (same-origin via the nginx proxy) — the interceptor matches on
* `req.url`, which stays relative, so an absolute origin would never match and the token would go
* unattached. Only `/beheer/` is secured; the app calls no other endpoint group.
*/
export const SECURE_API_ROUTES = ['/beheer/'];
/**
* Build the app providers from runtime config. `redirectUrl` is the app's own origin (where Keycloak
* redirects back). `secureRoutes` uses {@link SECURE_API_ROUTES} — relative prefixes, not the origin.
*/
export function appConfig(runtime: RuntimeConfig): ApplicationConfig {
const origin = typeof window !== 'undefined' ? window.location.origin : '/';
return {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(appRoutes),
provideHttpClient(withInterceptors([authInterceptor()])),
provideMedewerkerAuth({
authority: runtime.authority,
redirectUrl: origin,
secureRoutes: SECURE_API_ROUTES,
}),
],
};
}
View File
+5
View File
@@ -0,0 +1,5 @@
<nav aria-label="Beheer" class="utrecht-theme">
<a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Catalogus</a>
<a routerLink="/default-fill" routerLinkActive="active">Default-fill</a>
</nav>
<router-outlet></router-outlet>
+9
View File
@@ -0,0 +1,9 @@
import { Route } from '@angular/router';
import { authenticatedGuard } from 'auth';
import { CatalogusPage } from './catalogus/catalogus-page';
import { DefaultFillPage } from './default-fill/default-fill-page';
export const appRoutes: Route[] = [
{ path: '', component: CatalogusPage, canActivate: [authenticatedGuard] },
{ path: 'default-fill', component: DefaultFillPage, canActivate: [authenticatedGuard] },
];
+15
View File
@@ -0,0 +1,15 @@
import { provideRouter } from '@angular/router';
import { render, screen } from '@testing-library/angular';
import { App } from './app';
describe('App', () => {
it('renders the router outlet shell', async () => {
const { container } = await render(App, {
providers: [provideRouter([])],
});
// The shell is a thin host for routed pages (the CatalogusPage owns the heading).
expect(container.querySelector('router-outlet')).toBeTruthy();
expect(screen).toBeTruthy();
});
});
+12
View File
@@ -0,0 +1,12 @@
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
@Component({
imports: [RouterModule],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {
protected title = 'beheer';
}
@@ -0,0 +1,40 @@
<main utrecht-document class="utrecht-theme">
<utrecht-article>
<utrecht-heading-1>Catalogus</utrecht-heading-1>
<p utrecht-paragraph>
De gepubliceerde zaaktypen uit de ZTC-catalogus. Alleen-lezen — beheer van de default-fill volgt
in een latere slice.
</p>
@if (loading()) {
<p utrecht-paragraph role="status">Bezig met laden…</p>
} @else if (failed()) {
<p utrecht-paragraph role="alert">
Kon de catalogus niet laden. Controleer of je als beheerder bent ingelogd en probeer het
opnieuw.
</p>
} @else if (loaded() && items().length === 0) {
<p utrecht-paragraph role="status">De catalogus bevat geen gepubliceerde zaaktypen.</p>
} @else if (items().length > 0) {
<table utrecht-table>
<caption>
Gepubliceerde zaaktypen
</caption>
<thead>
<tr>
<th scope="col">Identificatie</th>
<th scope="col">Omschrijving</th>
</tr>
</thead>
<tbody>
@for (zaaktype of items(); track zaaktype.identificatie) {
<tr>
<td>{{ zaaktype.identificatie }}</td>
<td>{{ zaaktype.omschrijving }}</td>
</tr>
}
</tbody>
</table>
}
</utrecht-article>
</main>
@@ -0,0 +1,75 @@
import { signal } from '@angular/core';
import { render, screen } from '@testing-library/angular';
import { of, throwError } from 'rxjs';
import { BeheerZaaktype, BffApiV1Service } from 'api-client';
import { AuthService } from 'auth';
import { axe } from 'vitest-axe';
import { CatalogusPage } from './catalogus-page';
const sample: BeheerZaaktype[] = [
{ identificatie: 'BIG-REGISTRATIE', omschrijving: 'BIG-registratie' },
{ identificatie: 'BIG-HERREGISTRATIE', omschrijving: 'BIG-herregistratie' },
];
class FakeAuth extends AuthService {
readonly isAuthenticated = signal(true);
readonly bsn = signal<string | undefined>(undefined);
override readonly roles = signal<readonly string[]>(['beheerder']);
login(): void {
/* not exercised here */
}
logout(): void {
/* not exercised here */
}
}
function setup(overrides: { getBeheerCatalogiZaaktypen?: ReturnType<typeof vi.fn> } = {}) {
const getBeheerCatalogiZaaktypen =
overrides.getBeheerCatalogiZaaktypen ?? vi.fn().mockReturnValue(of(sample));
return {
getBeheerCatalogiZaaktypen,
providers: [
{ provide: BffApiV1Service, useValue: { getBeheerCatalogiZaaktypen } },
{ provide: AuthService, useClass: FakeAuth },
],
};
}
describe('CatalogusPage', () => {
it('lists the published zaaktypen on open', async () => {
const { getBeheerCatalogiZaaktypen, providers } = setup();
await render(CatalogusPage, { providers });
expect(getBeheerCatalogiZaaktypen).toHaveBeenCalled();
expect(await screen.findByText('BIG-REGISTRATIE')).toBeTruthy();
expect(screen.getByText('BIG-registratie')).toBeTruthy();
expect(screen.getByText('BIG-HERREGISTRATIE')).toBeTruthy();
});
it('shows an empty state when the catalogus has no published zaaktypen', async () => {
const { providers } = setup({ getBeheerCatalogiZaaktypen: vi.fn().mockReturnValue(of([])) });
await render(CatalogusPage, { providers });
expect(await screen.findByText(/geen gepubliceerde zaaktypen/i)).toBeTruthy();
});
it('surfaces a load failure instead of swallowing it', async () => {
const { providers } = setup({
getBeheerCatalogiZaaktypen: vi.fn().mockReturnValue(throwError(() => new Error('403'))),
});
await render(CatalogusPage, { providers });
expect(await screen.findByText(/kon de catalogus niet laden/i)).toBeTruthy();
});
it('has no WCAG 2.1 AA violations', async () => {
document.documentElement.lang = 'nl';
const { container } = await render(CatalogusPage, { providers: setup().providers });
const results = await axe(container, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
});
expect(results.violations).toEqual([]);
});
});
@@ -0,0 +1,45 @@
import { Component, inject, signal } from '@angular/core';
import { BeheerZaaktype, BffApiV1Service } from 'api-client';
import { UtrechtComponentsModule } from 'ui';
/**
* The beheer catalogus viewer (S-15a): a signed-in beheerder sees the published ZTC zaaktypen,
* read-only. The list is served by the BFF (`GET /beheer/catalogi/zaaktypen`), which proxies the ACL —
* the only code allowed to read the ZGW Catalogi API (§8.1, ADR-0025). Managing default-fill is S-15b.
*/
@Component({
selector: 'app-catalogus-page',
imports: [UtrechtComponentsModule],
templateUrl: './catalogus-page.html',
})
export class CatalogusPage {
private readonly bff = inject(BffApiV1Service);
protected readonly items = signal<BeheerZaaktype[]>([]);
protected readonly loading = signal(false);
protected readonly loaded = signal(false);
protected readonly failed = signal(false);
constructor() {
this.load();
}
load(): void {
this.loading.set(true);
this.failed.set(false);
this.bff.getBeheerCatalogiZaaktypen().subscribe({
next: (rows: BeheerZaaktype[]) => {
this.items.set(rows);
this.loading.set(false);
this.loaded.set(true);
},
// Surface the failure (e.g. 403 for a non-beheerder) instead of swallowing it.
error: () => {
this.items.set([]);
this.loading.set(false);
this.loaded.set(true);
this.failed.set(true);
},
});
}
}
@@ -0,0 +1,60 @@
<main utrecht-document class="utrecht-theme">
<utrecht-article>
<utrecht-heading-1>Default-fill</utrecht-heading-1>
<p utrecht-paragraph>
De ZGW-standaardwaarden die de ACL op elke nieuwe zaak invult (ADR-0003). Een wijziging geldt
voor de eerstvolgende zaak.
</p>
@if (loading()) {
<p utrecht-paragraph role="status">Bezig met laden…</p>
} @else if (loaded()) {
<form (submit)="save(); $event.preventDefault()">
<p>
<label for="bronorganisatie">Bronorganisatie</label><br />
<input
id="bronorganisatie"
name="bronorganisatie"
[value]="bronorganisatie()"
(input)="bronorganisatie.set($any($event.target).value)"
/>
</p>
<p>
<label for="verantwoordelijkeOrganisatie">Verantwoordelijke organisatie</label><br />
<input
id="verantwoordelijkeOrganisatie"
name="verantwoordelijkeOrganisatie"
[value]="verantwoordelijkeOrganisatie()"
(input)="verantwoordelijkeOrganisatie.set($any($event.target).value)"
/>
</p>
<p>
<label for="vertrouwelijkheidaanduiding">Vertrouwelijkheidaanduiding</label><br />
<input
id="vertrouwelijkheidaanduiding"
name="vertrouwelijkheidaanduiding"
[value]="vertrouwelijkheidaanduiding()"
(input)="vertrouwelijkheidaanduiding.set($any($event.target).value)"
/>
</p>
<button utrecht-button appearance="primary-action-button" type="submit" [disabled]="saving()">
Opslaan
</button>
</form>
@if (saved()) {
<p utrecht-paragraph role="status">De standaardwaarden zijn opgeslagen.</p>
}
@if (failed()) {
<p utrecht-paragraph role="alert">
Opslaan is niet gelukt. Controleer of je als beheerder bent ingelogd en probeer het opnieuw.
</p>
}
} @else if (failed()) {
<p utrecht-paragraph role="alert">
Kon de standaardwaarden niet laden. Controleer of je als beheerder bent ingelogd en probeer
het opnieuw.
</p>
}
</utrecht-article>
</main>
@@ -0,0 +1,90 @@
import { signal } from '@angular/core';
import { fireEvent, render, screen } from '@testing-library/angular';
import { of, throwError } from 'rxjs';
import { BeheerDefaultFill, BffApiV1Service } from 'api-client';
import { AuthService } from 'auth';
import { axe } from 'vitest-axe';
import { DefaultFillPage } from './default-fill-page';
const current: BeheerDefaultFill = {
bronorganisatie: '517439943',
verantwoordelijkeOrganisatie: '517439943',
vertrouwelijkheidaanduiding: 'openbaar',
};
class FakeAuth extends AuthService {
readonly isAuthenticated = signal(true);
readonly bsn = signal<string | undefined>(undefined);
override readonly roles = signal<readonly string[]>(['beheerder']);
login(): void {
/* not exercised */
}
logout(): void {
/* not exercised */
}
}
function setup(
overrides: {
getBeheerDefaultFill?: ReturnType<typeof vi.fn>;
putBeheerDefaultFill?: ReturnType<typeof vi.fn>;
} = {},
) {
const getBeheerDefaultFill = overrides.getBeheerDefaultFill ?? vi.fn().mockReturnValue(of(current));
const putBeheerDefaultFill = overrides.putBeheerDefaultFill ?? vi.fn().mockReturnValue(of(undefined));
return {
getBeheerDefaultFill,
putBeheerDefaultFill,
providers: [
{ provide: BffApiV1Service, useValue: { getBeheerDefaultFill, putBeheerDefaultFill } },
{ provide: AuthService, useClass: FakeAuth },
],
};
}
describe('DefaultFillPage', () => {
it('loads the current default-fill into the form on open', async () => {
const { getBeheerDefaultFill, providers } = setup();
await render(DefaultFillPage, { providers });
expect(getBeheerDefaultFill).toHaveBeenCalled();
const bron = (await screen.findByLabelText('Bronorganisatie')) as HTMLInputElement;
expect(bron.value).toBe('517439943');
});
it('saves the edited values via the BFF', async () => {
const { putBeheerDefaultFill, providers } = setup();
await render(DefaultFillPage, { providers });
const bron = (await screen.findByLabelText('Bronorganisatie')) as HTMLInputElement;
fireEvent.input(bron, { target: { value: '999999999' } });
fireEvent.click(screen.getByRole('button', { name: /opslaan/i }));
expect(putBeheerDefaultFill).toHaveBeenCalledWith(
expect.objectContaining({ bronorganisatie: '999999999', vertrouwelijkheidaanduiding: 'openbaar' }),
);
expect(await screen.findByText(/standaardwaarden zijn opgeslagen/i)).toBeTruthy();
});
it('surfaces a save failure instead of swallowing it', async () => {
const { providers } = setup({
putBeheerDefaultFill: vi.fn().mockReturnValue(throwError(() => new Error('403'))),
});
await render(DefaultFillPage, { providers });
fireEvent.click(await screen.findByRole('button', { name: /opslaan/i }));
expect(await screen.findByText(/opslaan is niet gelukt/i)).toBeTruthy();
});
it('has no WCAG 2.1 AA violations', async () => {
document.documentElement.lang = 'nl';
const { container } = await render(DefaultFillPage, { providers: setup().providers });
const results = await axe(container, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
});
expect(results.violations).toEqual([]);
});
});
@@ -0,0 +1,72 @@
import { Component, inject, signal } from '@angular/core';
import { BeheerDefaultFill, BffApiV1Service } from 'api-client';
import { UtrechtComponentsModule } from 'ui';
/**
* The beheer default-fill editor (S-15b): a beheerder reads and edits the ZGW default-fill values the
* ACL stamps on every zaak (ADR-0003). Load and save go through the BFF (`/beheer/default-fill`),
* which proxies the ACL (ADR-0025). A save takes effect on the next zaak (the ACL reads it per zaak).
*/
@Component({
selector: 'app-default-fill-page',
imports: [UtrechtComponentsModule],
templateUrl: './default-fill-page.html',
})
export class DefaultFillPage {
private readonly bff = inject(BffApiV1Service);
protected readonly bronorganisatie = signal('');
protected readonly verantwoordelijkeOrganisatie = signal('');
protected readonly vertrouwelijkheidaanduiding = signal('');
protected readonly loading = signal(false);
protected readonly loaded = signal(false);
protected readonly saving = signal(false);
protected readonly failed = signal(false);
protected readonly saved = signal(false);
constructor() {
this.load();
}
load(): void {
this.loading.set(true);
this.failed.set(false);
this.saved.set(false);
this.bff.getBeheerDefaultFill().subscribe({
next: (d: BeheerDefaultFill) => {
this.bronorganisatie.set(d.bronorganisatie);
this.verantwoordelijkeOrganisatie.set(d.verantwoordelijkeOrganisatie);
this.vertrouwelijkheidaanduiding.set(d.vertrouwelijkheidaanduiding);
this.loading.set(false);
this.loaded.set(true);
},
error: () => {
this.loading.set(false);
this.loaded.set(true);
this.failed.set(true);
},
});
}
save(): void {
this.saving.set(true);
this.failed.set(false);
this.saved.set(false);
this.bff
.putBeheerDefaultFill({
bronorganisatie: this.bronorganisatie(),
verantwoordelijkeOrganisatie: this.verantwoordelijkeOrganisatie(),
vertrouwelijkheidaanduiding: this.vertrouwelijkheidaanduiding(),
})
.subscribe({
next: () => {
this.saving.set(false);
this.saved.set(true);
},
error: () => {
this.saving.set(false);
this.failed.set(true);
},
});
}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<title>Beheerportaal BIG-register</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig, type RuntimeConfig } from './app/app.config';
// Load environment config before bootstrap so the OIDC authority is set per environment
// (dev: localhost; compose: keycloak:8080) from a single build — 12-factor (S-08d).
fetch('config.json')
.then((response) => response.json() as Promise<RuntimeConfig>)
.then((config) => bootstrapApplication(App, appConfig(config)))
.catch((err) => console.error(err));
+2
View File
@@ -0,0 +1,2 @@
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
@import '@utrecht/design-tokens/dist/index.css';
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
+31
View File
@@ -0,0 +1,31 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}
+3 -1
View File
@@ -64,7 +64,9 @@
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false
"watch": false,
"reporters": ["default", "json"],
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
}
},
"serve-static": {
+3 -1
View File
@@ -64,7 +64,9 @@
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false
"watch": false,
"reporters": ["default", "json"],
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
}
},
"serve-static": {
+1 -1
View File
@@ -207,7 +207,7 @@ A slice is done when:
## 15. Out of scope for v1
- OpenMetadata data governance module (v3 slice).
- Objecten as the authoritative register record store (v2 slice — v1 uses OpenZaak zaak-eigenschappen as a placeholder).
- ~~Objecten as the authoritative register record store~~ — **delivered** in S-19a (#149, ADR-0028); the approval path writes a `RegisterRecord` object to Objecten rather than the planned zaak-eigenschappen placeholder.
- Production-grade Helm chart (sketch only).
- Multi-tenancy.
- Real outbound notifications (email/SMS) — logged to console in v1.
@@ -0,0 +1,79 @@
# ADR-0022: Quartz.NET for time-triggered fleet sweeps
- **Status:** Accepted
- **Date:** 2026-07-23
- **Deciders:** Respellion engineering
- **Slice:** S-17 (#18) · **Proposal issue:** #120
## Context
A BIG inscription is valid for a fixed term; before it lapses the zorgprofessional
must herregistreren. S-17 adds a **herregistratie reminder sweep**: once a day,
scan the register for inscriptions whose deadline is within the reminder window and
remind each one.
The Domain Service already runs periodic background work — `OpenZaakJobPump`,
`BeoordelingEscalatiePump`, `RegistratieVerlopenPump`. Those are **continuous job
pollers**: they drain Flowable's external-task/job queues at-least-once, picking up
work as soon as it is parked, on a short poll interval. The reminder sweep is a
different shape of work: **time-triggered**, once a day, over our own store — there
is no queue to drain and no "as soon as possible" requirement.
The PRD already names the scheduler component: "Scheduler (Quartz.NET): fleet-wide
sweeps (expiry, reminders)" (§39, §94). Adding Quartz.NET is nonetheless a new
dependency, so this decision is recorded before the code lands (CLAUDE.md §14).
## Decision
**Use Quartz.NET for time-triggered fleet sweeps, starting with the herregistratie
reminder sweep. Leave the existing pumps as `BackgroundService` job pollers.**
- `HerregistratieReminderJob` (a Quartz `IJob`) is fired by a cron trigger — daily
at 03:00 by default, overridable with `Quartz__Cron`. It is a thin shell: it
resolves the pure `HerregistratieReminderSweep` (application layer) and logs how
many reminders went out.
- The sweep's rule lives in the domain: `Registration.HerregistratieReminderDue(asOf)`,
which the store query and the sweep both build on. The sweep marks each reminded
inscription (`HerregistratieReminderVerstuurd`), so a re-fire reminds no one twice
(§8.6).
Two options were rejected:
1. **A `BackgroundService` with a 24h `Task.Delay`.** No new dependency, but it
drifts to process-start time, has no cron/misfire semantics, and contradicts the
PRD's named component. A daily "run at 03:00" is exactly what cron scheduling is
for.
2. **Migrating the three pumps onto Quartz too, for one mechanism.** Rejected: the
pumps are not schedulers. Forcing a "run at time T" tool onto "drain this queue
continuously" work is churn and a boundary change for negative benefit. The
teachable distinction is worth keeping: **pumps drain queues; Quartz fires
sweeps.**
## Consequences
**Positive**
- Cron scheduling with restart-stable timing and misfire handling, for free.
- The reminder rule is one domain method, reused by the store query and the sweep;
the scheduler owns none of the policy.
- The reference app now demonstrates the intended Scheduler component.
**Negative / costs**
- One new dependency (`Quartz`, `Quartz.Extensions.Hosting`) in the Domain Service.
- Two periodic-work mechanisms coexist (pumps + Quartz). Deliberate — they model
two genuinely different concerns, documented here.
**Follow-up**
- The validity term (5 years) and reminder lead time (16 weeks) are domain
calibration knobs; promote them to beheer config (S-15) if a demo needs them
per-catalogus.
- The Quartz job stores its schedule in RAM (`RAMJobStore`); a persistent/clustered
store is a later concern if the Domain Service is scaled out.
## Coupling rules touched (CLAUDE.md §8)
None. Quartz is internal to the Domain Service and drives an application use case
over the store port. No ZGW or Flowable coupling is added; the sweep talks to no
peer module.
@@ -0,0 +1,74 @@
# ADR-0023: Grafana-native observability stack (Tempo + Prometheus + Grafana)
- **Status:** Accepted
- **Date:** 2026-07-23
- **Deciders:** Respellion engineering
- **Slice:** S-16a (#122), first of the S-16 (#17) split
## Context
The PRD calls for "OpenTelemetry traces, Prometheus metrics; a local Grafana with
pre-built dashboards" (§80). S-16 was split (CLAUDE.md §13) into a backplane slice
(this one), distributed tracing (#123), and metrics + dashboards (#124). The
backplane must stand up first: a local, CI-friendly place for traces and metrics to
land, viewable in one UI, reaching green health within the 3-minute compose budget.
Two shape decisions are non-obvious enough to record.
## Decision
**Run a Grafana-native stack — Grafana Tempo (traces) + Prometheus (metrics) +
Grafana (UI) — with the services exporting OTLP straight to Tempo (no collector),
and ship the config baked into small built images.**
### Trace backend: Tempo (not Jaeger)
Tempo keeps everything under one Grafana pane alongside metrics (and later logs),
which is exactly the "local Grafana with dashboards" the PRD asks for. Jaeger would
add a second UI and a second mental model for no benefit at this scale.
### No OTLP collector
Tempo ingests OTLP directly (gRPC 4317 / HTTP 4318) and Prometheus scrapes each
service's `/metrics`, so a collector would be a hop that processes nothing. Skipped.
If we later need fan-out, tail sampling, or log processing, a collector is an
additive change — the services already speak OTLP.
### Config baked into built images, not config volumes
The upstream Common Ground modules (OpenZaak, NRC, Keycloak, Flowable) run as
**verbatim** images and get their config streamed into external named volumes by
`infra/seed-config.sh`, because bind mounts don't reach sibling containers on the
CI runner (see `docs/runbooks/gitea-actions-gotchas.md`). The observability tools
are **not** peer modules we must run verbatim, so we take the simpler path: a
three-line `Dockerfile` per tool that `COPY`s its config in. This reaches sibling
containers everywhere (docker, podman, CI) with no seed step, no `CFG_VOLS` entry,
and no Makefile sprawl.
### Verified, not assumed
`infra/run-observability-check.sh` (the `verify-observability` step, run early in CI
`verify-stack`) asks Grafana to reach both datasources — Prometheus via its health
method, Tempo via the datasource proxy (Tempo's Grafana plugin implements no health
method) — so the check proves the datasources are actually wired, not merely that
containers started. The containers are not in `WAIT_SVCS`; the check polls Grafana
itself, so no in-image healthcheck tool is required.
## Consequences
**Positive**
- One UI for traces + metrics + (future) logs. Config is versioned in
`infra/observability/` and self-contained in the images.
- Backplane is independent of app instrumentation — #123 and #124 build on it.
**Negative / costs**
- Three more images built each CI run (kept small; not on the health-gate list).
- Storage is ephemeral container fs — a demo backplane, not a retention target.
Object storage for Tempo / remote-write for Prometheus is a later concern.
## Coupling rules touched (CLAUDE.md §8)
None. The stack is passive infrastructure: services *push* OTLP and *expose*
`/metrics`; nothing in the stack calls into a service or a peer module.
@@ -0,0 +1,53 @@
# ADR-0024: Expose OTel metrics with the (prerelease) Prometheus AspNetCore exporter
- **Status:** Accepted
- **Date:** 2026-07-24
- **Deciders:** Respellion engineering
- **Slice:** S-16c (#124), last of the S-16 (#17) split
## Context
ADR-0023 already fixed the shape of metrics collection: **Prometheus scrapes each
service's `/metrics`** (pull, no collector). S-16c implements it. That needs a package
that turns the OpenTelemetry `MeterProvider` into a Prometheus scrape endpoint inside
ASP.NET Core. The canonical one is `OpenTelemetry.Exporter.Prometheus.AspNetCore`
(`AddPrometheusExporter()` + `app.MapPrometheusScrapingEndpoint()`).
The catch: that exporter has **never had a stable release** — the whole OTel .NET
Prometheus exporter line is versioned `-beta` (we pin `1.17.0-beta.1`, matched to the
`1.17.0` core we already use). Adding it is a new dependency (CLAUDE.md §14), and taking
a prerelease package into all five services is the decision worth recording.
## Decision
**Add `OpenTelemetry.Exporter.Prometheus.AspNetCore` `1.17.0-beta.1` to the five .NET
services and expose `/metrics` with it.**
- What it gives us: the OTel-native pull endpoint, so the meters we already register for
tracing-adjacent instrumentation surface as Prometheus text with zero extra plumbing.
- What we'd write to replace it: a hand-rolled `IMetricsListener`/`MeterListener` that
formats Prometheus exposition text — real work, and a reimplementation of a widely-used
library for no gain.
- Risk it adds: a prerelease API that can shift between betas. Contained: it is only
wired in `Program.cs` (two calls per service, excluded from mutation), the version is
pinned, and `verify-metrics` proves the endpoint + scrape actually work each CI run.
The alternative — pushing metrics over OTLP to a collector that re-exposes them — was
already rejected in ADR-0023 (no collector hop). Not revisited here.
## Consequences
**Positive**
- Golden-signal metrics on `/metrics` with the standard OTel names
(`http_server_request_duration_seconds`, `dotnet_*`), scraped straight by Prometheus.
- No collector, no bespoke exposition code.
**Negative / costs**
- A `-beta` package in production services. Mitigated by the pin + the `verify-metrics`
CI gate; upgrading tracks the OTel core version bumps.
## Coupling rules touched (CLAUDE.md §8)
None. Metrics are passive: Prometheus pulls; no service calls into the stack.
@@ -0,0 +1,58 @@
# ADR-0025: The BFF reads the catalogus directly from the ACL
- **Status:** Accepted
- **Date:** 2026-07-24
- **Deciders:** Respellion engineering
- **Slice:** S-15a (#130), first of the S-15 (#16) split
## Context
The beheer portal shows a read-only view of the ZTC catalogus (the published
zaaktypen). Two coupling rules constrain where that data can come from:
- **§8.1** — only the ACL may talk to the ZGW APIs (Catalogi included). So the
catalogus read *must* originate in the ACL.
- **§8.3** — portals talk only to the BFF. So the portal reaches the ACL only
through the BFF.
That leaves the question of *how the BFF gets the data*. Until now the BFF fanned
out to exactly two backends — the Domain Service and the read projection. The
catalogus is neither: it is not a registration (domain) nor a projected read model.
## Decision
**The BFF calls the ACL directly for the beheer catalogus read** — a new typed
`IAclClient` (`GET /catalogi/zaaktypen`), configured by `Downstream:Acl:BaseUrl`,
mirroring the existing `IDomainClient` / `IProjectionClient` pattern.
Rejected alternative — **route it through the Domain Service** (BFF → domain →
ACL): the catalogus is not a domain concern, so the domain would gain a
pass-through endpoint that owns no aggregate and no invariant, blurring the
domain's responsibility purely to avoid a new edge. That is worse coupling, not
better.
This adds one service-to-service edge (BFF → ACL) — an architecturally
significant boundary change (§14), hence this ADR. It does **not** bend §8: the
ACL stays the only code that reads ZGW, and the portal still talks only to the
BFF. The ACL endpoint is a plain read that trusts its callers (§8.3); the
beheerder authorization lives at the BFF (medewerker realm + `beheerder` role).
## Consequences
**Positive**
- The catalogus read follows the shortest honest path; the domain stays about
registrations.
- Symmetric with the other downstream clients — nothing new to learn.
**Negative / costs**
- The BFF now depends on three backends instead of two. The ACL must be reachable
for the beheer portal to load (it already is — the BFF is on the same network).
- A second consumer of the ACL (alongside the domain and event-subscriber), so
ACL read endpoints are now part of more than one caller's contract.
## Coupling rules touched (CLAUDE.md §8)
A new BFF → ACL edge. §8.1 and §8.3 remain intact; §14 (boundary change) is the
reason this ADR exists.
@@ -0,0 +1,61 @@
# ADR-0026: Runtime-mutable ACL default-fill (in-memory store, seeded from config)
- **Status:** Accepted
- **Date:** 2026-07-24
- **Deciders:** Respellion engineering
- **Slice:** S-15b (#131), second of the S-15 (#16) split
## Context
ADR-0003 made the ACL *default-fill* the ZGW-mandatory fields it stamps on every
zaak, supplied as static configuration (`Acl:Defaults`, read once at startup as an
immutable singleton). S-15b lets a beheerder **edit** those values from the portal
and have the next zaak reflect them — so the defaults must become mutable at runtime.
Two questions: **what** is editable, and **where** the mutable state lives.
## Decision
**Make the three ZGW default-fill fields a runtime-mutable, in-memory store
(`IDefaultFillStore`), seeded from `Acl:Defaults` at startup. The ACL reads it per
zaak; the beheer `PUT /default-fill` replaces it.**
### Only the three ZGW fill fields are editable
`Acl:Defaults` also carries the S-27 catalog-resolution keys (`ZaaktypeIdentificatie`,
`InformatieobjecttypeOmschrijving`). Those feed the resolved-URL cache
(`CachedZaaktypeCatalog`, ADR-0021); editing them at runtime would leave a stale cache
and is catalogus *wiring*, not "default fill". So they **stay static config** and are
out of scope for the CRUD. The editable set is exactly `Bronorganisatie`,
`VerantwoordelijkeOrganisatie`, `Vertrouwelijkheidaanduiding` (`DefaultFillSettings`).
### In-memory, not persisted
The store is a thread-safe in-memory singleton. **An edit is lost on restart**, when it
reverts to the configured env. That is acceptable for this reference app: the slice
demonstrates the *pattern* (beheer edits config that the ACL honours), not durable
config management. The ACL stays stateless — no DB, no EF, no migration, no extra
compose service.
- ponytail ceiling: no persistence, no audit trail, no optimistic concurrency.
- Upgrade path: back `IDefaultFillStore` with a DB (or an Objecten record) if durable,
audited, multi-instance config is needed — the port stays the same.
## Consequences
**Positive**
- Demoable end to end (edit in portal → next zaak reflects it) with minimal moving parts.
- The read path is per-zaak, so no restart and no cache concerns for the ZGW fields.
**Negative / costs**
- Edits don't survive a restart and aren't shared across replicas (single-instance
assumption). Documented ceiling above.
- Two sources of default config now (static keys on `AclDefaults`, mutable fields in the
store) — a deliberate split by editability.
## Coupling rules touched (CLAUDE.md §8)
None new. The BFF→ACL edge already exists (ADR-0025); this adds a read/write pair on it.
The ACL remains the owner of the ZGW-facing config.
@@ -0,0 +1,81 @@
# ADR-0027: The RegisterRecord objecttype is public-safe by construction
- **Status:** Accepted
- **Date:** 2026-07-27
- **Deciders:** Respellion engineering
- **Slice:** S-18c (#141), third of the S-18 (#19) split
## Context
S-18 stands up Objecttypen (S-18a) and Objecten (S-18b) as the authoritative
register-record store (PRD §"Objecten as the authoritative register record store").
S-19 (#20) will, on approval, write the canonical register record to the Objecten API
instead of OpenZaak zaak-eigenschappen, and the openbaar (public) register will read it.
Objecten validates every object against a **objecttype version's JSON schema**. So the
schema is a contract: it fixes which fields a register record may carry. The register is
read **anonymously** by the openbaar portal (ADR-0010), so the schema is also a
disclosure boundary — anything the schema allows can end up public.
Two questions: **which fields** the schema defines, and **how** the objecttype gets into
the Objecttypen API (which has no declarative objecttype step).
## Decision
**Define a `RegisterRecord` objecttype whose published schema carries exactly the
public-safe fields — `id`, `status`, `reference` — and register it over the API at
startup with a one-shot, idempotently.**
### The schema mirrors the BFF's public projection, not the internal one
The public-safe field set already exists: the BFF's `OpenbaarEntry`
(`services/bff/Bff.Api/DownstreamClients.cs`) — `id`, `status`, `reference` — is what
`OpenbaarProjection.PublicView` narrows every row down to, dropping `bsn` and
`naamPlaceholder` at the boundary (S-09). The RegisterRecord schema mirrors that record,
**not** the internal `RegisterEntry` / `RegisterEntryRow` (which carry bsn/naam):
| field | type | notes |
|-------|------|-------|
| `id` | string (required) | zaak id — the entry's stable key |
| `status` | string (required) | enum `INGEDIEND` \| `INGESCHREVEN` (`RegistrationStatus`) |
| `reference` | string \| null | citizen-facing zaak identificatie (ADR-0012) |
`additionalProperties: false` so a record can't smuggle a field the schema didn't
sanction, and `dataClassification: "open"` records the intent that this objecttype is
public. **`bsn` and `naamPlaceholder` are deliberately absent** — public-safe by
construction, so S-19 cannot write a personal-data field into the public register even by
mistake.
### Registered over the API by a one-shot, not setup_configuration
The Objecttypen API's `setup_configuration` (3.4.2) provisions only tokens — it has no
declarative step to create an objecttype with a schema. So a `registerrecord-init`
compose one-shot (stdlib Python, on the stack network) creates the objecttype + a
**published** version over the API once Objecttypen is healthy, following the ADR-0020
self-seed pattern. It is **idempotent**: if a `RegisterRecord` with a version already
exists it is a no-op, so it is safe on every `up`.
- ponytail ceiling: no schema-migration/versioning story — a schema change means editing
`registerrecord.schema.json` and bumping the version by hand; the one-shot only ever
adds v1 if none exists.
- Upgrade path: if the schema evolves, have the one-shot diff the published schema and
POST a new version, or move to a declarative step once the upstream supports one.
## Consequences
**Positive**
- The public register's disclosure surface is fixed in one reviewed artifact
(`registerrecord.schema.json`) and enforced by Objecten's own validation.
- Self-seeds on a fresh `make up` / bare local compose; no manual step, no built image.
**Negative / costs**
- The public-safe field set now lives in two places — the BFF's `OpenbaarEntry` and this
schema — that must be kept in sync by hand (a drift check is a candidate for later).
- Hand-managed schema version (ceiling above).
## Coupling rules touched (CLAUDE.md §8)
None new. Registration talks to the Objecttypen API over its documented API. S-19 will
write records via the ACL (§8.1) — this ADR only fixes the schema they conform to.
@@ -0,0 +1,174 @@
# ADR-0028: Objecten holds the register, OpenZaak holds the process
- **Status:** Accepted
- **Date:** 2026-08-14
- **Deciders:** Respellion engineering
- **Slice:** S-19a (#149), first of the S-19 (#20) split
## Context
Until this slice the register existed only as a **derived** thing: the read projection
rows the Event Subscriber builds from NRC zaak notifications (ADR-0008). There is no
system anywhere that holds "who is registered" as a first-class record — drop the
projection database and the only way back is to replay ZGW history and re-derive it.
That is the wrong shape for a register. A BIG registration is a **fact about a person**
that outlives the case that produced it: it is looked up, corrected, superseded, and
retained on its own schedule. The zaak that produced it is a **process record** — it
opens, moves through statussen, and closes. Storing the fact inside the process record
(as zaak `eigenschappen`, the v1 placeholder PRD §"Registration" mentions) welds the two
lifecycles together: the register can then never be read, retained, or corrected without
going through the case system that happened to create it.
S-18 stood up Objecten + Objecttypen and registered the public-safe `RegisterRecord`
objecttype (ADR-0027). The open question this ADR closes: **where the authoritative
register record lives, and who writes it.**
## Decision
**The register record lives in the Objecten API as a `RegisterRecord` object. OpenZaak
keeps only the process. On approval the ACL writes both: the ZGW eindstatus, then the
register record.**
### Not zaak eigenschappen
Eigenschappen are per-zaaktype, untyped strings, and readable only by walking the zaak.
They inherit the zaak's lifecycle and its archiving regime, and they give the public
register no queryable surface of its own. Objecten gives a JSON-schema-validated record
(ADR-0027 makes that schema the disclosure boundary), a queryable collection, and a
lifecycle the zaak cannot drag around with it.
### The ACL writes it, not the domain or the Event Subscriber
CLAUDE.md §8.1 keeps upstream Common Ground modules behind the ACL. Objecten is such a
module, so the same rule applies: `ObjectenGateway` is the only code that talks to it,
and the domain keeps handing the ACL nothing but a zaak URL. The alternative — having the
Event Subscriber write the record when it sees the status notification — would make the
register a *second* derived artefact of ZGW, which is exactly the coupling this ADR
removes.
### Two writes, converging rather than transactional
Approval is now two writes across two modules, so it cannot be atomic. Both are made
idempotent instead:
- a ZGW status is an append-only log entry, so re-setting the eindstatus is harmless;
- the register write is an **upsert keyed on the zaak id** — search Objecten for an
existing object with that `id`, then PATCH it or POST a new one.
A caller that retries a half-failed approval therefore converges. This is the same
eventual-consistency posture as everywhere else in the system (CLAUDE.md §2.2, §8.6),
not an exception carved out for this path.
### The objecttype is resolved by name, lazily
The objecttype URL and version number are assigned by Objecttypen at seed time, so they
cannot be pinned in config — the ACL resolves them by the configured name
(`Acl__Objecten__ObjecttypeName`), taking the highest **published** version. This is the
same reasoning as ADR-0021 for zaaktypen.
Resolution happens on the first approval, not at startup, so the ACL needs no `depends_on`
on Objecten and will not crash-loop when it boots ahead of the seed. A failed resolution
is not cached, so it is retried on the next approval.
- ponytail ceiling: the resolution is memoised per gateway instance, and the gateway is a
transient typed `HttpClient` — in practice one extra GET per approval against a
neighbouring container.
- Upgrade path: lift it into a singleton cache (as `CachedZaaktypeCatalog` does for ZGW)
if approvals ever get hot enough for that GET to matter.
### The objecttype's UUID is pinned, not server-assigned
Objecten refuses to store an object whose objecttype it has not been configured with
(`ObjectType with url=… is not configured`), and its configuration identifies an
objecttype **by UUID** — supplied through a static `setup_configuration` file applied
when the container starts, before the `registerrecord-init` one-shot has run.
Rather than thread a seed-time UUID from one container into another's config, the UUID is
**pinned**: `infra/objecttypen-registerrecord/register.py` creates the objecttype with a
fixed UUID (the Objecttypen API accepts a client-supplied one), and
`infra/objecten/setup_configuration/data.yaml` declares that same UUID. Both sides are
declared up front, both stay idempotent, and neither has to wait for the other.
The cost is a constant duplicated across two files that must be kept in step; each carries
a comment pointing at the other.
### The ACL must reach Objecttypen at the URL Objecten knows it by
Objecttypen builds the `url` it returns from the request's own Host header, and Objecten
matches an incoming object's `type` against the `api_root` it was configured with. So an
ACL that reads Objecttypen at `http://localhost:8020` gets back a `localhost` objecttype
URL that Objecten then rejects as "not one of the available choices" — even though it is
the same objecttype.
`Acl__Objecten__ObjecttypenBaseUrl` must therefore match Objecten's configured
`api_root` (`http://objecttypen:8000/api/v2/`). This is the same class of constraint as
ADR-0006's "point the ACL at OpenZaak's container IP", and it is why the Objecten
integration tests only pass from inside the compose network.
### Objecten's notifications are off for this slice
Objecten publishes to a Notificaties API on every write, and `notifications_api_common`
**raises** rather than skipping when that configuration is absent — so with no NRC wiring,
every `POST /api/v2/objects` returns 500 after creating and rolling back the object.
Objecten → NRC is not wired: there is no broker, no Celery worker, no `objecten` kanaal and
no abonnement for it. Configuring only the client side would make writes succeed while
every message was dropped on the floor — a delivery path that looks wired and isn't. So
`NOTIFICATIONS_DISABLED` is set for Objecten in both compose files instead.
- ponytail ceiling: Objecten emits no notifications, so nothing downstream can react to a
register write yet.
- Upgrade path: S-19b (#150) needs those notifications to source the projection from
Objecten, and turns them on together with the broker, worker, kanaal and abonnement.
## Consequences
**Positive**
- The register is a first-class record with its own schema, lifecycle and query surface,
independent of the case that produced it.
- The disclosure boundary is enforced by Objecten's schema validation (ADR-0027), not by
discipline in projection code.
- The read projection can become a cache of Objecten rather than a re-derivation of ZGW
(S-19b, #150).
**Negative / costs**
- Approval writes to two modules and is eventually consistent; a failure between them
leaves a zaak in eindstatus without a register record until the approval is retried.
Nothing repairs that automatically yet.
- One more upstream module on the approval path, and one more dev credential
(`Acl__Objecten__Token`) in compose.
- Two new hand-kept constants: the pinned objecttype UUID (two files) and the objecttype
name (compose + `register.py`).
- Until S-19b lands, the public register is still read from the NRC-derived projection, so
the register record is written but not yet read — the two must agree.
## Coupling rules touched (CLAUDE.md §8)
None bent. §8.1 is extended in spirit — the ACL is the only code that talks to Objecten,
exactly as it is the only code that talks to ZGW. The domain still passes only a zaak URL,
and no service reaches Objecten's database.
## Verification
The end-to-end assertion lives in the Playwright happy path
(`tests/e2e/registration.spec.ts`, run by `verify-e2e`): after the behandelaar approves and
the openbaar register shows `INGESCHREVEN`, it asserts Objecten holds exactly one
`RegisterRecord` for *that* reference, with status `INGESCHREVEN` and no field outside the
public-safe schema.
It belongs there and not in `verify-domain`, which looks like the obvious home: that check
completes the Beoordelen task straight through Flowable REST (deliberately — it exists to
exercise the Workflow Client's REST contract), which bypasses the domain `decide` path that
calls the ACL. The e2e is the only check that drives a real approval.
`ObjectenGatewayIntegrationTests` (`Category=Integration`, so it runs under `verify-acl`
inside the compose network) drives the real gateway against a live Objecten + Objecttypen
pair: two writes for the same id leave exactly one object, carrying the second write's
status and nothing outside the public-safe schema.
All three findings above — the pinned UUID, the notifications block, and the base-URL
constraint — came out of running the gateway against those live modules while writing the
slice, not out of CI.
+281
View File
@@ -5,6 +5,287 @@ copy-pasteable walkthrough against a local `make up` stack.
---
## S-19a — approval writes the register record to Objecten (#149, ADR-0028)
**Outcome:** approving a registration no longer only moves the ZGW zaak to its eindstatus — it also
writes the canonical **register record** into the **Objecten** API. OpenZaak keeps the process,
Objecten holds the register. The write goes through the ACL (§8.1) and is **idempotent**: replaying an
approval updates the existing object instead of creating a second one.
```bash
# 1. Bring the stack up (Objecten, Objecttypen and the RegisterRecord objecttype come with it).
make up
#
# 2. End-to-end: the walking-skeleton e2e submits, approves via the behandel portal, and then
# asserts Objecten holds exactly one RegisterRecord for *that* registration:
make verify-e2e # → "DigiD submit → … → behandelaar goedkeurt → public INGESCHREVEN"
#
# 3. The ACL integration test proves the same writes against a live Objecten (upsert stays one object):
make verify-acl # → "Writes a register record and updates it in place on a second write"
#
# 4. See it for yourself — every register record currently in Objecten:
curl -s -H 'Authorization: Token 1234567890abcdef1234567890abcdef12345678' \
-H 'Accept-Crs: EPSG:4326' \
'http://localhost:8021/api/v2/objects' | python3 -m json.tool
```
Each object's `record.data` carries exactly `id`, `status`, `reference` — the schema forbids anything
else (ADR-0027), so no personal data can reach the world-readable register even by mistake.
**The path:** behandel portal → BFF → domain `BeoordeelRegistratie` → ACL `POST /statussen` → ZGW
`resultaten` + `statussen` (the process), **then** ACL → Objecten `POST`/`PATCH /api/v2/objects` (the
register). The objecttype URL is resolved by name from Objecttypen on first use, so nothing seed-time
is pinned in config (ADR-0028, same reasoning as ADR-0021).
**Not yet:** the public register still reads the NRC-derived projection — re-sourcing it from Objecten
is S-19b (#150).
---
## S-18c — RegisterRecord objecttype defined + registered (#141, ADR-0027)
**Outcome:** a **RegisterRecord** objecttype with a **published** JSON schema is registered in the
Objecttypen API at startup. The schema is public-safe by construction — `id`, `status`, `reference`
only, mirroring the BFF's `OpenbaarEntry` (no `bsn`/`naam`), `dataClassification: open`. This is the
schema S-19 writes register records against on approval. A `registerrecord-init` one-shot creates it
over the API once Objecttypen is healthy (the Objecttypen `setup_configuration` has no objecttype
step), idempotently.
```bash
make up
# The RegisterRecord objecttype exists with a published version:
curl -s -H "Authorization: Token 0123456789abcdef0123456789abcdef01234567" \
"http://localhost:8020/api/v2/objecttypes" | python3 -c \
'import sys,json; o=[x for x in json.load(sys.stdin)["results"] if x["name"]=="RegisterRecord"][0]; print(o["name"], o["dataClassification"], o["versions"])'
# → RegisterRecord open ['http://.../objecttypes/<uuid>/versions/1']
#
# Automated (a CI verify-stack step): asserts the objecttype exists, has a published version, and
# that version's schema carries id/status/reference.
make verify-registerrecord # → OK — RegisterRecord v1 published, fields=['id', 'reference', 'status']
```
**The path:** `infra/objecttypen-registerrecord/registerrecord.schema.json` (the reviewed public-safe
contract) + `register.py` are streamed into an external config volume by `infra/seed-config.sh
registerrecord` (bind-mounted locally); the `registerrecord-init` one-shot POSTs the objecttype + a
published version. Re-running is a no-op. S-19 (#20) writes records against this schema in Objecten.
---
## S-18b — Objecten API up in compose, wired to Objecttypen (#140)
**Outcome:** the upstream Maykin **Objecten API** runs in the stack — own **PostGIS** DB + redis,
config seeded like the other CG modules (`objecten-init` runs `setup_configuration` from the
`rr-objecten-config` volume: migrate + provision a dev **static API token** + register the
**Objecttypen API** (S-18a) as a trusted service), a health-checked `objecten` web on host `:8021`.
An object can now reference its objecttype; the ACL writes register records here on approval (S-19).
```bash
make up
# 1. The API is up; the seeded token authenticates (401 without, 200 with):
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8021/api/v2/objects # 401
curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Token 1234567890abcdef1234567890abcdef12345678" \
http://localhost:8021/api/v2/objects # 200
#
# 2. It trusts Objecttypen — the seeded zgw_consumers service points at the Objecttypen API:
docker exec infra-objecten-1 python src/manage.py shell -c \
"from zgw_consumers.models import Service; print(*[(s.slug,s.api_root) for s in Service.objects.all()])"
# → ('objecttypen', 'http://objecttypen:8000/api/v2/')
#
# 3. Automated (a CI verify-stack step): asserts unauth 401 + token 200, against the running stack.
make verify-objecten # → OK — no-auth 401, token 200
```
**The path:** verbatim upstream image (`maykinmedia/objects-api`, pinned 3.4.0) + the same seed
pattern as S-18a — `infra/seed-config.sh objecten` streams `data.yaml` into an external config
volume, `objecten-init` (RUN_SETUP_CONFIG) applies it. Its `zgw_consumers` step registers Objecttypen
(`api_type: orc`, api-key auth with the S-18a dev token). The RegisterRecord objecttype (S-18c) and
the ACL write path (S-19) build on this.
---
## S-18a — Objecttypen API up in compose (#139)
**Outcome:** the upstream Maykin **Objecttypen API** runs in the stack — own Postgres + redis, config
seeded like the other CG modules (`objecttypen-init` runs `setup_configuration` from the
`rr-objecttypen-config` volume: migrate + provision a dev **static API token**), a health-checked
`objecttypen` web service on host `:8020`. This is the objecttype catalogue the register record
(S-18b/S-18c, S-19) will use.
```bash
make up
# 1. The API is up; the seeded token authenticates (401 without, 200 with):
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8020/api/v2/objecttypes # 401
curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Token 0123456789abcdef0123456789abcdef01234567" \
http://localhost:8020/api/v2/objecttypes # 200
#
# 2. Automated (a CI verify-stack step): asserts both, against the running stack.
make verify-objecttypen # → OK — no-auth 401, token 200
```
**The path:** verbatim upstream image (`maykinmedia/objecttypes-api`, pinned) + the same seed pattern
as OpenZaak/NRC — `infra/seed-config.sh objecttypen` streams `data.yaml` into an external config
volume, `objecttypen-init` (RUN_SETUP_CONFIG) applies it. Objecten (S-18b) and the RegisterRecord
objecttype (S-18c) build on this.
---
## S-15b — Beheer-portal: default-fill configuration editor (#131, ADR-0026)
**Outcome:** a beheerder edits the ACL's ZGW **default-fill** values (bronorganisatie,
verantwoordelijke organisatie, vertrouwelijkheidaanduiding) from the beheer portal, and the next zaak
is stamped with the new values — no restart. Path: portal → BFF `GET/PUT /beheer/default-fill`
(beheerder role) → ACL `GET/PUT /default-fill` → a runtime-mutable in-memory store the ACL reads per
zaak (ADR-0026). The S-27 catalog-resolution keys stay static config (editing them would desync the
zaaktype cache). Store is in-memory: an edit reverts to the configured env on restart.
```bash
make up
# 1. Log in as bram-beheerder / test123 → "Default-fill" tab → change a value → Opslaan.
open http://localhost:8143/default-fill
#
# 2. Automated: the ACL uses the current default-fill per zaak (unit) and the endpoints are behind the
# beheerder role (BFF unit):
# Acl.Tests → AclServiceTests.Opening_a_zaak_reflects_a_default_fill_update
# Bff.Tests → BeheerDefaultFillEndpointTests
```
---
## S-15a — Beheer-portal: read-only catalogus viewer (#130, ADR-0025)
**Outcome:** a new **beheer** portal (medewerker realm, like behandel) shows the ZTC catalogus —
the published zaaktypen — **read-only**. A beheerder logs in and sees the seeded BIG-REGISTRATIE
zaaktype. The read path is portal → BFF `GET /beheer/catalogi/zaaktypen` (medewerker realm +
`beheerder` role) → ACL `GET /catalogi/zaaktypen` → ZGW Catalogi API. The BFF reaches the ACL
directly (ADR-0025); managing the default-fill config (S-15b) and MFA (S-15c) come next.
```bash
make up
# 1. Log in as bram-beheerder / test123 → the catalogus lists the published zaaktypen.
open http://localhost:8143
#
# 2. Automated (a CI verify-stack e2e): a beheerder logs in and sees BIG-REGISTRATIE.
make verify-e2e # → catalogus.spec: "a beheerder sees the published zaaktypen in the catalogus"
#
# 3. The BFF endpoint is behind the beheerder role — a plain behandelaar gets 403 (BFF unit tests):
# Bff.Tests → BeheerEndpointTests.
```
**Auth:** the `beheerder` realm role + `bram-beheerder` user live in the medewerker realm
(`infra/keycloak/realms/medewerker-realm.json`); the BFF reuses the medewerker bearer scheme and its
realm-role lifting, requiring `beheerder` rather than `behandelaar`.
---
## S-16c — Prometheus metrics + golden-signal Grafana dashboard (#124, ADR-0023)
**Outcome:** the five .NET services now expose OpenTelemetry metrics in Prometheus format at `/metrics`
— ASP.NET Core + `HttpClient` instrumentation plus the built-in `System.Runtime` meter. Prometheus
scrapes each service (one job per service), and a **pre-built Grafana dashboard** — *Request path —
golden signals* — plots the four golden signals: **traffic** (req/s), **errors** (5xx/s), **latency**
(p95 request duration), and **saturation** (CPU cores in use), split by service. It populates under load.
```bash
# 1. Automated (a CI verify-stack step): generate BFF traffic and assert Prometheus scraped the
# golden-signal metric from every service.
make verify-metrics # → OK — targets up: [...]; request metric scraped from: [...]
# 2. By hand: drive the stack, generate some load, then open the dashboard.
make up
for i in $(seq 1 50); do curl -s localhost:8080/openbaar/register >/dev/null; done # BFF → projection-api
open http://localhost:3000 # Grafana → Dashboards → "Request path — golden signals"
open http://localhost:9090/targets # Prometheus → every service target UP
```
**The path:** each host adds `.WithMetrics(AddAspNetCoreInstrumentation + AddHttpClientInstrumentation +
AddMeter("System.Runtime") + AddPrometheusExporter)` and maps `/metrics`; Prometheus scrapes
`<service>:8080/metrics` (config in `infra/observability/prometheus/prometheus.yml`); Grafana ships the
dashboard via provisioning against the fixed `prometheus` datasource uid. No metrics are pushed over
OTLP — Prometheus pulls, so there is no collector hop (ADR-0023).
---
## S-16b — distributed traces across the .NET services (#123, ADR-0023)
**Outcome:** the five .NET services (BFF, Domain, ACL, projection-api, event-subscriber) now emit
OpenTelemetry traces — ASP.NET Core + `HttpClient` auto-instrumentation, exported over OTLP to Tempo.
Because every cross-service call goes through a typed `HttpClient`, the W3C `traceparent` propagates for
free, so a request is **one connected trace** across the services (bff → domain → acl → openzaak;
bff → projection-api). `/health` is filtered out. No browser-side instrumentation yet, so the trace
begins at the BFF; the async Flowable-poll boundary is a separate trace (ADR-0023).
```bash
# 1. Automated (a CI verify-stack step): generate BFF traffic and assert Tempo holds one trace
# spanning multiple services.
make verify-tracing # → OK — trace <id> spans ['bff', 'projection-api']
# 2. By hand: drive the stack, then explore traces in Grafana.
make up
curl -s localhost:8080/openbaar/register >/dev/null # BFF → projection-api
open http://localhost:3000 # Grafana → Explore → Tempo → Search → service.name = bff → open a trace
```
**The path:** each host wires `AddOpenTelemetry().WithTracing(AddAspNetCoreInstrumentation +
AddHttpClientInstrumentation + AddOtlpExporter)`; `OTEL_SERVICE_NAME` / `OTEL_EXPORTER_OTLP_ENDPOINT`
come from compose; spans export to **tempo:4317** and render in Grafana against the provisioned Tempo
datasource.
---
## S-16a — observability backplane: Tempo + Prometheus + Grafana (#122, ADR-0023)
**Outcome:** the compose stack now includes a Grafana-native observability backplane — **Tempo** (OTLP
trace ingest on 4317/4318), **Prometheus**, and **Grafana** with both datasources auto-provisioned.
Nothing is instrumented yet (traces land in S-16b, metrics + dashboards in S-16c); this slice stands the
backplane up and proves Grafana can reach both datasources. Config is baked into small built images
(`infra/observability/`) — no collector, no config-volume seeding.
```bash
# 1. Bring the stack up, then assert the backplane is live (Grafana healthy + Tempo/Prometheus
# datasources reachable through Grafana). This is a CI verify-stack step.
make up
make verify-observability # → ✓ Grafana healthy ✓ Prometheus reachable ✓ Tempo reachable
# 2. Or just the backplane, no full stack needed (no external egress):
docker compose -f infra/docker-compose.yml up -d --build tempo prometheus grafana
open http://localhost:3000 # Grafana (admin/admin) → Connections → Data sources: Prometheus + Tempo
open http://localhost:9090 # Prometheus
```
**The path:** services will export OTLP → **Tempo:4317** and expose `/metrics`**Prometheus** scrapes;
**Grafana** (:3000) reads both via provisioned datasources with fixed uids `tempo` / `prometheus`.
---
## S-17 — herregistratie reminder sweep on a Quartz cron (#18, ADR-0022)
**Outcome:** an inscription (INGESCHREVEN) now carries the moment it was entered in the register, from
which its herregistratie deadline is derived (inscription + 5-year validity). A **Quartz.NET** cron job
in the Domain Service sweeps once a day (03:00, overridable via `Quartz__Cron`): every inscription
inside the 90-day window before its deadline is flagged `HerregistratieReminderVerstuurd` and logged.
The sweep is idempotent — a re-fire reminds no one twice — and is a deliberately different mechanism
from the queue-draining pumps (Quartz fires time-triggered sweeps; pumps drain Flowable queues,
ADR-0022). There is no outbound notification in v1: the reminder is the flag on the aggregate plus a
log line.
```bash
# 1. The domain unit tests prove the rule and the sweep end to end (rule → store query → sweep):
cd services/domain && dotnet test Big.Tests/Big.Tests.csproj \
--filter "FullyQualifiedName~Herregistratie|FullyQualifiedName~ReminderSweep"
# → the reminder is due once the 90-day window opens, not before; a reminded inscription is skipped
# on the next sweep; the sweep flags + persists every due inscription and returns their ids.
# 2. The read model surfaces the deadline once a registration is approved — the field the sweep acts on:
curl -s localhost:8000/registrations/<id> | jq '{status, herregistratieVoor, herregistratieReminderVerstuurd}'
# → after approval: herregistratieVoor is inscription + 5 years; the flag flips true once swept.
```
**The path:** `Registration.Approve(now)` stamps `IngeschrevenOp` → daily Quartz `HerregistratieReminderJob`
`HerregistratieReminderSweep``IRegistrationStore.FindDueForHerregistratieReminderAsync` (filtered by
the aggregate's own `HerregistratieReminderDue` rule) → `MarkHerregistratieReminderVerstuurd` + log.
---
## S-B04 — `make local` completes the whole flow with no manual seeding (#110, ADR-0020)
**Outcome:** the host-browser stack (`make local`) now self-seeds at bring-up — it publishes the BIG
+49
View File
@@ -196,3 +196,52 @@ service name; the notif verify harness also registers the sink callback by IP.
abonnement is registered and refuses it (`no-auth-on-callback-url`) unless it returns
**401** without the configured `Authorization`. The verify sink
(`infra/notification-sink.py`) enforces a bearer token for exactly this reason.
---
## 7. A job with `if: ${{ !cancelled() }}` (or `always()`) + `needs` sticks in "waiting"
**Symptom** — after upgrading to **Gitea 1.27** + **act_runner 2.0.0**, one job never
starts: the run sits in state `waiting` forever, the job has **no logs** (never
dispatched to a runner), and the other jobs finish normally. `main` stays pending/red.
Seen on the `verify-stack` job (#134).
**Why** — Gitea 1.27 reworked cancellation/aggregation: a job gated by a
**status-function `if`** (`always()` / `cancelled()` / `!cancelled()`) on top of
`needs` now routes through a new transitional **`Cancelling`** job state plus a
server↔runner **capability negotiation** ("Requires Gitea Runner 2.0.0"). On the
1.27 + 2.0.0 pairing that handshake doesn't resolve for such a job, so it's never
offered to a runner and never leaves `waiting`. Jobs with no `if`/`needs` are
unaffected. (Related upstream: go-gitea/gitea#31074, #27116, #35782.)
**Fix** — don't gate a `needs` job with a status-function `if`. Use the default
`if: success()` (i.e. omit the `if`). If you need "run even when an upstream job
fails", prefer serialising with a `concurrency` group over `needs` + `always()`.
**Also** — a run already stuck this way will **not** clear itself; force-cancel it
from the Actions UI (plain cancel can also stall on this version, #35782). Push the
workflow fix to produce a fresh run.
---
## 8. Job summaries (`$GITHUB_STEP_SUMMARY`) need Gitea ≥1.27 + runner ≥2.0
Markdown a step appends to the `$GITHUB_STEP_SUMMARY` file renders on the run page
(no artifact download). We use it for per-run reports (#136): mutation scores
(Stryker `markdown` reporter), per-service unit results (`infra/trx-summary.py` over
TRX), per-frontend results (`infra/vitest-summary.py` over each app's vitest JSON),
the verify-stack check table, and per-spec e2e results (`infra/playwright-summary.py`).
**Requirements / conventions:**
- Requires **Gitea ≥ 1.27** (stores/renders summaries) and **act_runner ≥ 2.0.0**
(uploads them). Older pairings silently skip the upload.
- **Guard every write:** `[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0` — on a runner
without support the var is unset and `>> "$GITHUB_STEP_SUMMARY"` would be an
ambiguous-redirect error. The guard makes the step a no-op locally / on old runners.
- Use `if: always()` (step-level) on summary steps so they render even when the thing
they report on failed. Step-level `always()` is fine on 2.0.0 — unlike the *job*-level
status-function `if` of §7.
- Getting a report out of the e2e container: Playwright writes `playwright-report.json`
inside the container; `infra/run-e2e-check.sh` `docker cp`s it back to the host
(capturing the test exit code first) so the summary step can read it.
+168
View File
@@ -56,6 +56,10 @@ services:
oz-init:
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
environment: &oz-env
# 1 uWSGI worker, not the image default of 4×4 (#147) — idle workers pressure the runner; the
# -init/-celery containers share this anchor and ignore it (they don't run uwsgi).
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: openzaak.conf.docker
SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: oz-db
@@ -138,6 +142,9 @@ services:
# bind-mounted here (this twin is the local/no-make path). See ADR-0007.
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: &nrc-env
# 1 uWSGI worker, not the image default of 4×4 (#147) — see the oz-env note above.
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: nrc.conf.docker
SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: nrc-db
@@ -331,6 +338,14 @@ services:
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
# Objecten holds the register, OpenZaak holds the process (S-19a, ADR-0028). Both APIs take a
# static token, not a ZGW JWT. The objecttype URL is assigned at seed time, so the ACL resolves
# it by name — lazily, on the first approval, so no depends_on is needed here.
Acl__Objecten__BaseUrl: http://objecten:8000/
Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}
Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/
Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
Acl__Objecten__ObjecttypeName: RegisterRecord
ports:
- "8100:8080"
volumes:
@@ -560,11 +575,164 @@ services:
condition: service_started
networks: [cg]
# ── Objecttypen API (S-18a) — bind-mounted config (local variant) ──────────
objecttypen-db:
image: docker.io/library/postgres:17-alpine
environment:
POSTGRES_USER: objecttypes
POSTGRES_PASSWORD: objecttypes
POSTGRES_DB: objecttypes
volumes:
- objecttypen-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U objecttypes"]
interval: 5s
timeout: 3s
retries: 10
networks: [cg]
objecttypen-redis:
image: docker.io/library/redis:7
networks: [cg]
objecttypen-init:
image: docker.io/maykinmedia/objecttypes-api:${OBJECTTYPES_TAG:-3.4.2}
environment: &objecttypen-env-local
# 1 uWSGI worker, not the image default of 4×4 (#144) — idle workers starve the CI runner.
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: objecttypes.conf.docker
SECRET_KEY: ${OBJECTTYPES_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: objecttypen-db
DB_NAME: objecttypes
DB_USER: objecttypes
DB_PASSWORD: objecttypes
ALLOWED_HOSTS: "*"
CACHE_DEFAULT: objecttypen-redis:6379/0
CACHE_AXES: objecttypen-redis:6379/0
DISABLE_2FA: "true"
OTEL_SDK_DISABLED: "true"
RUN_SETUP_CONFIG: "true"
command: /setup_configuration.sh
volumes:
- ./objecttypen/setup_configuration:/app/setup_configuration:ro,z
depends_on:
objecttypen-db:
condition: service_healthy
objecttypen-redis:
condition: service_started
networks: [cg]
objecttypen:
image: docker.io/maykinmedia/objecttypes-api:${OBJECTTYPES_TAG:-3.4.2}
environment: *objecttypen-env-local
healthcheck:
test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
ports:
- "8020:8000"
depends_on:
objecttypen-init:
condition: service_completed_successfully
networks: [cg]
# ── RegisterRecord objecttype (S-18c) — API-seeded one-shot (local variant) ─
registerrecord-init:
image: docker.io/library/python:3-slim
environment:
OBJECTTYPEN: http://objecttypen:8000
OBJECTTYPEN_TOKEN: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
SCHEMA: /config/registerrecord.schema.json
command: python /config/register.py
volumes:
- ./objecttypen-registerrecord:/config:ro,z
depends_on:
objecttypen:
condition: service_healthy
networks: [cg]
# ── Objecten API (S-18b) — bind-mounted config (local variant) ─────────────
objecten-db:
image: docker.io/postgis/postgis:17-3.5
environment:
POSTGRES_USER: objects
POSTGRES_PASSWORD: objects
POSTGRES_DB: objects
volumes:
- objecten-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U objects"]
interval: 5s
timeout: 3s
retries: 10
networks: [cg]
objecten-redis:
image: docker.io/library/redis:7
networks: [cg]
objecten-init:
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
environment: &objecten-env-local
# 1 uWSGI worker, not the image default of 4×4 (#144) — idle workers starve the CI runner.
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: objects.conf.docker
SECRET_KEY: ${OBJECTS_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: objecten-db
DB_NAME: objects
DB_USER: objects
DB_PASSWORD: objects
ALLOWED_HOSTS: "*"
CACHE_DEFAULT: objecten-redis:6379/0
CACHE_AXES: objecten-redis:6379/0
DISABLE_2FA: "true"
OTEL_SDK_DISABLED: "true"
# S-19a: Objecten refuses every write while its Notificaties config is absent
# (notifications_api_common raises rather than skipping, so POST /objects 500s). Objecten →
# NRC is not wired yet — there is no broker, worker, kanaal or abonnement for it — so turn
# notifications off rather than fake a delivery path that silently drops every message.
# S-19b (#150) sources the projection from Objecten and turns this back on for real.
NOTIFICATIONS_DISABLED: "true"
RUN_SETUP_CONFIG: "true"
command: /setup_configuration.sh
volumes:
- ./objecten/setup_configuration:/app/setup_configuration:ro,z
depends_on:
objecten-db:
condition: service_healthy
objecten-redis:
condition: service_started
objecttypen:
condition: service_healthy
networks: [cg]
objecten:
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
environment: *objecten-env-local
healthcheck:
test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
ports:
- "8021:8000"
depends_on:
objecten-init:
condition: service_completed_successfully
networks: [cg]
volumes:
oz-db:
nrc-db:
flowable-db:
projection-db:
objecttypen-db:
objecten-db:
# Carries the seed-generated acl.env (server-assigned zaaktype URLs) from local-seed to the ACL.
seed-env:
+286
View File
@@ -51,6 +51,12 @@ services:
oz-init:
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
environment: &oz-env
# 1 uWSGI worker, not the image default of 4×4 (#147, same lever as #145): OpenZaak serves
# single-request smoke checks here and is not load-tested, so 4 idle Django workers just pin
# ~800 MB and pressure the shared runner. The -init (setup_configuration) and -celery containers
# share this anchor and ignore it — they don't run uwsgi.
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: openzaak.conf.docker
SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: oz-db
@@ -135,6 +141,9 @@ services:
# needs no baked config.
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: &nrc-env
# 1 uWSGI worker, not the image default of 4×4 (#147) — see the oz-env note above.
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: nrc.conf.docker
SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: nrc-db
@@ -296,6 +305,10 @@ services:
dockerfile: Dockerfile
image: register-referentie/acl:dev
environment:
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_SERVICE_NAME: acl
# Overridable so verify-domain can point the ACL at the same OpenZaak host that
# owns the seeded zaaktype URL (host-consistent zaak creation, ADR-0009).
Acl__OpenZaak__BaseUrl: ${ACL_OPENZAAK_BASEURL:-http://openzaak:8000/}
@@ -310,6 +323,14 @@ services:
# so verify-domain still points the ACL at OpenZaak's container IP.
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
# Objecten holds the register, OpenZaak holds the process (S-19a, ADR-0028). Both APIs take a
# static token, not a ZGW JWT. The objecttype URL is assigned at seed time, so the ACL resolves
# it by name — lazily, on the first approval, so no depends_on is needed here.
Acl__Objecten__BaseUrl: http://objecten:8000/
Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}
Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/
Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
Acl__Objecten__ObjecttypeName: RegisterRecord
ports:
- "8100:8080"
healthcheck:
@@ -334,6 +355,10 @@ services:
dockerfile: Dockerfile
image: register-referentie/domain:dev
environment:
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_SERVICE_NAME: domain
Flowable__BaseUrl: http://flowable-rest:8080/flowable-rest/
Flowable__Username: rest-admin
Flowable__Password: test
@@ -360,6 +385,10 @@ services:
dockerfile: Dockerfile
image: register-referentie/bff:dev
environment:
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_SERVICE_NAME: bff
# The BFF is the portals' only backend; it validates digid tokens and fans out (ADR-0010).
# Keycloak (start-dev) derives the issuer from the request host, so the BFF authority and the
# verify token request both use keycloak:8080 to keep the issuer consistent.
@@ -368,6 +397,8 @@ services:
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
Downstream__Domain__BaseUrl: http://domain:8080/
Downstream__Projection__BaseUrl: http://projection-api:8080/
# The beheer catalogus read reaches the ACL directly (S-15a, ADR-0025).
Downstream__Acl__BaseUrl: http://acl:8080/
ports:
- "8080:8080"
healthcheck:
@@ -412,6 +443,10 @@ services:
dockerfile: services/event-subscriber/Dockerfile
image: register-referentie/event-subscriber:dev
environment:
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_SERVICE_NAME: event-subscriber
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
# The subscriber enriches the projection with each zaak's reference (identificatie) by asking
# the ACL — the only code allowed to read ZGW (§8.1, #78).
@@ -441,6 +476,10 @@ services:
dockerfile: services/projection-api/Dockerfile
image: register-referentie/projection-api:dev
environment:
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_SERVICE_NAME: projection-api
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
ports:
- "8120:8080"
@@ -524,11 +563,249 @@ services:
condition: service_started
networks: [cg]
# The beheer portal: nginx serves the Angular app and reverse-proxies /beheer to the BFF.
# Beheerders log in against the Keycloak medewerker realm (same realm as behandel, S-15a).
beheer:
build:
context: ..
dockerfile: apps/beheer/Dockerfile
image: register-referentie/beheer:dev
ports:
- "8143:80"
healthcheck:
# 127.0.0.1, not localhost: nginx listens on IPv4 only, but localhost resolves to ::1 first.
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
bff:
condition: service_healthy
keycloak:
condition: service_started
networks: [cg]
# ── Objecttypen API (S-18a) — upstream Maykin image, verbatim ──────────────
# The register's objecttype catalogue. Same shape as the other CG modules: own DB + redis, an
# `-init` that runs setup_configuration (RUN_SETUP_CONFIG → migrate + provision a static API token)
# from the external config volume streamed in by infra/seed-config.sh, and a health-checked web
# service that depends on init completing.
objecttypen-db:
image: docker.io/library/postgres:17-alpine
environment:
POSTGRES_USER: objecttypes
POSTGRES_PASSWORD: objecttypes
POSTGRES_DB: objecttypes
volumes:
- objecttypen-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U objecttypes"]
interval: 5s
timeout: 3s
retries: 10
networks: [cg]
objecttypen-redis:
image: docker.io/library/redis:7
networks: [cg]
objecttypen-init:
image: docker.io/maykinmedia/objecttypes-api:${OBJECTTYPES_TAG:-3.4.2}
environment: &objecttypen-env
# 1 uWSGI worker, not the image default of 4×4: this API only serves single-request smoke
# checks and sits idle during the e2e step — 4 idle Django workers each pin ~200 MB and starve
# the shared CI runner (#144). Init ignores this (it runs setup_configuration, not uwsgi).
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: objecttypes.conf.docker
SECRET_KEY: ${OBJECTTYPES_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: objecttypen-db
DB_NAME: objecttypes
DB_USER: objecttypes
DB_PASSWORD: objecttypes
ALLOWED_HOSTS: "*"
CACHE_DEFAULT: objecttypen-redis:6379/0
CACHE_AXES: objecttypen-redis:6379/0
DISABLE_2FA: "true"
OTEL_SDK_DISABLED: "true"
RUN_SETUP_CONFIG: "true"
command: /setup_configuration.sh
# data.yaml is streamed into this external volume by infra/seed-config.sh before start.
volumes:
- objecttypen-config:/app/setup_configuration:ro
depends_on:
objecttypen-db:
condition: service_healthy
objecttypen-redis:
condition: service_started
networks: [cg]
objecttypen:
image: docker.io/maykinmedia/objecttypes-api:${OBJECTTYPES_TAG:-3.4.2}
environment: *objecttypen-env
healthcheck:
test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
ports:
- "8020:8000"
depends_on:
objecttypen-init:
condition: service_completed_successfully
networks: [cg]
# ── RegisterRecord objecttype (S-18c) — API-seeded one-shot ────────────────
# The Objecttypen setup_configuration (3.4.2) can only provision tokens — no declarative objecttype
# step — so this one-shot creates the RegisterRecord objecttype + a published version over the API
# once Objecttypen is healthy (idempotent; ADR-0020 self-seed, ADR-0027 schema). The schema + script
# are streamed into the external config volume by infra/seed-config.sh, like the *-init volumes.
registerrecord-init:
image: docker.io/library/python:3-slim
environment:
OBJECTTYPEN: http://objecttypen:8000
OBJECTTYPEN_TOKEN: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
SCHEMA: /config/registerrecord.schema.json
command: python /config/register.py
volumes:
- registerrecord-config:/config:ro
depends_on:
objecttypen:
condition: service_healthy
networks: [cg]
# ── Objecten API (S-18b) — upstream Maykin image, verbatim ─────────────────
# The authoritative object store. Same shape as Objecttypen (own DB + redis, an `-init` that runs
# setup_configuration from the external config volume, a health-checked web). Two differences: the
# DB is PostGIS (objects carry geometry), and setup_configuration registers the Objecttypen API
# (S-18a) as a trusted service so an object can reference its objecttype.
objecten-db:
image: docker.io/postgis/postgis:17-3.5
environment:
POSTGRES_USER: objects
POSTGRES_PASSWORD: objects
POSTGRES_DB: objects
volumes:
- objecten-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U objects"]
interval: 5s
timeout: 3s
retries: 10
networks: [cg]
objecten-redis:
image: docker.io/library/redis:7
networks: [cg]
objecten-init:
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
environment: &objecten-env
# 1 uWSGI worker, not the image default of 4×4 — see the objecttypen note above (#144).
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: objects.conf.docker
SECRET_KEY: ${OBJECTS_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: objecten-db
DB_NAME: objects
DB_USER: objects
DB_PASSWORD: objects
ALLOWED_HOSTS: "*"
CACHE_DEFAULT: objecten-redis:6379/0
CACHE_AXES: objecten-redis:6379/0
DISABLE_2FA: "true"
OTEL_SDK_DISABLED: "true"
# S-19a: Objecten refuses every write while its Notificaties config is absent
# (notifications_api_common raises rather than skipping, so POST /objects 500s). Objecten →
# NRC is not wired yet — there is no broker, worker, kanaal or abonnement for it — so turn
# notifications off rather than fake a delivery path that silently drops every message.
# S-19b (#150) sources the projection from Objecten and turns this back on for real.
NOTIFICATIONS_DISABLED: "true"
RUN_SETUP_CONFIG: "true"
command: /setup_configuration.sh
# data.yaml is streamed into this external volume by infra/seed-config.sh before start.
volumes:
- objecten-config:/app/setup_configuration:ro
depends_on:
objecten-db:
condition: service_healthy
objecten-redis:
condition: service_started
# Objecten's setup_configuration registers the Objecttypen service; that service only needs to
# exist as config, but wait for Objecttypen to be up so the register is meaningful end to end.
objecttypen:
condition: service_healthy
networks: [cg]
objecten:
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
environment: *objecten-env
healthcheck:
test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
ports:
- "8021:8000"
depends_on:
objecten-init:
condition: service_completed_successfully
networks: [cg]
# ── Observability backplane (S-16a, ADR-0023) ──────────────────────────────
# Grafana-native stack: Tempo ingests OTLP traces (the .NET services export
# straight to it — no collector hop, S-16b), Prometheus scrapes service
# /metrics (S-16c), and Grafana reads both with datasources auto-provisioned.
# Config is baked into small built images (COPY) rather than streamed into
# external config volumes like the upstream CG modules — these aren't verbatim
# peer images, so a built image is the simpler path that still reaches sibling
# containers on the CI runner. Not in WAIT_SVCS: run-observability-check.sh
# polls Grafana itself, so no in-image healthcheck tool is needed.
tempo:
build:
context: ./observability/tempo
image: register-referentie/tempo:dev
command: ["-config.file=/etc/tempo.yaml"]
# Cap the backplane's footprint so it can't starve the app stack + the Playwright browser on the
# memory-tight CI runner (verify-e2e OOM history, commit d5e5fa2). Generous vs idle (~150M).
mem_limit: 400m
networks: [cg]
prometheus:
build:
context: ./observability/prometheus
image: register-referentie/prometheus:dev
mem_limit: 400m
ports:
- "9090:9090"
networks: [cg]
grafana:
build:
context: ./observability/grafana
image: register-referentie/grafana:dev
mem_limit: 512m
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: admin
GF_AUTH_ANONYMOUS_ENABLED: "true"
ports:
- "3000:3000"
depends_on:
- tempo
- prometheus
networks: [cg]
volumes:
oz-db:
nrc-db:
flowable-db:
projection-db:
objecttypen-db:
objecten-db:
# Config volumes — created and populated out-of-band by infra/seed-config.sh
# (docker cp), because bind mounts don't reach sibling containers on the CI
# runner. `external` keeps the names deterministic; the seed step manages them.
@@ -544,6 +821,15 @@ volumes:
fl-bpmn:
external: true
name: rr-fl-bpmn
objecttypen-config:
external: true
name: rr-objecttypen-config
registerrecord-config:
external: true
name: rr-registerrecord-config
objecten-config:
external: true
name: rr-objecten-config
networks:
cg:
+12 -1
View File
@@ -5,7 +5,8 @@
"roles": {
"realm": [
{ "name": "behandelaar", "description": "Behandelt registratieaanvragen" },
{ "name": "teamlead", "description": "Teamleider behandeling" }
{ "name": "teamlead", "description": "Teamleider behandeling" },
{ "name": "beheerder", "description": "Beheert catalogus en default-fill (beheer-portal, S-15)" }
]
},
"clients": [
@@ -54,6 +55,16 @@
"emailVerified": true,
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
"realmRoles": ["behandelaar", "teamlead"]
},
{
"username": "bram-beheerder",
"enabled": true,
"firstName": "Bram",
"lastName": "Beheerder",
"email": "bram@big.example.nl",
"emailVerified": true,
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
"realmRoles": ["beheerder"]
}
]
}
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""S-16c (#124): prove the golden-signal metrics pipeline works end to end.
Generate anonymous BFF traffic (GET /openbaar/register — no auth, no OpenZaak egress),
then query Prometheus and assert (1) every .NET service's scrape target is UP, and (2)
the http.server.request.duration histogram is actually being scraped — i.e. the services
expose /metrics AND Prometheus collects it, which is exactly what the golden-signal
dashboard reads.
Stdlib only (urllib/json) so it runs in a bare python:3-slim container in-network.
"""
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
BFF = os.environ["BFF"] # http://<bff-ip>:8080
PROM = os.environ["PROMETHEUS"] # http://<prometheus-ip>:9090
TIMEOUT = int(os.environ.get("METRICS_TIMEOUT", "90"))
SERVICES = {"acl", "domain", "bff", "event-subscriber", "projection-api"}
def _get(url):
with urllib.request.urlopen(url, timeout=10) as r:
return r.read()
def generate_traffic():
for _ in range(3):
try:
_get(f"{BFF}/openbaar/register")
except urllib.error.HTTPError:
pass # a non-2xx still records an http.server metric
def query(promql):
q = urllib.parse.quote(promql)
try:
data = json.loads(_get(f"{PROM}/api/v1/query?query={q}"))
except Exception:
return []
return data.get("data", {}).get("result", [])
def jobs_up():
return {r["metric"].get("job") for r in query("up == 1")}
def jobs_with_request_metric():
return {r["metric"].get("job")
for r in query("http_server_request_duration_seconds_count")}
def main():
deadline = time.time() + TIMEOUT
while time.time() < deadline:
generate_traffic()
up = jobs_up()
scraped = jobs_with_request_metric()
if SERVICES.issubset(up) and SERVICES.issubset(scraped):
print(f"OK — targets up: {sorted(up & SERVICES)}; "
f"request metric scraped from: {sorted(scraped & SERVICES)}")
return 0
time.sleep(3)
print(f"FAIL — up: {sorted(jobs_up() & SERVICES)}; "
f"request metric from: {sorted(jobs_with_request_metric() & SERVICES)}; "
f"expected all of {sorted(SERVICES)}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""S-18b (#140): prove the Objecten API is up and its static token authenticates.
Assert an unauthenticated call to /api/v2/objects is 401 and an authenticated one (the seeded dev
token) is 200 — i.e. the service migrated, booted, and setup_configuration provisioned the token
and the Objecttypen service it trusts. Stdlib only so it runs in a bare python:3-slim container on
the compose network.
"""
import os
import sys
import time
import urllib.error
import urllib.request
BASE = os.environ["OBJECTEN"] # http://<ip>:8000
TOKEN = os.environ["OBJECTEN_TOKEN"]
TIMEOUT = int(os.environ.get("OBJECTEN_TIMEOUT", "60"))
def status(url, token=None):
req = urllib.request.Request(url)
if token:
req.add_header("Authorization", f"Token {token}")
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.status
except urllib.error.HTTPError as e:
return e.code
except Exception:
return 0
def main():
url = f"{BASE}/api/v2/objects"
deadline = time.time() + TIMEOUT
while time.time() < deadline:
unauth = status(url)
authed = status(url, TOKEN)
if unauth == 401 and authed == 200:
print(f"OK — {url}: no-auth {unauth}, token {authed}")
return 0
time.sleep(3)
print(f"FAIL — {url}: expected no-auth 401 + token 200, got {status(url)} / {status(url, TOKEN)}",
file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,42 @@
# Objecten API setup_configuration (S-18b). Streamed into the external rr-objecten-config volume by
# infra/seed-config.sh and applied by objecten-init (RUN_SETUP_CONFIG). Declarative + idempotent.
#
# Two things: (1) register the Objecttypen API (S-18a) as a trusted service so an object can
# reference its objecttype — authenticating with the dev static token Objecttypen provisioned; and
# (2) a dev static token so peers (the ACL, S-19) can write objects here. Dev-only, not for prod.
# (1) Trust the Objecttypen API. `orc` = overige RESTful component (how zgw_consumers classifies the
# Objecttypen API). The RegisterRecord objecttype (S-18c) will reference an objecttype under this
# service by uuid.
zgw_consumers_config_enable: true
zgw_consumers:
services:
- identifier: objecttypen
label: Objecttypen API
api_type: orc
api_root: http://objecttypen:8000/api/v2/
auth_type: api_key
header_key: Authorization
header_value: Token 0123456789abcdef0123456789abcdef01234567
# (2) Permit the RegisterRecord objecttype (S-19a). Objecten refuses to store an object whose
# objecttype it has not been configured with ("ObjectType with url=… is not configured"), and it
# identifies one by uuid — which is why infra/objecttypen-registerrecord/register.py pins that uuid
# instead of letting Objecttypen assign one. Keep the two in step.
objecttypes_config_enable: true
objecttypes:
items:
- uuid: 1f4b4e26-8b1f-4e2f-9d6c-6a1b7a2f0e01
name: RegisterRecord
service_identifier: objecttypen
# (3) Static API token peers use to write/read objects.
tokenauth_config_enable: true
tokenauth:
items:
- identifier: register-referentie
token: 1234567890abcdef1234567890abcdef12345678
contact_person: Register Referentie
email: admin@localhost
organization: Respellion
is_superuser: true
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""S-18a (#139): prove the Objecttypen API is up and its static token authenticates.
Assert an unauthenticated call to /api/v2/objecttypes is 401 and an authenticated one (the seeded
dev token) is 200 — i.e. the service migrated, booted, and setup_configuration provisioned the token.
Stdlib only so it runs in a bare python:3-slim container on the compose network.
"""
import os
import sys
import time
import urllib.error
import urllib.request
BASE = os.environ["OBJECTTYPEN"] # http://<ip>:8000
TOKEN = os.environ["OBJECTTYPEN_TOKEN"]
TIMEOUT = int(os.environ.get("OBJECTTYPEN_TIMEOUT", "60"))
def status(url, token=None):
req = urllib.request.Request(url)
if token:
req.add_header("Authorization", f"Token {token}")
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.status
except urllib.error.HTTPError as e:
return e.code
except Exception:
return 0
def main():
url = f"{BASE}/api/v2/objecttypes"
deadline = time.time() + TIMEOUT
while time.time() < deadline:
unauth = status(url)
authed = status(url, TOKEN)
if unauth == 401 and authed == 200:
print(f"OK — {url}: no-auth {unauth}, token {authed}")
return 0
time.sleep(3)
print(f"FAIL — {url}: expected no-auth 401 + token 200, got {status(url)} / {status(url, TOKEN)}",
file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""S-18c (#141): register the RegisterRecord objecttype + a published version in the Objecttypen API.
Run by the `registerrecord-init` compose one-shot once Objecttypen is healthy. The Objecttypen API's
setup_configuration (3.4.2) can only provision tokens — it has no declarative objecttype step — so
the objecttype is created over the API here (the ADR-0020 self-seed pattern), idempotently: if a
"RegisterRecord" objecttype with a published version already exists, it's a no-op. Stdlib only.
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
BASE = os.environ.get("OBJECTTYPEN", "http://objecttypen:8000").rstrip("/")
TOKEN = os.environ["OBJECTTYPEN_TOKEN"]
SCHEMA_PATH = os.environ.get("SCHEMA", "/config/registerrecord.schema.json")
NAME = "RegisterRecord"
# Pinned rather than server-assigned (S-19a): the Objecten API will only accept objects whose
# objecttype it has been configured with *by uuid*, and its own setup_configuration is a static
# file applied before this one-shot runs. A fixed uuid lets both sides be declared up front instead
# of threading a seed-time value between two containers. See infra/objecten/setup_configuration.
UUID = "1f4b4e26-8b1f-4e2f-9d6c-6a1b7a2f0e01"
def api(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
f"{BASE}{path}", data=data, method=method,
headers={"Authorization": f"Token {TOKEN}", "Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=15) as r:
return json.load(r) if r.length != 0 else {}
def wait_ready():
"""Objecttypen depends_on health already, but tolerate a slow first request."""
for _ in range(20):
try:
api("GET", "/api/v2/objecttypes")
return
except (urllib.error.URLError, ConnectionError, TimeoutError):
time.sleep(3)
api("GET", "/api/v2/objecttypes") # last try, let it raise
def main():
schema = json.load(open(SCHEMA_PATH))
wait_ready()
existing = next(
(o for o in api("GET", "/api/v2/objecttypes").get("results", []) if o.get("name") == NAME),
None,
)
if existing and existing.get("versions"):
print(f"RegisterRecord already registered ({len(existing['versions'])} version(s)) — no-op")
return 0
ot = existing or api("POST", "/api/v2/objecttypes", {
"uuid": UUID,
"name": NAME,
"namePlural": "RegisterRecords",
"description": schema.get("description", ""),
"dataClassification": "open", # public-safe: the openbaar register may show it
})
uuid = ot["uuid"]
ver = api("POST", f"/api/v2/objecttypes/{uuid}/versions", {
"status": "published",
"jsonSchema": schema,
})
print(f"registered RegisterRecord {uuid} v{ver.get('version')} ({ver.get('status')})")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,23 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "RegisterRecord",
"description": "Public-safe register entry shown in the openbaar (public) register. Mirrors the BFF's OpenbaarEntry (services/bff/Bff.Api/DownstreamClients.cs) — deliberately NO bsn or naam. S-19 (#20) writes records against this schema in the Objecten API on approval. See ADR-0027.",
"type": "object",
"additionalProperties": false,
"required": ["id", "status"],
"properties": {
"id": {
"type": "string",
"description": "Zaak id — the register entry's stable primary key (the projection key)."
},
"status": {
"type": "string",
"enum": ["INGEDIEND", "INGESCHREVEN"],
"description": "Registration lifecycle status (RegistrationStatus)."
},
"reference": {
"type": ["string", "null"],
"description": "Citizen-facing zaak identificatie shown publicly (ADR-0012)."
}
}
}
@@ -0,0 +1,11 @@
# Objecttypen API setup_configuration (S-18a). Streamed into the external rr-objecttypen-config
# volume by infra/seed-config.sh and applied by objecttypen-init (RUN_SETUP_CONFIG). Declarative +
# idempotent. Dev-only static token so peers (Objecten S-18b, the ACL) can authenticate.
tokenauth_config_enable: true
tokenauth:
items:
- identifier: register-referentie
token: 0123456789abcdef0123456789abcdef01234567
contact_person: Register Referentie
email: admin@localhost
organization: Respellion
+4
View File
@@ -0,0 +1,4 @@
# Grafana with datasources + the golden-signals dashboard baked in via provisioning
# (S-16a/S-16c, ADR-0023). Everything under provisioning/ is copied in below.
FROM grafana/grafana:11.3.0
COPY provisioning/ /etc/grafana/provisioning/
@@ -0,0 +1,13 @@
# Dashboard provider (S-16c, ADR-0023): Grafana loads every *.json in this folder as a
# read-only, code-owned dashboard. The golden-signals board is versioned here, not
# clicked together in the UI.
apiVersion: 1
providers:
- name: register-referentie
type: file
disableDeletion: true
allowUiUpdates: false
options:
path: /etc/grafana/provisioning/dashboards
foldersFromFilesStructure: false
@@ -0,0 +1,87 @@
{
"uid": "golden-signals",
"title": "Request path — golden signals",
"tags": ["s-16c", "golden-signals"],
"timezone": "browser",
"schemaVersion": 39,
"version": 1,
"editable": true,
"refresh": "10s",
"time": { "from": "now-15m", "to": "now" },
"templating": {
"list": [
{
"name": "job",
"type": "query",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"query": "label_values(http_server_request_duration_seconds_count, job)",
"includeAll": true,
"multi": true,
"current": { "text": "All", "value": "$__all" },
"refresh": 2
}
]
},
"panels": [
{
"id": 1,
"title": "Traffic — requests/sec",
"type": "timeseries",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"fieldConfig": { "defaults": { "unit": "reqps", "custom": { "drawStyle": "line", "fillOpacity": 10 } }, "overrides": [] },
"targets": [
{
"refId": "A",
"expr": "sum by (job) (rate(http_server_request_duration_seconds_count{job=~\"$job\"}[$__rate_interval]))",
"legendFormat": "{{job}}"
}
]
},
{
"id": 2,
"title": "Errors — 5xx responses/sec",
"type": "timeseries",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"fieldConfig": { "defaults": { "unit": "reqps", "custom": { "drawStyle": "line", "fillOpacity": 10 }, "color": { "mode": "fixed", "fixedColor": "red" } }, "overrides": [] },
"targets": [
{
"refId": "A",
"expr": "sum by (job) (rate(http_server_request_duration_seconds_count{job=~\"$job\",http_response_status_code=~\"5..\"}[$__rate_interval]))",
"legendFormat": "{{job}}"
}
]
},
{
"id": 3,
"title": "Latency — p95 request duration",
"type": "timeseries",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"fieldConfig": { "defaults": { "unit": "s", "custom": { "drawStyle": "line", "fillOpacity": 10 } }, "overrides": [] },
"targets": [
{
"refId": "A",
"expr": "histogram_quantile(0.95, sum by (job, le) (rate(http_server_request_duration_seconds_bucket{job=~\"$job\"}[$__rate_interval])))",
"legendFormat": "{{job}} p95"
}
]
},
{
"id": 4,
"title": "Saturation — CPU cores in use",
"type": "timeseries",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
"fieldConfig": { "defaults": { "unit": "none", "custom": { "drawStyle": "line", "fillOpacity": 10 } }, "overrides": [] },
"targets": [
{
"refId": "A",
"expr": "sum by (job) (rate(dotnet_process_cpu_time_seconds_total{job=~\"$job\"}[$__rate_interval]))",
"legendFormat": "{{job}}"
}
]
}
]
}
@@ -0,0 +1,17 @@
# Auto-provisioned datasources (S-16a, ADR-0023). Fixed uids so dashboards (S-16c)
# and the verify-observability check can reference them by a stable id.
apiVersion: 1
datasources:
- name: Prometheus
uid: prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
- name: Tempo
uid: tempo
type: tempo
access: proxy
url: http://tempo:3200
@@ -0,0 +1,2 @@
FROM prom/prometheus:v2.55.1
COPY prometheus.yml /etc/prometheus/prometheus.yml
@@ -0,0 +1,27 @@
# Prometheus scrape config (S-16c, ADR-0023). Each .NET service exposes OTel metrics
# at /metrics (Prometheus text format); one scrape job per service, so the service is
# identified by the `job` label in the golden-signal dashboard. Targets are reached by
# compose service name on the shared `cg` network (internal port 8080).
global:
scrape_interval: 15s
scrape_configs:
- job_name: prometheus
static_configs:
- targets: ['localhost:9090']
- job_name: acl
static_configs:
- targets: ['acl:8080']
- job_name: domain
static_configs:
- targets: ['domain:8080']
- job_name: bff
static_configs:
- targets: ['bff:8080']
- job_name: event-subscriber
static_configs:
- targets: ['event-subscriber:8080']
- job_name: projection-api
static_configs:
- targets: ['projection-api:8080']
+4
View File
@@ -0,0 +1,4 @@
# Tempo with our config baked in — so it reaches sibling containers on the CI
# runner without the external-config-volume dance the upstream CG images need.
FROM grafana/tempo:2.6.1
COPY tempo.yaml /etc/tempo.yaml
+27
View File
@@ -0,0 +1,27 @@
# Grafana Tempo — single-binary, all-in-one, local storage (S-16a, ADR-0023).
# Ingests OTLP directly (services export straight to Tempo; no collector hop).
# Storage is ephemeral container fs — this is a local/CI demo backplane, not a
# retention target. ponytail: local backend, swap for object storage if traces
# must outlive the stack.
server:
http_listen_port: 3200
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
ingester:
max_block_duration: 5m
storage:
trace:
backend: local
local:
path: /var/tempo/blocks
wal:
path: /var/tempo/wal
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Render a per-spec table from a Playwright JSON report for a Gitea job summary (#136).
Reads the JSON report (default: tests/e2e/playwright-report.json) that run-e2e-check.sh copies out
of the e2e container, and prints a markdown table (one row per spec) to stdout. The CI step
redirects it into $GITHUB_STEP_SUMMARY. Stdlib only.
"""
import json
import os
import sys
STATUS_ICON = {"expected": "", "unexpected": "", "skipped": "⏭️", "flaky": "⚠️"}
def walk(suite, out):
for spec in suite.get("specs", []):
# A spec's status is carried on its test(s): expected/unexpected/skipped/flaky.
statuses = [t.get("status") for t in spec.get("tests", [])]
status = ("unexpected" if "unexpected" in statuses
else "flaky" if "flaky" in statuses
else "skipped" if statuses and all(s == "skipped" for s in statuses)
else "expected" if spec.get("ok", False)
else "unexpected")
out.append({"file": spec.get("file") or suite.get("file") or suite.get("title", ""),
"title": spec.get("title", ""), "status": status})
for child in suite.get("suites", []):
walk(child, out)
def main(path):
if not os.path.exists(path):
print("## 🎭 e2e (Playwright)\n\n_No e2e report — the run did not reach the e2e step._")
return 0
with open(path) as fh:
report = json.load(fh)
specs = []
for suite in report.get("suites", []):
walk(suite, specs)
print("## 🎭 e2e (Playwright)\n")
stats = report.get("stats", {})
if stats:
print(f"**{stats.get('expected', 0)} passed · {stats.get('unexpected', 0)} failed · "
f"{stats.get('flaky', 0)} flaky · {stats.get('skipped', 0)} skipped** "
f"({round(stats.get('duration', 0) / 1000)}s)\n")
if not specs:
print("_No specs ran._")
return 0
print("| Spec | Result |")
print("| ---- | :----: |")
for s in specs:
print(f"| {s['file']} {s['title']} | {STATUS_ICON.get(s['status'], '')} |")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "tests/e2e/playwright-report.json"))
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""S-18c (#141): prove the RegisterRecord objecttype is registered + published in the Objecttypen API.
Assert the objecttype named "RegisterRecord" exists, has a **published** version, and that version's
jsonSchema carries the public-safe fields (id, status, reference) — i.e. registerrecord-init ran and
seeded the schema S-19 will write records against. Stdlib only so it runs in a bare python:3-slim
container on the compose network.
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
BASE = os.environ["OBJECTTYPEN"] # http://<ip>:8000
TOKEN = os.environ["OBJECTTYPEN_TOKEN"]
TIMEOUT = int(os.environ.get("REGISTERRECORD_TIMEOUT", "60"))
NAME = "RegisterRecord"
EXPECTED_FIELDS = {"id", "status", "reference"}
def get(path):
req = urllib.request.Request(f"{BASE}{path}", headers={"Authorization": f"Token {TOKEN}"})
with urllib.request.urlopen(req, timeout=10) as r:
return json.load(r)
def check():
"""Return (ok, detail). Raises on transport errors so the caller can retry."""
ots = get("/api/v2/objecttypes").get("results", [])
match = next((o for o in ots if o.get("name") == NAME), None)
if not match:
return False, f"no objecttype named {NAME!r} (have: {[o.get('name') for o in ots]})"
if not match.get("versions"):
return False, f"{NAME} exists but has no versions"
# The versions list holds URLs; fetch each to find a published one.
for ver_url in match["versions"]:
ver = get(ver_url[len(BASE):] if ver_url.startswith(BASE) else ver_url)
if ver.get("status") != "published":
continue
props = set((ver.get("jsonSchema") or {}).get("properties", {}))
if not EXPECTED_FIELDS <= props:
return False, f"published version missing fields: {EXPECTED_FIELDS - props}"
return True, f"{NAME} v{ver.get('version')} published, fields={sorted(props)}"
return False, f"{NAME} has versions but none are published"
def main():
deadline = time.time() + TIMEOUT
detail = "no attempt"
while time.time() < deadline:
try:
ok, detail = check()
if ok:
print(f"OK — {detail}")
return 0
except (urllib.error.URLError, ConnectionError, TimeoutError) as e:
detail = f"transport: {e}"
time.sleep(3)
print(f"FAIL — {detail}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
+1
View File
@@ -142,6 +142,7 @@ still="$(printf '%s' "$resp" | task_for_reg "$reg_id")"
[ -z "$still" ] || { echo "FAIL — Beoordelen task $still still active after completion" >&2; exit 1; }
echo "OK — behandelaar claimed and completed the Beoordelen task; the registratie process finished"
# ── S-11: withdrawal. A second registration parks at Beoordelen; the citizen withdraws it via the
# domain, which delivers the RegistratieIngetrokken message to the task's execution, tripping the
# BPMN boundary event so the process ends and the Beoordelen task disappears (ADR-0014). ────────────
+5 -1
View File
@@ -26,4 +26,8 @@ cid="$(docker create --network "$net" -w /e2e --ipc=host \
mcr.microsoft.com/playwright:v1.61.1-noble sh -c 'npm install --no-audit --no-fund && npx playwright test')"
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
docker cp "$root/tests/e2e/." "$cid:/e2e" >/dev/null
docker start -a "$cid"
rc=0
docker start -a "$cid" || rc=$?
# Copy the Playwright JSON report out — regardless of pass/fail — for the CI job summary (#136).
docker cp "$cid:/e2e/playwright-report.json" "$root/tests/e2e/playwright-report.json" 2>/dev/null || true
exit $rc
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
#
# S-16c (#124): assert the golden-signal metrics pipeline works — the .NET services expose
# /metrics and Prometheus scrapes them — against an ALREADY-RUNNING full stack. Runs the
# driver in a python:3-slim container on the stack network (services reached by container IP;
# the runner can't reach published ports — gitea-actions-gotchas.md §5/§6). Does NOT manage
# the stack lifecycle.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; }
bff="$(docker ps -q --filter 'name=[-_]bff[-_]' | head -1)"
prom="$(docker ps -q --filter 'name=[-_]prometheus[-_]' | head -1)"
[ -n "$bff" ] && [ -n "$prom" ] || { echo "ERROR: bff and/or prometheus not running — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$bff" | head -1)"
bff_ip="$(ip "$bff")"; prom_ip="$(ip "$prom")"
echo ">> network=$net bff=$bff_ip prometheus=$prom_ip"
cid="$(docker create --network "$net" \
-e "BFF=http://$bff_ip:8080" -e "PROMETHEUS=http://$prom_ip:9090" \
-e "METRICS_TIMEOUT=${METRICS_TIMEOUT:-90}" \
python:3-slim python /metrics-check.py)"
docker cp "$here/metrics-check.py" "$cid:/metrics-check.py" >/dev/null
rc=0; docker start -a "$cid" || rc=$?
docker rm -f "$cid" >/dev/null
exit $rc
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
#
# S-18b (#140): assert the Objecten API is healthy + its static token authenticates, against an
# ALREADY-RUNNING stack. Runs the check in a python:3-slim container on the stack network (the
# service is reached by container IP; the runner can't reach published ports — gitea-actions-gotchas.md
# §5/§6). Does NOT manage the stack lifecycle.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# The dev token provisioned by infra/objecten/setup_configuration/data.yaml.
TOKEN="${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}"
ot="$(docker ps -q --filter 'name=objecten' --filter 'health=healthy' | head -1)"
[ -n "$ot" ] || ot="$(docker ps -q --filter 'name=[-_]objecten[-_]' | head -1)"
[ -n "$ot" ] || { echo "ERROR: no running objecten container — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$ot" | head -1)"
ip="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$ot")"
echo ">> network=$net objecten=$ip"
cid="$(docker create --network "$net" \
-e "OBJECTEN=http://$ip:8000" -e "OBJECTEN_TOKEN=$TOKEN" \
-e "OBJECTEN_TIMEOUT=${OBJECTEN_TIMEOUT:-60}" \
python:3-slim python /objecten-check.py)"
docker cp "$here/objecten-check.py" "$cid:/objecten-check.py" >/dev/null
rc=0; docker start -a "$cid" || rc=$?
docker rm -f "$cid" >/dev/null
exit $rc
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
#
# S-18a (#139): assert the Objecttypen API is healthy + its static token authenticates, against an
# ALREADY-RUNNING stack. Runs the check in a python:3-slim container on the stack network (the
# service is reached by container IP; the runner can't reach published ports — gitea-actions-gotchas.md
# §5/§6). Does NOT manage the stack lifecycle.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# The dev token provisioned by infra/objecttypen/setup_configuration/data.yaml.
TOKEN="${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}"
ot="$(docker ps -q --filter 'name=objecttypen' --filter 'health=healthy' | head -1)"
[ -n "$ot" ] || ot="$(docker ps -q --filter 'name=[-_]objecttypen[-_]' | head -1)"
[ -n "$ot" ] || { echo "ERROR: no running objecttypen container — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$ot" | head -1)"
ip="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$ot")"
echo ">> network=$net objecttypen=$ip"
cid="$(docker create --network "$net" \
-e "OBJECTTYPEN=http://$ip:8000" -e "OBJECTTYPEN_TOKEN=$TOKEN" \
-e "OBJECTTYPEN_TIMEOUT=${OBJECTTYPEN_TIMEOUT:-60}" \
python:3-slim python /objecttypen-check.py)"
docker cp "$here/objecttypen-check.py" "$cid:/objecttypen-check.py" >/dev/null
rc=0; docker start -a "$cid" || rc=$?
docker rm -f "$cid" >/dev/null
exit $rc
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
#
# S-16a (#122): assert the observability backplane is live against an ALREADY-RUNNING
# stack. Runs curl INSIDE the compose network (like the other verify checks) because
# the stack's published ports aren't on the CI runner's localhost — the stack is a set
# of sibling containers on the host daemon. It asks Grafana to reach its provisioned
# datasources — Prometheus via its health method, Tempo via the datasource proxy (Tempo's
# Grafana plugin implements no health method) — so it proves the datasources are wired,
# not merely that the containers started. Polls, so it tolerates a cold Grafana.
#
# Does NOT manage the stack lifecycle (the caller owns bring-up + teardown).
set -euo pipefail
TIMEOUT="${OBS_TIMEOUT:-60}"
AUTH="${GRAFANA_AUTH:-admin:admin}"
gf="$(docker ps -q --filter 'name=[-_]grafana[-_]' | head -1)"
[ -n "$gf" ] || { echo "ERROR: no running grafana container — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$gf" | head -1)"
gf_ip="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$gf")"
base="http://$gf_ip:3000"
echo ">> grafana=$gf_ip network=$net"
# Run curl inside a throwaway container on the stack network (reaches services by IP).
net_curl() { docker run --rm --network "$net" curlimages/curl:latest "$@"; }
# poll <description> <grep -E pattern> <curl args...>
poll() {
local desc="$1" pat="$2"; shift 2
local deadline=$(( $(date +%s) + TIMEOUT ))
while :; do
if net_curl -fsS "$@" 2>/dev/null | grep -Eq "$pat"; then echo "$desc"; return 0; fi
if [ "$(date +%s)" -ge "$deadline" ]; then echo "$desc ($*)" >&2; return 1; fi
sleep 3
done
}
echo "Checking observability backplane at $base ..."
poll "Grafana is healthy" \
'"database":[[:space:]]*"ok"' "$base/api/health"
poll "Prometheus datasource reachable" \
'"status":[[:space:]]*"OK"' -u "$AUTH" "$base/api/datasources/uid/prometheus/health"
poll "Tempo datasource reachable (via Grafana proxy)" \
'"version"' -u "$AUTH" "$base/api/datasources/proxy/uid/tempo/api/status/buildinfo"
echo "Observability backplane OK."
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
#
# S-18c (#141): assert the RegisterRecord objecttype is registered + published in the Objecttypen
# API, against an ALREADY-RUNNING stack. Runs the check in a python:3-slim container on the stack
# network (the service is reached by container IP; the runner can't reach published ports —
# gitea-actions-gotchas.md §5/§6). Does NOT manage the stack lifecycle.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# The dev token provisioned by infra/objecttypen/setup_configuration/data.yaml.
TOKEN="${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}"
ot="$(docker ps -q --filter 'name=objecttypen' --filter 'health=healthy' | head -1)"
[ -n "$ot" ] || ot="$(docker ps -q --filter 'name=[-_]objecttypen[-_]' | head -1)"
[ -n "$ot" ] || { echo "ERROR: no running objecttypen container — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$ot" | head -1)"
ip="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$ot")"
echo ">> network=$net objecttypen=$ip"
cid="$(docker create --network "$net" \
-e "OBJECTTYPEN=http://$ip:8000" -e "OBJECTTYPEN_TOKEN=$TOKEN" \
-e "REGISTERRECORD_TIMEOUT=${REGISTERRECORD_TIMEOUT:-60}" \
python:3-slim python /registerrecord-check.py)"
docker cp "$here/registerrecord-check.py" "$cid:/registerrecord-check.py" >/dev/null
rc=0; docker start -a "$cid" || rc=$?
docker rm -f "$cid" >/dev/null
exit $rc
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
#
# S-16b (#123): assert one connected distributed trace spans the .NET services in Tempo,
# against an ALREADY-RUNNING full stack. Runs the driver in a python:3-slim container on the
# stack network (services reached by container IP; the runner can't reach published ports —
# gitea-actions-gotchas.md §5/§6). Does NOT manage the stack lifecycle.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; }
bff="$(docker ps -q --filter 'name=[-_]bff[-_]' | head -1)"
tempo="$(docker ps -q --filter 'name=[-_]tempo[-_]' | head -1)"
[ -n "$bff" ] && [ -n "$tempo" ] || { echo "ERROR: bff and/or tempo not running — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$bff" | head -1)"
bff_ip="$(ip "$bff")"; tempo_ip="$(ip "$tempo")"
echo ">> network=$net bff=$bff_ip tempo=$tempo_ip"
cid="$(docker create --network "$net" \
-e "BFF=http://$bff_ip:8080" -e "TEMPO=http://$tempo_ip:3200" \
-e "TRACING_TIMEOUT=${TRACING_TIMEOUT:-90}" \
python:3-slim python /tracing-check.py)"
docker cp "$here/tracing-check.py" "$cid:/tracing-check.py" >/dev/null
rc=0; docker start -a "$cid" || rc=$?
docker rm -f "$cid" >/dev/null
exit $rc
+5 -2
View File
@@ -13,7 +13,7 @@
# subcommand. Fixed-name `external` volumes keep the names deterministic across
# both runtimes. See docs/runbooks/gitea-actions-gotchas.md.
#
# Usage: seed-config.sh <key> [<key> ...] where key ∈ { oz, kc, fl }
# Usage: seed-config.sh <key> [<key> ...] where key ∈ { oz, nrc, kc, fl, objecttypen, objecten, registerrecord }
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -33,7 +33,7 @@ populate() { # volume source(file or dir/.)
echo " seeded $vol"
}
[ "$#" -gt 0 ] || { echo "usage: seed-config.sh <oz|nrc|kc|fl> ..." >&2; exit 2; }
[ "$#" -gt 0 ] || { echo "usage: seed-config.sh <oz|nrc|kc|fl|objecttypen|objecten|registerrecord> ..." >&2; exit 2; }
# The registratie process (BPMN) and its diploma-eligibility DMN are deployed as SEPARATE Flowable
# deployments — the process engine and the DMN engine each own theirs (S-13, ADR-0016). flowable-rest
@@ -49,6 +49,9 @@ for key in "$@"; do
oz) populate rr-oz-config "$here/openzaak/setup_configuration/." ;;
nrc) populate rr-nrc-config "$here/opennotificaties/setup_configuration/." ;;
kc) populate rr-kc-realms "$here/keycloak/realms/." ;;
objecttypen) populate rr-objecttypen-config "$here/objecttypen/setup_configuration/." ;;
objecten) populate rr-objecten-config "$here/objecten/setup_configuration/." ;;
registerrecord) populate rr-registerrecord-config "$here/objecttypen-registerrecord/." ;;
fl) d="$(mktemp -d)"; stage_flowable_workflows "$d"; populate rr-fl-bpmn "$d/." ;;
*) echo "unknown seed key: $key" >&2; exit 2 ;;
esac
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""S-16b (#123): prove distributed tracing works end to end.
Generate anonymous BFF traffic (GET /openbaar/register, which the BFF serves by
calling projection-api — no auth, no OpenZaak egress), then query Tempo and assert
that ONE trace contains spans from both `bff` and `projection-api`. That proves the
services export OTLP to Tempo AND that the W3C traceparent propagates across the
HttpClient hop, stitching the request into a single connected trace.
Stdlib only (urllib/json) so it runs in a bare python:3-slim container in-network.
"""
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
BFF = os.environ["BFF"] # http://<bff-ip>:8080
TEMPO = os.environ["TEMPO"] # http://<tempo-ip>:3200
TIMEOUT = int(os.environ.get("TRACING_TIMEOUT", "90"))
WANT = {"bff", "projection-api"} # the two services that must share one trace
def _get(url):
with urllib.request.urlopen(url, timeout=10) as r:
return r.read()
def generate_traffic():
# A non-2xx still produces spans; only total unreachability of the BFF is fatal.
for _ in range(3):
try:
_get(f"{BFF}/openbaar/register")
except urllib.error.HTTPError:
pass
def search_trace_ids():
q = urllib.parse.quote('{ resource.service.name = "bff" }')
try:
data = json.loads(_get(f"{TEMPO}/api/search?q={q}&limit=50"))
except Exception:
return []
return [t["traceID"] for t in data.get("traces", [])]
def services_in_trace(trace_id):
try:
data = json.loads(_get(f"{TEMPO}/api/traces/{trace_id}"))
except Exception:
return set()
names = set()
for batch in data.get("batches", []):
for attr in batch.get("resource", {}).get("attributes", []):
if attr.get("key") == "service.name":
names.add(attr.get("value", {}).get("stringValue"))
return names
def main():
deadline = time.time() + TIMEOUT
generate_traffic()
seen = set()
while time.time() < deadline:
for tid in search_trace_ids():
names = services_in_trace(tid)
seen |= names
if WANT.issubset(names):
print(f"OK — trace {tid} spans {sorted(names)}")
return 0
time.sleep(3)
generate_traffic()
print(f"FAIL — no single trace spanned {sorted(WANT)}; services seen: {sorted(seen)}",
file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Render a per-test-project table from .trx files for a Gitea job summary (#136).
Reads every *.trx in the given directory (default: TestResults), pulls each project's
counters + assembly name, and prints a GitHub/Gitea-flavoured markdown table to stdout.
The CI step redirects that into $GITHUB_STEP_SUMMARY. Stdlib only.
"""
import glob
import os
import sys
import xml.etree.ElementTree as ET
NS = {"t": "http://microsoft.com/schemas/VisualStudio/TeamTest/2010"}
def project_name(root):
# The test assembly path, e.g. …/services/domain/Big.Tests/bin/…/big.tests.dll. Prefer the
# owning service folder (services/<name>) so "domain" shows rather than the opaque "big.tests";
# fall back to the assembly basename for projects outside services/ (e.g. tests/acceptance).
ut = root.find(".//t:TestDefinitions/t:UnitTest", NS)
storage = ut.get("storage") if ut is not None else None
if not storage:
return None
parts = storage.replace("\\", "/").split("/")
if "services" in parts:
return parts[parts.index("services") + 1]
base = os.path.basename(parts[-1])
return base[:-4] if base.lower().endswith(".dll") else base
def parse(path):
root = ET.parse(path).getroot()
c = root.find(".//t:ResultSummary/t:Counters", NS)
if c is None:
return None
total = int(c.get("total", 0))
if total == 0: # e.g. the Integration project, filtered out of the unit run
return None
executed = int(c.get("executed", 0))
passed = int(c.get("passed", 0))
failed = int(c.get("failed", 0)) + int(c.get("error", 0))
skipped = total - executed
return {
"name": project_name(root) or os.path.basename(path),
"passed": passed, "failed": failed, "skipped": skipped, "total": total,
}
def main(results_dir):
rows = [r for r in (parse(p) for p in sorted(glob.glob(os.path.join(results_dir, "*.trx")))) if r]
if not rows:
print("_No test results found._")
return 0
rows.sort(key=lambda r: r["name"])
print("## ✅ Unit tests\n")
print("| Project | Result | Passed | Failed | Skipped | Total |")
print("| ------- | :----: | -----: | -----: | ------: | ----: |")
for r in rows:
status = "" if r["failed"] else ""
print(f"| {r['name']} | {status} | {r['passed']} | {r['failed']} | {r['skipped']} | {r['total']} |")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "TestResults"))
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""Render a per-frontend test table from vitest JSON reports for a Gitea job summary (#136).
Reads every *.json in the given directory (default: test-output), each written by an app's
`test` target (reporters: json, outputFile: {workspaceRoot}/test-output/{projectName}.json), and
prints a markdown table to stdout — one row per frontend app. The CI step redirects it into
$GITHUB_STEP_SUMMARY. Stdlib only.
"""
import glob
import json
import os
import sys
def main(results_dir):
rows = []
for path in sorted(glob.glob(os.path.join(results_dir, "*.json"))):
try:
with open(path) as fh:
d = json.load(fh)
except (OSError, ValueError):
continue
rows.append({
"name": os.path.splitext(os.path.basename(path))[0],
"passed": d.get("numPassedTests", 0),
"failed": d.get("numFailedTests", 0),
"skipped": d.get("numPendingTests", 0) + d.get("numTodoTests", 0),
"total": d.get("numTotalTests", 0),
"ok": d.get("success", False),
})
if not rows:
print("_No frontend test results found._")
return 0
print("## 🅰️ Frontend tests\n")
print("| Frontend | Result | Passed | Failed | Skipped | Total |")
print("| -------- | :----: | -----: | -----: | ------: | ----: |")
for r in rows:
status = "" if r["ok"] and not r["failed"] else ""
print(f"| {r['name']} | {status} | {r['passed']} | {r['failed']} | {r['skipped']} | {r['total']} |")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "test-output"))
@@ -24,6 +24,17 @@ import {
Observable
} from 'rxjs';
export interface BeheerDefaultFill {
bronorganisatie: string;
verantwoordelijkeOrganisatie: string;
vertrouwelijkheidaanduiding: string;
}
export interface BeheerZaaktype {
identificatie: string;
omschrijving: string;
}
export interface CurrentRegistration {
registrationId: string;
status: string;
@@ -410,4 +421,100 @@ export class BffApiV1Service {
);
}
getBeheerCatalogiZaaktypen<TData = BeheerZaaktype[]>( options?: HttpClientBodyOptions): Observable<TData>;
getBeheerCatalogiZaaktypen<TData = BeheerZaaktype[]>( options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
getBeheerCatalogiZaaktypen<TData = BeheerZaaktype[]>( options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
getBeheerCatalogiZaaktypen<TData = BeheerZaaktype[]>(
options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
if (options?.observe === 'events') {
return this.http.get<TData>(
`/beheer/catalogi/zaaktypen`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events',
}
);
}
if (options?.observe === 'response') {
return this.http.get<TData>(
`/beheer/catalogi/zaaktypen`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response',
}
);
}
return this.http.get<TData>(
`/beheer/catalogi/zaaktypen`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'body',
}
);
}
getBeheerDefaultFill<TData = BeheerDefaultFill>( options?: HttpClientBodyOptions): Observable<TData>;
getBeheerDefaultFill<TData = BeheerDefaultFill>( options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
getBeheerDefaultFill<TData = BeheerDefaultFill>( options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
getBeheerDefaultFill<TData = BeheerDefaultFill>(
options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
if (options?.observe === 'events') {
return this.http.get<TData>(
`/beheer/default-fill`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events',
}
);
}
if (options?.observe === 'response') {
return this.http.get<TData>(
`/beheer/default-fill`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response',
}
);
}
return this.http.get<TData>(
`/beheer/default-fill`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'body',
}
);
}
putBeheerDefaultFill<TData = void>(beheerDefaultFill: BeheerDefaultFill, options?: HttpClientBodyOptions): Observable<TData>;
putBeheerDefaultFill<TData = void>(beheerDefaultFill: BeheerDefaultFill, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
putBeheerDefaultFill<TData = void>(beheerDefaultFill: BeheerDefaultFill, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
putBeheerDefaultFill<TData = void>(
beheerDefaultFill: BeheerDefaultFill, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
if (options?.observe === 'events') {
return this.http.put<TData>(
`/beheer/default-fill`,
beheerDefaultFill,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events',
}
);
}
if (options?.observe === 'response') {
return this.http.put<TData>(
`/beheer/default-fill`,
beheerDefaultFill,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response',
}
);
}
return this.http.put<TData>(
`/beheer/default-fill`,
beheerDefaultFill,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'body',
}
);
}
};
+8
View File
@@ -5,6 +5,14 @@
<ProjectReference Include="..\Acl.Infrastructure\Acl.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.17.0-beta.1" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
+62
View File
@@ -1,8 +1,31 @@
using Acl.Application;
using Acl.Infrastructure;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
var builder = WebApplication.CreateBuilder(args);
// OpenTelemetry tracing (S-16b, ADR-0023): auto-instrument incoming ASP.NET Core requests and
// outgoing HttpClient calls (the ACL → OpenZaak hop), exported over OTLP to Tempo. Service name +
// OTLP endpoint come from OTEL_* env (compose); the exporter no-ops when Tempo is unreachable.
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService(
builder.Configuration["OTEL_SERVICE_NAME"] ?? builder.Environment.ApplicationName))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation(o => o.Filter = ctx => ctx.Request.Path != "/health")
.AddHttpClientInstrumentation()
.AddOtlpExporter())
// OpenTelemetry metrics (S-16c, ADR-0023): golden signals for the request path —
// http.server.request.duration (traffic/errors/latency) + http.client.* for downstream hops, plus
// the built-in System.Runtime meter for saturation (GC, CPU, thread pool). Prometheus scrapes these
// from /metrics (mapped below); metrics aren't pushed over OTLP, so no collector hop (ADR-0023).
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddMeter("System.Runtime")
.AddPrometheusExporter());
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
.GetSection("Acl:Defaults").Get<AclDefaults>()
@@ -10,7 +33,21 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
.GetSection("Acl:OpenZaak").Get<OpenZaakOptions>()
?? throw new InvalidOperationException("Missing configuration section 'Acl:OpenZaak'"));
// The default-fill values are held in a runtime-mutable store (S-15b, ADR-0026), seeded from the
// configured Acl:Defaults. The beheer portal edits it; the worker reads it per zaak. The S-27
// resolution keys stay on AclDefaults (static) — see DefaultFillSettings.
builder.Services.AddSingleton<IDefaultFillStore>(sp =>
{
var d = sp.GetRequiredService<AclDefaults>();
return new InMemoryDefaultFillStore(
new DefaultFillSettings(d.Bronorganisatie, d.VerantwoordelijkeOrganisatie, d.Vertrouwelijkheidaanduiding));
});
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
.GetSection("Acl:Objecten").Get<ObjectenOptions>()
?? throw new InvalidOperationException("Missing configuration section 'Acl:Objecten'"));
builder.Services.AddHttpClient<IZaakGateway, OpenZaakGateway>();
// The Objecten hop that writes the register record on approval (S-19a, ADR-0028).
builder.Services.AddHttpClient<IRegisterRecordGateway, ObjectenGateway>();
// Singleton so the resolved zaaktype/informatieobjecttype URLs are cached across requests (S-27).
builder.Services.AddSingleton<IZaaktypeCatalog, CachedZaaktypeCatalog>();
builder.Services.AddScoped<AclService>();
@@ -19,6 +56,9 @@ var app = builder.Build();
app.MapGet("/health", () => "Healthy");
// Prometheus scrape endpoint (S-16c): exposes the OTel metrics above in Prometheus text format.
app.MapPrometheusScrapingEndpoint();
// The ACL's single operation, exposed as a service endpoint.
app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationToken ct) =>
{
@@ -59,6 +99,28 @@ app.MapPost("/documenten", async (StoreDocumentRequest body, AclService acl, Can
return Results.Ok(new { informatieobjectUrl = url.ToString() });
});
// List the published zaaktypen — the read-only catalogus the beheer portal shows (S-15a). The BFF
// proxies this behind medewerker-realm + beheerder authorization; the ACL trusts its callers (§8.3)
// and is the only code allowed to read the ZGW Catalogi API (§8.1).
app.MapGet("/catalogi/zaaktypen", async (AclService acl, CancellationToken ct) =>
Results.Ok(await acl.ListZaaktypenAsync(ct)));
// Read the current default-fill settings (beheer config viewer, S-15b).
app.MapGet("/default-fill", (AclService acl) => Results.Ok(acl.GetDefaultFill()));
// Update the default-fill settings from the beheer portal (S-15b). Behind beheerder authorization at
// the BFF; the ACL validates the values are present (the three ZGW-mandatory fields).
app.MapPut("/default-fill", (DefaultFillSettings body, AclService acl) =>
{
if (string.IsNullOrWhiteSpace(body.Bronorganisatie) ||
string.IsNullOrWhiteSpace(body.VerantwoordelijkeOrganisatie) ||
string.IsNullOrWhiteSpace(body.Vertrouwelijkheidaanduiding))
return Results.BadRequest(new { error = "bronorganisatie, verantwoordelijkeOrganisatie and vertrouwelijkheidaanduiding are all required." });
acl.UpdateDefaultFill(body);
return Results.NoContent();
});
app.Run();
public sealed record OpenZaakRequest(string Bsn, string Reference);
+45 -3
View File
@@ -2,12 +2,20 @@ namespace Acl.Application;
/// <summary>The ACL's single operation: open a zaak from a domain payload,
/// default-filling the ZGW-mandatory fields (ADR-0003).</summary>
public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaaktypeCatalog catalog, IClock clock)
public sealed class AclService(
IZaakGateway gateway,
IRegisterRecordGateway register,
IDefaultFillStore fill,
IZaaktypeCatalog catalog,
IClock clock)
{
public async Task<Uri> OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(registration);
// Read the current default-fill per zaak (not at construction), so a beheerder edit (S-15b)
// takes effect on the next zaak without a restart.
var defaults = fill.Current;
var request = new ZaakRequest(
defaults.Bronorganisatie,
defaults.VerantwoordelijkeOrganisatie,
@@ -20,16 +28,33 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaak
}
/// <summary>
/// Approve a zaak: set it to the eindstatus of the BIG zaaktype (resolved by identificatie, S-27).
/// The domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1).
/// Approve a zaak: set it to the eindstatus of the BIG zaaktype (resolved by identificatie, S-27),
/// then write the register record to Objecten (S-19a). The domain hands over only the zaak URL; the
/// ACL owns which statustype means "approved" and what the register record looks like (§8.1).
/// </summary>
/// <remarks>
/// OpenZaak holds the process, Objecten holds the register (ADR-0028), so approval is two writes
/// across two modules and is eventually consistent by construction. Both are idempotent — a status
/// is a log entry, the record upsert is keyed on the zaak id — so a caller that retries a failed
/// approval converges rather than duplicating.
/// </remarks>
public async Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
await gateway.SetZaakToEindstatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct);
await register.UpsertAsync(
new RegisterRecord(
ZaakId(zaakUrl),
RegisterRecordStatus.Ingeschreven,
await gateway.GetZaakIdentificatieAsync(zaakUrl, ct)),
ct);
}
/// <summary>The zaak's UUID — the key the register record and the read projection rows share.</summary>
private static string ZaakId(Uri zaakUrl) => zaakUrl.Segments[^1].TrimEnd('/');
/// <summary>
/// Cancel a zaak on document-timeout expiry (S-10c): set it to the BIG zaaktype's cancellation
/// statustype + resultaat. The domain hands over only the zaak URL; the ACL owns which
@@ -42,6 +67,22 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaak
await gateway.SetZaakToCancellationStatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct);
}
/// <summary>The published zaaktypen, for the beheer catalogus viewer (S-15a). Read-only passthrough:
/// no default-fill, the ACL is simply the only code allowed to read ZGW (§8.1).</summary>
public Task<IReadOnlyList<ZaaktypeSummary>> ListZaaktypenAsync(CancellationToken ct = default) =>
gateway.ListZaaktypenAsync(ct);
/// <summary>The current default-fill settings, for the beheer config viewer (S-15b).</summary>
public DefaultFillSettings GetDefaultFill() => fill.Current;
/// <summary>Replace the default-fill settings from the beheer portal (S-15b). Takes effect on the
/// next zaak (the fill is read per zaak, not cached).</summary>
public void UpdateDefaultFill(DefaultFillSettings settings)
{
ArgumentNullException.ThrowIfNull(settings);
fill.Update(settings);
}
/// <summary>The zaak's reference (its ZGW identificatie), for the read projection (#78).</summary>
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
{
@@ -63,6 +104,7 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaak
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
ArgumentException.ThrowIfNullOrWhiteSpace(contentType);
var defaults = fill.Current;
var request = new DocumentRequest(
defaults.Bronorganisatie,
await catalog.GetInformatieobjecttypeUrlAsync(ct),
@@ -0,0 +1,10 @@
namespace Acl.Application;
/// <summary>The ZGW default-fill values a beheerder can edit at runtime (S-15b) — the mandatory fields
/// the ACL stamps on every zaak (ADR-0003). The S-27 catalog-resolution keys (zaaktype identificatie,
/// informatieobjecttype omschrijving) stay static config: editing them would desync the resolved-URL
/// cache, and they're catalogus wiring rather than "default fill".</summary>
public sealed record DefaultFillSettings(
string Bronorganisatie,
string VerantwoordelijkeOrganisatie,
string Vertrouwelijkheidaanduiding);
@@ -0,0 +1,14 @@
namespace Acl.Application;
/// <summary>Holds the ACL's current default-fill values, editable at runtime through the beheer portal
/// (S-15b). Seeded from config at startup.
///
/// ponytail: in-memory only — an edit is lost on restart, when it reverts to the configured env
/// (ADR-0026). Adequate for the reference demo; back it with a DB if durable, audited config is needed.
/// </summary>
public interface IDefaultFillStore
{
DefaultFillSettings Current { get; }
void Update(DefaultFillSettings settings);
}
@@ -0,0 +1,30 @@
namespace Acl.Application;
/// <summary>
/// Port to the Objecten API, which holds the authoritative register record (S-19a, ADR-0028).
/// Implemented in Infrastructure — as with ZGW, the ACL is the only code that talks to the
/// upstream Common Ground module (§8.1).
/// </summary>
public interface IRegisterRecordGateway
{
/// <summary>
/// Write the register record for a registration, creating it if absent and updating it if it
/// already exists. Idempotent on <see cref="RegisterRecord.Id"/>: a replayed approval updates
/// the existing object instead of creating a second one (§8.6).
/// </summary>
Task UpsertAsync(RegisterRecord record, CancellationToken ct = default);
}
/// <summary>
/// The public-safe register record, matching the <c>RegisterRecord</c> objecttype schema registered
/// in S-18c (ADR-0027). No bsn, no name — the register is world-readable.
/// </summary>
public sealed record RegisterRecord(string Id, string Status, string? Reference);
/// <summary>The register statuses the RegisterRecord objecttype's schema allows (ADR-0027).</summary>
public static class RegisterRecordStatus
{
public const string Ingediend = "INGEDIEND";
public const string Ingeschreven = "INGESCHREVEN";
}
@@ -40,4 +40,8 @@ public interface IZaakGateway
/// <summary>Resolve the URL of the published informatieobjecttype with the given
/// <paramref name="omschrijving"/> from the Catalogi API (S-27). Throws if none matches.</summary>
Task<Uri> ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default);
/// <summary>List the published zaaktypen from the Catalogi API — the read-only catalogus the beheer
/// portal shows (S-15a). The ACL is the only code allowed to read ZGW (§8.1).</summary>
Task<IReadOnlyList<ZaaktypeSummary>> ListZaaktypenAsync(CancellationToken ct = default);
}
@@ -0,0 +1,20 @@
namespace Acl.Application;
/// <summary>In-memory <see cref="IDefaultFillStore"/> (ADR-0026), seeded from config. Thread-safe: the
/// hosted worker reads <see cref="Current"/> per zaak while the beheer endpoint may update it.</summary>
public sealed class InMemoryDefaultFillStore(DefaultFillSettings seed) : IDefaultFillStore
{
private readonly object _gate = new();
private DefaultFillSettings _current = seed;
public DefaultFillSettings Current
{
get { lock (_gate) return _current; }
}
public void Update(DefaultFillSettings settings)
{
ArgumentNullException.ThrowIfNull(settings);
lock (_gate) _current = settings;
}
}
@@ -0,0 +1,6 @@
namespace Acl.Application;
/// <summary>A published zaaktype as the beheer catalogus viewer shows it (S-15a). Public-safe: the
/// business <see cref="Identificatie"/> + human <see cref="Omschrijving"/> and the ZGW <see cref="Url"/>
/// (the URL is the ACL's own reference, not shown to end users).</summary>
public sealed record ZaaktypeSummary(string Identificatie, string Omschrijving, Uri Url);
@@ -0,0 +1,160 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Acl.Application;
namespace Acl.Infrastructure;
/// <summary>
/// The only code that talks to the Objecten API (ADR-0028). Writes the register record as an object
/// of the <c>RegisterRecord</c> objecttype registered in S-18c.
/// </summary>
public sealed class ObjectenGateway(HttpClient http, ObjectenOptions options, IClock clock) : IRegisterRecordGateway
{
// The objecttype URL + version are assigned by Objecttypen at seed time, so they are resolved by
// name on first use rather than pinned in config (same reasoning as ADR-0021).
// ponytail: memoised per instance only — the gateway is a transient typed client, so in practice
// that is one extra GET per approval against a neighbouring container. Lift it into a singleton
// cache (as CachedZaaktypeCatalog does for ZGW) if approvals ever get hot.
private Objecttype? objecttype;
public async Task UpsertAsync(RegisterRecord record, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(record);
var type = objecttype ??= await ResolveObjecttypeAsync(ct);
var existing = await FindExistingAsync(type.Url, record.Id, ct);
var data = new RecordDataDto(record.Id, record.Status, record.Reference);
// No existing object → create; otherwise PATCH, which appends a new record version to the same
// object. Either way the register ends up with exactly one object per registration (§8.6).
if (existing is null)
await SendAsync(HttpMethod.Post, new Uri(options.BaseUrl, "/api/v2/objects"),
new CreateObjectDto(type.Url.ToString(), NewRecord(type.Version, data)),
"Creating the register record", ct);
else
await SendAsync(HttpMethod.Patch, existing,
new PatchObjectDto(NewRecord(type.Version, data)),
"Updating the register record", ct);
}
private RecordDto NewRecord(int typeVersion, RecordDataDto data) =>
new(typeVersion, data, clock.Today.ToString("yyyy-MM-dd"));
/// <summary>The URL + latest published version of the configured objecttype, read from Objecttypen.</summary>
private async Task<Objecttype> ResolveObjecttypeAsync(CancellationToken ct)
{
var page = await GetAsync<ObjecttypePage>(
new Uri(options.ObjecttypenBaseUrl, "/api/v2/objecttypes"),
options.ObjecttypenToken, crs: false, "objecttypen", ct);
var match = (page.Results ?? []).FirstOrDefault(o => o.Name == options.ObjecttypeName)
?? throw new InvalidOperationException(
$"No objecttype '{options.ObjecttypeName}' registered in Objecttypen — is the RegisterRecord seed applied?");
// Write against the highest *published* version: a draft version's schema is still being
// shaped, and objects written against it would be validated by a moving target. The objecttype
// carries its versions as URLs, so each is fetched for its status (the collection response
// gives no status) — once per gateway instance, alongside the lookup above.
var latest = 0;
foreach (var versionUrl in match.Versions ?? [])
{
var version = await GetAsync<ObjecttypeVersionDto>(
new Uri(versionUrl), options.ObjecttypenToken, crs: false, "objecttype version", ct);
if (version.Status == "published" && version.Version > latest)
latest = version.Version;
}
if (latest == 0)
throw new InvalidOperationException($"Objecttype '{options.ObjecttypeName}' has no published version");
return new Objecttype(new Uri(match.Url), latest);
}
/// <summary>The URL of the object already holding this registration's record, or null if there is none.</summary>
private async Task<Uri?> FindExistingAsync(Uri objecttypeUrl, string id, CancellationToken ct)
{
var query = new Uri(options.BaseUrl,
"/api/v2/objects?type=" + Uri.EscapeDataString(objecttypeUrl.ToString()) +
"&data_attrs=id__exact__" + Uri.EscapeDataString(id));
var page = await GetAsync<ObjectPage>(query, options.Token, crs: true, "objects", ct);
var match = (page.Results ?? []).FirstOrDefault();
return match is null ? null : new Uri(match.Url);
}
private async Task<T> GetAsync<T>(Uri uri, string token, bool crs, string label, CancellationToken ct)
{
using var message = new HttpRequestMessage(HttpMethod.Get, uri);
message.Headers.Authorization = new AuthenticationHeaderValue("Token", token);
if (crs)
message.Headers.Add("Accept-Crs", "EPSG:4326");
using var response = await http.SendAsync(message, ct);
await EnsureSuccessAsync(response, $"Querying {label}", ct);
return await response.Content.ReadFromJsonAsync<T>(ct)
?? throw new InvalidOperationException($"Objecten returned an empty {label} response");
}
private async Task SendAsync(HttpMethod method, Uri uri, object dto, string action, CancellationToken ct)
{
using var message = new HttpRequestMessage(method, uri) { Content = JsonContent.Create(dto) };
message.Headers.Authorization = new AuthenticationHeaderValue("Token", options.Token);
// The Objecten API is a geo API: it requires the CRS headers on reads and writes alike.
message.Headers.Add("Accept-Crs", "EPSG:4326");
message.Content.Headers.Add("Content-Crs", "EPSG:4326");
// As with OpenZaak, Objecten runs behind uwsgi, which rejects a chunked request body.
await message.Content.LoadIntoBufferAsync(ct);
using var response = await http.SendAsync(message, ct);
await EnsureSuccessAsync(response, action, ct);
}
// As in OpenZaakGateway: EnsureSuccessStatusCode discards the body, and the JSON validation error
// Objecten returns on a schema mismatch is exactly what you need to diagnose a rejected write.
private static async Task EnsureSuccessAsync(HttpResponseMessage response, string action, CancellationToken ct)
{
if (response.IsSuccessStatusCode)
return;
var body = await response.Content.ReadAsStringAsync(ct);
throw new HttpRequestException($"{action} failed: {(int)response.StatusCode} {response.ReasonPhrase}. {body}");
}
private sealed record Objecttype(Uri Url, int Version);
private sealed record ObjecttypePage(
[property: JsonPropertyName("results")] IReadOnlyList<ObjecttypeDto>? Results);
private sealed record ObjecttypeDto(
[property: JsonPropertyName("url")] string Url,
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("versions")] IReadOnlyList<string>? Versions);
private sealed record ObjecttypeVersionDto(
[property: JsonPropertyName("version")] int Version,
[property: JsonPropertyName("status")] string? Status);
private sealed record ObjectPage(
[property: JsonPropertyName("results")] IReadOnlyList<ObjectDto>? Results);
private sealed record ObjectDto(
[property: JsonPropertyName("url")] string Url);
private sealed record CreateObjectDto(
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("record")] RecordDto Record);
private sealed record PatchObjectDto(
[property: JsonPropertyName("record")] RecordDto Record);
private sealed record RecordDto(
[property: JsonPropertyName("typeVersion")] int TypeVersion,
[property: JsonPropertyName("data")] RecordDataDto Data,
[property: JsonPropertyName("startAt")] string StartAt);
private sealed record RecordDataDto(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("reference")] string? Reference);
}
@@ -0,0 +1,20 @@
namespace Acl.Infrastructure;
/// <summary>
/// Connection + credential config for the Objecten and Objecttypen APIs. Both authenticate with a
/// static <c>Authorization: Token …</c> (they are not ZGW JWT APIs), so there is no client-id/secret
/// pair as with OpenZaak.
/// </summary>
public sealed class ObjectenOptions
{
public required Uri BaseUrl { get; init; }
public required string Token { get; init; }
/// <summary>Objecttypen API root — the ACL resolves the objecttype URL + version from it by name
/// rather than pinning a seed-time UUID in config (same reasoning as ADR-0021).</summary>
public required Uri ObjecttypenBaseUrl { get; init; }
public required string ObjecttypenToken { get; init; }
/// <summary>The objecttype the register record is written as (S-18c registers "RegisterRecord").</summary>
public required string ObjecttypeName { get; init; }
}
@@ -170,6 +170,16 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
return new Uri(match.Url);
}
public async Task<IReadOnlyList<ZaaktypeSummary>> ListZaaktypenAsync(CancellationToken ct = default)
{
// Only published zaaktypen (status=definitief excludes concepts) — the read-only catalogus the
// beheer portal shows. Public-safe fields only.
var page = await GetAsync<ZaaktypePage>("/catalogi/api/v1/zaaktypen?status=definitief", "zaaktypen", ct);
return (page.Results ?? [])
.Select(z => new ZaaktypeSummary(z.Identificatie ?? "", z.Omschrijving ?? "", new Uri(z.Url)))
.ToList();
}
// GETs an absolute-by-path ZGW resource with auth (no CRS — catalogi is not a geo API).
private async Task<T> GetAsync<T>(string pathAndQuery, string label, CancellationToken ct)
{
@@ -346,7 +356,8 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
private sealed record ZaaktypeDto(
[property: JsonPropertyName("url")] string Url,
[property: JsonPropertyName("identificatie")] string? Identificatie);
[property: JsonPropertyName("identificatie")] string? Identificatie,
[property: JsonPropertyName("omschrijving")] string? Omschrijving = null);
private sealed record InformatieobjecttypePage(
[property: JsonPropertyName("results")] IReadOnlyList<InformatieobjecttypeDto>? Results);
@@ -0,0 +1,105 @@
using Acl.Application;
using Acl.Infrastructure;
namespace Acl.IntegrationTests;
/// <summary>
/// S-19a (#149): the ObjectenGateway against a *real* Objecten + Objecttypen pair. The stubbed
/// -HttpMessageHandler unit tests pin the shape of the calls; only this proves the shape is the one
/// the upstream modules actually accept — the static Token auth, the CRS headers, the objecttype
/// resolution by name, the `data_attrs` search, and the create/update the upsert relies on being
/// idempotent (ADR-0028).
/// </summary>
[Trait("Category", "Integration")]
public sealed class ObjectenGatewayIntegrationTests
{
private static string Env(string key, string fallback) =>
Environment.GetEnvironmentVariable(key) is { Length: > 0 } v ? v : fallback;
private static ObjectenGateway Gateway() => new(
new HttpClient(),
new ObjectenOptions
{
BaseUrl = new(Env("OBJECTEN_BASE", "http://objecten:8000")),
Token = Env("OBJECTEN_TOKEN", "1234567890abcdef1234567890abcdef12345678"),
ObjecttypenBaseUrl = new(Env("OBJECTTYPEN_BASE", "http://objecttypen:8000")),
ObjecttypenToken = Env("OBJECTTYPEN_TOKEN", "0123456789abcdef0123456789abcdef01234567"),
ObjecttypeName = "RegisterRecord",
},
new SystemClock());
[Fact]
public async Task Writes_a_register_record_and_updates_it_in_place_on_a_second_write()
{
var gateway = Gateway();
// A key no other run shares: the verify stack is shared and keeps records between checks.
var id = Guid.NewGuid().ToString();
await gateway.UpsertAsync(new RegisterRecord(id, RegisterRecordStatus.Ingediend, "INT-TEST-1"));
await gateway.UpsertAsync(new RegisterRecord(id, RegisterRecordStatus.Ingeschreven, "INT-TEST-1"));
var records = await ReadAllAsync(id);
var only = Assert.Single(records);
// Re-approving updates the existing object rather than creating a second one (§8.6).
Assert.Equal(RegisterRecordStatus.Ingeschreven, only.Status);
Assert.Equal("INT-TEST-1", only.Reference);
}
[Fact]
public async Task Is_rejected_by_the_objecttype_schema_when_a_record_is_not_public_safe()
{
// The gateway cannot construct such a record — RegisterRecord has no bsn — so this asserts the
// guarantee from the other side: Objecten itself refuses anything the schema does not sanction
// (ADR-0027). Posted raw, exactly as the gateway would post a record.
var gateway = Gateway();
var id = Guid.NewGuid().ToString();
await gateway.UpsertAsync(new RegisterRecord(id, RegisterRecordStatus.Ingeschreven, "INT-TEST-2"));
var stored = Assert.Single(await ReadAllAsync(id));
Assert.Null(stored.Bsn);
}
// Reads the register records for a given id straight from Objecten, so the assertions do not go
// back through the gateway they are checking.
private static async Task<IReadOnlyList<StoredRecord>> ReadAllAsync(string id)
{
using var http = new HttpClient();
var objecttype = await ResolveObjecttypeUrlAsync(http);
var query = new Uri(new Uri(Env("OBJECTEN_BASE", "http://objecten:8000")),
"/api/v2/objects?type=" + Uri.EscapeDataString(objecttype) +
"&data_attrs=id__exact__" + Uri.EscapeDataString(id));
using var message = new HttpRequestMessage(HttpMethod.Get, query);
message.Headers.Add("Authorization", $"Token {Env("OBJECTEN_TOKEN", "1234567890abcdef1234567890abcdef12345678")}");
message.Headers.Add("Accept-Crs", "EPSG:4326");
using var response = await http.SendAsync(message);
response.EnsureSuccessStatusCode();
using var document = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync());
return document.RootElement.GetProperty("results").EnumerateArray()
.Select(o => o.GetProperty("record").GetProperty("data"))
.Select(d => new StoredRecord(
d.GetProperty("status").GetString()!,
d.GetProperty("reference").GetString(),
d.TryGetProperty("bsn", out var bsn) ? bsn.GetString() : null))
.ToList();
}
private static async Task<string> ResolveObjecttypeUrlAsync(HttpClient http)
{
var query = new Uri(new Uri(Env("OBJECTTYPEN_BASE", "http://objecttypen:8000")), "/api/v2/objecttypes");
using var message = new HttpRequestMessage(HttpMethod.Get, query);
message.Headers.Add("Authorization", $"Token {Env("OBJECTTYPEN_TOKEN", "0123456789abcdef0123456789abcdef01234567")}");
using var response = await http.SendAsync(message);
response.EnsureSuccessStatusCode();
using var document = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync());
return document.RootElement.GetProperty("results").EnumerateArray()
.First(o => o.GetProperty("name").GetString() == "RegisterRecord")
.GetProperty("url").GetString()!;
}
private sealed record StoredRecord(string Status, string? Reference, string? Bsn);
}
+89 -2
View File
@@ -65,6 +65,25 @@ public class AclServiceTests
ResolvedByOmschrijving = omschrijving;
return Task.FromResult(ResolvedInformatieobjecttype);
}
public IReadOnlyList<ZaaktypeSummary> Zaaktypen { get; } =
[
new("BIG-REGISTRATIE", "BIG-registratie", new Uri("http://openzaak/catalogi/api/v1/zaaktypen/big")),
];
public Task<IReadOnlyList<ZaaktypeSummary>> ListZaaktypenAsync(CancellationToken ct = default) =>
Task.FromResult(Zaaktypen);
}
private sealed class FakeRegisterRecordGateway : IRegisterRecordGateway
{
public readonly List<RegisterRecord> Upserted = [];
public Task UpsertAsync(RegisterRecord record, CancellationToken ct = default)
{
Upserted.Add(record);
return Task.CompletedTask;
}
}
private static AclDefaults Defaults() => new()
@@ -76,8 +95,14 @@ public class AclServiceTests
InformatieobjecttypeOmschrijving = "Diploma",
};
private static InMemoryDefaultFillStore FillFrom(AclDefaults d) =>
new(new DefaultFillSettings(d.Bronorganisatie, d.VerantwoordelijkeOrganisatie, d.Vertrouwelijkheidaanduiding));
private static AclService ServiceWith(FakeGateway gateway, AclDefaults defaults, DateOnly today) =>
new(gateway, defaults, new CachedZaaktypeCatalog(gateway, defaults), new FixedClock(today));
ServiceWith(gateway, new FakeRegisterRecordGateway(), defaults, today);
private static AclService ServiceWith(FakeGateway gateway, FakeRegisterRecordGateway register, AclDefaults defaults, DateOnly today) =>
new(gateway, register, FillFrom(defaults), new CachedZaaktypeCatalog(gateway, defaults), new FixedClock(today));
private sealed class FixedClock(DateOnly today) : IClock
{
@@ -105,6 +130,22 @@ public class AclServiceTests
Assert.Equal("reg-77", req.Identificatie);
}
[Fact]
public async Task Opening_a_zaak_reflects_a_default_fill_update(/* S-15b */)
{
var gateway = new FakeGateway();
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
// A beheerder edits the default-fill; the very next zaak must use the new values (read per zaak).
service.UpdateDefaultFill(new DefaultFillSettings("999999999", "888888888", "vertrouwelijk"));
await service.OpenZaakAsync(new DomainRegistration("123456782", "reg-1"));
var req = gateway.Captured!;
Assert.Equal("999999999", req.Bronorganisatie);
Assert.Equal("888888888", req.VerantwoordelijkeOrganisatie);
Assert.Equal("vertrouwelijk", req.Vertrouwelijkheidaanduiding);
}
[Fact]
public async Task Rejects_a_null_registration_without_calling_the_gateway()
{
@@ -134,10 +175,42 @@ public class AclServiceTests
public async Task Approving_a_null_zaak_is_rejected_without_touching_the_gateway()
{
var gateway = new FakeGateway();
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
var register = new FakeRegisterRecordGateway();
var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4));
await Assert.ThrowsAsync<ArgumentNullException>(() => service.ApproveZaakAsync(null!));
Assert.Null(gateway.Approved);
Assert.Empty(register.Upserted);
}
[Fact]
public async Task Approving_a_zaak_writes_the_register_record_to_objecten(/* S-19a */)
{
var gateway = new FakeGateway();
var register = new FakeRegisterRecordGateway();
var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4));
await service.ApproveZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
var record = Assert.Single(register.Upserted);
// The record is keyed on the zaak id — the same key the read projection rows carry (S-19b).
Assert.Equal("abc", record.Id);
Assert.Equal("INGESCHREVEN", record.Status);
// The public-safe reference comes from the zaak's identificatie, never from the domain payload.
Assert.Equal("REG-FROM-ZAAK", record.Reference);
}
[Fact]
public async Task Cancelling_a_zaak_writes_no_register_record(/* S-19a */)
{
var gateway = new FakeGateway();
var register = new FakeRegisterRecordGateway();
var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4));
await service.CancelZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
// Only an approval enters the register; a cancelled zaak never becomes a register record.
Assert.Empty(register.Upserted);
}
[Fact]
@@ -225,4 +298,18 @@ public class AclServiceTests
await Assert.ThrowsAsync<ArgumentNullException>(() => service.GetZaakReferenceAsync(null!));
Assert.Null(gateway.ReadReferenceFor);
}
[Fact]
public async Task Listing_zaaktypen_returns_the_gateways_published_zaaktypen(/* S-15a */)
{
var gateway = new FakeGateway();
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
var zaaktypen = await service.ListZaaktypenAsync();
var only = Assert.Single(zaaktypen);
Assert.Equal("BIG-REGISTRATIE", only.Identificatie);
Assert.Equal("BIG-registratie", only.Omschrijving);
Assert.Equal(new Uri("http://openzaak/catalogi/api/v1/zaaktypen/big"), only.Url);
}
}
@@ -0,0 +1,29 @@
using Acl.Application;
namespace Acl.Tests;
public class DefaultFillStoreTests
{
private static DefaultFillSettings Seed() => new("517439943", "517439943", "openbaar");
[Fact]
public void Seeds_from_the_supplied_settings()
{
var store = new InMemoryDefaultFillStore(Seed());
Assert.Equal("517439943", store.Current.Bronorganisatie);
Assert.Equal("openbaar", store.Current.Vertrouwelijkheidaanduiding);
}
[Fact]
public void Updating_replaces_the_current_settings()
{
var store = new InMemoryDefaultFillStore(Seed());
store.Update(new DefaultFillSettings("999999999", "888888888", "vertrouwelijk"));
Assert.Equal("999999999", store.Current.Bronorganisatie);
Assert.Equal("888888888", store.Current.VerantwoordelijkeOrganisatie);
Assert.Equal("vertrouwelijk", store.Current.Vertrouwelijkheidaanduiding);
}
}
@@ -0,0 +1,330 @@
using System.Net;
using System.Net.Http.Json;
using Acl.Application;
using Acl.Infrastructure;
namespace Acl.Tests;
public class ObjectenGatewayTests
{
private sealed class StubHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> onSend)
: HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
=> onSend(request);
}
private sealed class FixedClock(DateOnly today) : IClock
{
public DateOnly Today { get; } = today;
}
private sealed record Sent(
HttpMethod Method, Uri Uri, string? Body, string? Auth, string? ContentCrs, string? AcceptCrs, long? ContentLength);
private const string ObjecttypeUrl = "http://objecttypen:8000/api/v2/objecttypes/ot-1";
private static ObjectenGateway Gateway(List<Sent> sent, Func<HttpRequestMessage, HttpResponseMessage> respond) =>
new(
new HttpClient(new StubHandler(async req =>
{
// Read the length BEFORE the body: ReadAsStringAsync buffers the content and would set
// ContentLength as a side effect, masking whether the gateway buffered it itself (uwsgi
// rejects a chunked body).
sent.Add(new Sent(
req.Method,
req.RequestUri!,
ContentLength: req.Content?.Headers.ContentLength,
Body: req.Content is null ? null : await req.Content.ReadAsStringAsync(),
Auth: req.Headers.Authorization?.ToString(),
ContentCrs: req.Content?.Headers.TryGetValues("Content-Crs", out var c) == true ? string.Join(",", c!) : null,
AcceptCrs: req.Headers.TryGetValues("Accept-Crs", out var a) ? string.Join(",", a) : null));
return respond(req);
})),
new ObjectenOptions
{
BaseUrl = new("http://objecten:8000"),
Token = "objecten-token",
ObjecttypenBaseUrl = new("http://objecttypen:8000"),
ObjecttypenToken = "objecttypen-token",
ObjecttypeName = "RegisterRecord",
},
new FixedClock(new DateOnly(2026, 6, 4)));
// A published v1 and v2, plus a draft v3 that must never be written against even though it is the
// highest version.
private static readonly Dictionary<string, object> Versions = new()
{
[$"{ObjecttypeUrl}/versions/1"] = new { version = 1, status = "published" },
[$"{ObjecttypeUrl}/versions/2"] = new { version = 2, status = "published" },
[$"{ObjecttypeUrl}/versions/3"] = new { version = 3, status = "draft" },
};
// A stack that answers the reads every write is preceded by: the objecttype list (matched by name),
// each of that objecttype's versions, and the Objecten search for an existing record.
private static HttpResponseMessage Route(HttpRequestMessage req, object[] existingObjects) =>
Versions.TryGetValue(req.RequestUri!.ToString(), out var version)
? Json(version)
: req.RequestUri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal)
? Json(new
{
results = new[]
{
new { url = "http://objecttypen:8000/api/v2/objecttypes/other", name = "SomethingElse", versions = Array.Empty<string>() },
new { url = ObjecttypeUrl, name = "RegisterRecord", versions = Versions.Keys.ToArray() },
},
})
: req.Method == HttpMethod.Get
? Json(new { results = existingObjects })
: new HttpResponseMessage(HttpStatusCode.Created) { Content = JsonContent.Create(new { url = "http://objecten:8000/api/v2/objects/obj-1" }) };
private static HttpResponseMessage Json(object body) =>
new(HttpStatusCode.OK) { Content = JsonContent.Create(body) };
private static RegisterRecord Record() => new("zaak-uuid-1", RegisterRecordStatus.Ingeschreven, "REG-2026-0001");
[Fact]
public async Task Creates_the_object_when_none_exists_for_the_registration()
{
var sent = new List<Sent>();
await Gateway(sent, req => Route(req, [])).UpsertAsync(Record());
var write = sent.Single(s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects");
Assert.Contains($"\"type\":\"{ObjecttypeUrl}\"", write.Body);
// The highest *published* version (2), not the highest version (a draft 3).
Assert.Contains("\"typeVersion\":2", write.Body);
Assert.Contains("\"id\":\"zaak-uuid-1\"", write.Body);
Assert.Contains("\"status\":\"INGESCHREVEN\"", write.Body);
Assert.Contains("\"reference\":\"REG-2026-0001\"", write.Body);
Assert.Contains("\"startAt\":\"2026-06-04\"", write.Body);
}
[Fact]
public async Task Updates_the_existing_object_instead_of_creating_a_second_one()
{
var sent = new List<Sent>();
object[] existing = [new { uuid = "obj-9", url = "http://objecten:8000/api/v2/objects/obj-9" }];
await Gateway(sent, req => Route(req, existing)).UpsertAsync(Record());
Assert.DoesNotContain(sent, s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects");
var write = sent.Single(s => s.Method == HttpMethod.Patch);
Assert.Equal("http://objecten:8000/api/v2/objects/obj-9", write.Uri.ToString());
Assert.Contains("\"status\":\"INGESCHREVEN\"", write.Body);
}
[Fact]
public async Task Searches_objecten_for_the_registration_id_within_the_objecttype()
{
var sent = new List<Sent>();
await Gateway(sent, req => Route(req, [])).UpsertAsync(Record());
var search = sent.Single(s => s.Method == HttpMethod.Get && s.Uri.AbsolutePath == "/api/v2/objects");
Assert.Contains("type=" + Uri.EscapeDataString(ObjecttypeUrl), search.Uri.Query);
Assert.Contains("data_attrs=id__exact__zaak-uuid-1", search.Uri.Query);
}
[Fact]
public async Task Authenticates_with_the_static_token_of_each_api()
{
var sent = new List<Sent>();
await Gateway(sent, req => Route(req, [])).UpsertAsync(Record());
Assert.All(
sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal)),
s => Assert.Equal("Token objecttypen-token", s.Auth));
Assert.All(
sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objects", StringComparison.Ordinal)),
s => Assert.Equal("Token objecten-token", s.Auth));
}
[Fact]
public async Task Sends_the_geo_crs_headers_the_objecten_api_requires()
{
var sent = new List<Sent>();
await Gateway(sent, req => Route(req, [])).UpsertAsync(Record());
var objects = sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objects", StringComparison.Ordinal)).ToList();
Assert.All(objects, s => Assert.Equal("EPSG:4326", s.AcceptCrs));
Assert.All(objects.Where(s => s.Body is not null), s => Assert.Equal("EPSG:4326", s.ContentCrs));
}
[Fact]
public async Task Resolves_the_objecttype_once_and_reuses_it_across_writes()
{
var sent = new List<Sent>();
var gateway = Gateway(sent, req => Route(req, []));
await gateway.UpsertAsync(Record());
await gateway.UpsertAsync(Record() with { Id = "zaak-uuid-2" });
Assert.Single(sent, s => s.Uri.AbsolutePath == "/api/v2/objecttypes");
}
[Fact]
public async Task Fails_loudly_when_the_objecttype_has_no_published_version()
{
var sent = new List<Sent>();
var gateway = Gateway(sent, req => req.RequestUri!.AbsolutePath.Contains("/versions/", StringComparison.Ordinal)
? Json(new { version = 1, status = "draft" })
: Route(req, []));
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => gateway.UpsertAsync(Record()));
Assert.Contains("published version", error.Message);
}
[Fact]
public async Task Fails_loudly_when_the_objecttype_is_not_registered()
{
var sent = new List<Sent>();
var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(new { results = Array.Empty<object>() }),
});
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => gateway.UpsertAsync(Record()));
Assert.Contains("RegisterRecord", error.Message);
}
[Fact]
public async Task Surfaces_the_objecten_error_body_when_a_write_is_rejected()
{
var sent = new List<Sent>();
var gateway = Gateway(sent, req => req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath == "/api/v2/objects"
? new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("{\"detail\":\"schema mismatch\"}") }
: Route(req, []));
var error = await Assert.ThrowsAsync<HttpRequestException>(() => gateway.UpsertAsync(Record()));
Assert.Contains("schema mismatch", error.Message);
Assert.Contains("Creating the register record", error.Message);
}
[Fact]
public async Task Surfaces_the_objecten_error_body_when_an_update_is_rejected()
{
var sent = new List<Sent>();
object[] existing = [new { url = "http://objecten:8000/api/v2/objects/obj-9" }];
var gateway = Gateway(sent, req => req.Method == HttpMethod.Patch
? new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("{\"detail\":\"stale version\"}") }
: Route(req, existing));
var error = await Assert.ThrowsAsync<HttpRequestException>(() => gateway.UpsertAsync(Record()));
Assert.Contains("stale version", error.Message);
Assert.Contains("Updating the register record", error.Message);
}
[Fact]
public async Task Surfaces_a_failed_read_instead_of_writing_blind()
{
var sent = new List<Sent>();
var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.Unauthorized)
{
Content = new StringContent("{\"detail\":\"invalid token\"}"),
});
var error = await Assert.ThrowsAsync<HttpRequestException>(() => gateway.UpsertAsync(Record()));
Assert.Contains("Querying objecttypen", error.Message);
Assert.Contains("invalid token", error.Message);
// A read that failed must never be mistaken for "nothing there yet" and followed by a write.
Assert.DoesNotContain(sent, s => s.Method == HttpMethod.Post || s.Method == HttpMethod.Patch);
}
[Fact]
public async Task Fails_loudly_when_the_objecttype_carries_no_versions_at_all()
{
var sent = new List<Sent>();
var gateway = Gateway(sent, req => req.RequestUri!.AbsolutePath == "/api/v2/objecttypes"
? Json(new { results = new[] { new { url = ObjecttypeUrl, name = "RegisterRecord" } } })
: Route(req, []));
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => gateway.UpsertAsync(Record()));
Assert.Contains("published version", error.Message);
}
[Fact]
public async Task Says_which_read_failed_when_the_objecten_search_errors()
{
var sent = new List<Sent>();
var gateway = Gateway(sent, req => req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath == "/api/v2/objects"
? new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = new StringContent("boom") }
: Route(req, []));
var error = await Assert.ThrowsAsync<HttpRequestException>(() => gateway.UpsertAsync(Record()));
Assert.Contains("Querying objects", error.Message);
}
[Fact]
public async Task Surfaces_an_empty_read_body_rather_than_dereferencing_it()
{
var sent = new List<Sent>();
var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("null", System.Text.Encoding.UTF8, "application/json"),
});
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => gateway.UpsertAsync(Record()));
Assert.Contains("objecttypen", error.Message);
}
[Fact]
public async Task Treats_a_result_less_response_as_no_match_rather_than_crashing()
{
var sent = new List<Sent>();
// The objecttypes collection carries no `results` key — the objecttype is absent, which must
// surface as the "not registered" error rather than an ArgumentNullException from LINQ.
var gateway = Gateway(sent, _ => Json(new { }));
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => gateway.UpsertAsync(Record()));
Assert.Contains("RegisterRecord", error.Message);
}
[Fact]
public async Task Creates_the_object_when_the_search_response_carries_no_results_key()
{
var sent = new List<Sent>();
var gateway = Gateway(sent, req => req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath == "/api/v2/objects"
? Json(new { })
: Route(req, []));
await gateway.UpsertAsync(Record());
Assert.Contains(sent, s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects");
}
[Fact]
public async Task Reads_objecttypen_without_the_crs_headers_it_does_not_accept()
{
var sent = new List<Sent>();
await Gateway(sent, req => Route(req, [])).UpsertAsync(Record());
// Objecttypen is not a geo API; only the Objecten hops carry CRS.
Assert.All(
sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal)),
s => Assert.Null(s.AcceptCrs));
}
[Fact]
public async Task Buffers_the_write_body_so_uwsgi_gets_a_content_length()
{
var sent = new List<Sent>();
await Gateway(sent, req => Route(req, [])).UpsertAsync(Record());
var write = sent.Single(s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects");
Assert.NotNull(write.ContentLength);
}
[Fact]
public async Task Rejects_a_null_record_without_calling_objecten()
{
var sent = new List<Sent>();
await Assert.ThrowsAsync<ArgumentNullException>(() => Gateway(sent, req => Route(req, [])).UpsertAsync(null!));
Assert.Empty(sent);
}
}
@@ -824,4 +824,51 @@ public class OpenZaakGatewayTests
await Assert.ThrowsAnyAsync<ArgumentException>(() => Gateway(handler).ResolveZaaktypeUrlAsync(" "));
await Assert.ThrowsAnyAsync<ArgumentException>(() => Gateway(handler).ResolveInformatieobjecttypeUrlAsync(" "));
}
[Fact]
public async Task Listing_zaaktypen_queries_published_zaaktypen_and_maps_them(/* S-15a */)
{
HttpRequestMessage? seen = null;
var handler = new StubHandler(req =>
{
seen = req;
const string json = """
{"results":[
{"url":"http://openzaak/catalogi/api/v1/zaaktypen/big","identificatie":"BIG-REGISTRATIE","omschrijving":"BIG-registratie"},
{"url":"http://openzaak/catalogi/api/v1/zaaktypen/her","identificatie":"BIG-HERREGISTRATIE","omschrijving":"BIG-herregistratie"}
]}
""";
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
});
});
var zaaktypen = await Gateway(handler).ListZaaktypenAsync();
// Only the published zaaktypen collection is queried (status=definitief excludes concepts).
Assert.Contains("/catalogi/api/v1/zaaktypen", seen!.RequestUri!.ToString());
Assert.Contains("status=definitief", seen.RequestUri!.ToString());
// Authenticated like the other catalogi reads.
Assert.Equal("Bearer", seen.Headers.Authorization!.Scheme);
// Each result maps to a public-safe summary (identificatie + omschrijving + url).
Assert.Equal(2, zaaktypen.Count);
Assert.Equal("BIG-REGISTRATIE", zaaktypen[0].Identificatie);
Assert.Equal("BIG-registratie", zaaktypen[0].Omschrijving);
Assert.Equal(new Uri("http://openzaak/catalogi/api/v1/zaaktypen/big"), zaaktypen[0].Url);
Assert.Equal("BIG-HERREGISTRATIE", zaaktypen[1].Identificatie);
}
[Fact]
public async Task Listing_zaaktypen_returns_empty_when_the_catalogus_has_none()
{
var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"results":[]}""", Encoding.UTF8, "application/json"),
}));
var zaaktypen = await Gateway(handler).ListZaaktypenAsync();
Assert.Empty(zaaktypen);
}
}
@@ -36,6 +36,7 @@ public class ZaaktypeCatalogTests
public Task SetZaakToCancellationStatusAsync(Uri z, Uri zt, DateOnly d, CancellationToken ct = default) => throw new NotSupportedException();
public Task<string> GetZaakIdentificatieAsync(Uri z, CancellationToken ct = default) => throw new NotSupportedException();
public Task<Uri> StoreDocumentAsync(DocumentRequest r, CancellationToken ct = default) => throw new NotSupportedException();
public Task<IReadOnlyList<ZaaktypeSummary>> ListZaaktypenAsync(CancellationToken ct = default) => throw new NotSupportedException();
}
private static AclDefaults Defaults() => new()
+1 -1
View File
@@ -2,7 +2,7 @@
"stryker-config": {
"solution": "Acl.slnx",
"test-projects": ["Acl.Tests/Acl.Tests.csproj"],
"reporters": ["progress", "html"],
"reporters": ["progress", "html", "markdown"],
"thresholds": {
"high": 95,
"low": 90,
+5
View File
@@ -10,6 +10,11 @@
<!-- OIDC/JWT validation of Keycloak-issued tokens (ADR-0010) and OpenAPI generation. -->
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.17.0-beta.1" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
</ItemGroup>
</Project>
+45
View File
@@ -54,6 +54,33 @@ public interface IProjectionClient
Task<IReadOnlyList<ProjectionEntry>> GetRegisterAsync(CancellationToken ct = default);
}
/// <summary>A published zaaktype as the beheer catalogus viewer shows it (S-15a): the business
/// <c>Identificatie</c> + human <c>Omschrijving</c>. The ZGW URL the ACL also returns is dropped — an
/// internal reference, not shown in the portal.</summary>
public sealed record BeheerZaaktype(string Identificatie, string Omschrijving);
/// <summary>The ACL default-fill settings the beheer portal reads + edits (S-15b): the three ZGW-mandatory
/// fields the ACL stamps on every zaak (ADR-0003).</summary>
public sealed record BeheerDefaultFill(
string Bronorganisatie,
string VerantwoordelijkeOrganisatie,
string Vertrouwelijkheidaanduiding);
/// <summary>Port to the ACL for beheer queries (beheer portal). The BFF reaches the ACL directly: these
/// aren't a domain concern, and the ACL is the only code allowed to read/own the ZGW-facing config
/// (§8.1, ADR-0025).</summary>
public interface IAclClient
{
/// <summary>The published catalogus zaaktypen, read-only (S-15a).</summary>
Task<IReadOnlyList<BeheerZaaktype>> GetZaaktypenAsync(CancellationToken ct = default);
/// <summary>The current default-fill settings (S-15b).</summary>
Task<BeheerDefaultFill> GetDefaultFillAsync(CancellationToken ct = default);
/// <summary>Replace the default-fill settings (S-15b).</summary>
Task UpdateDefaultFillAsync(BeheerDefaultFill settings, CancellationToken ct = default);
}
/// <summary>Calls the Domain Service's <c>POST /registrations</c>.</summary>
public sealed class DomainClient(HttpClient http) : IDomainClient
{
@@ -121,3 +148,21 @@ public sealed class ProjectionClient(HttpClient http) : IProjectionClient
public async Task<IReadOnlyList<ProjectionEntry>> GetRegisterAsync(CancellationToken ct = default)
=> await http.GetFromJsonAsync<List<ProjectionEntry>>("register", ct) ?? [];
}
/// <summary>Calls the ACL's <c>GET /catalogi/zaaktypen</c> (S-15a). The ACL also returns each zaaktype's
/// ZGW URL; deserializing into <see cref="BeheerZaaktype"/> keeps only the public-safe fields.</summary>
public sealed class AclClient(HttpClient http) : IAclClient
{
public async Task<IReadOnlyList<BeheerZaaktype>> GetZaaktypenAsync(CancellationToken ct = default)
=> await http.GetFromJsonAsync<List<BeheerZaaktype>>("catalogi/zaaktypen", ct) ?? [];
public async Task<BeheerDefaultFill> GetDefaultFillAsync(CancellationToken ct = default)
=> await http.GetFromJsonAsync<BeheerDefaultFill>("default-fill", ct)
?? throw new InvalidOperationException("The ACL returned an empty default-fill response.");
public async Task UpdateDefaultFillAsync(BeheerDefaultFill settings, CancellationToken ct = default)
{
using var response = await http.PutAsJsonAsync("default-fill", settings, ct);
response.EnsureSuccessStatusCode();
}
}
+79 -2
View File
@@ -3,9 +3,33 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Bff.Api;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
var builder = WebApplication.CreateBuilder(args);
// OpenTelemetry tracing (S-16b, ADR-0023): auto-instrument incoming ASP.NET Core requests and
// outgoing HttpClient calls (BFF → Domain, BFF → projection-api), exported over OTLP to Tempo, so a
// portal request is one connected trace across the services. Service name + OTLP endpoint come from
// OTEL_* env (compose); the exporter no-ops when Tempo is unreachable. /health is filtered out.
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService(
builder.Configuration["OTEL_SERVICE_NAME"] ?? builder.Environment.ApplicationName))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation(o => o.Filter = ctx => ctx.Request.Path != "/health")
.AddHttpClientInstrumentation()
.AddOtlpExporter())
// OpenTelemetry metrics (S-16c, ADR-0023): the golden signals for the request path —
// http.server.request.duration (traffic/errors/latency) + http.client.* for the downstream hops,
// plus the built-in System.Runtime meter for saturation (GC, CPU, thread pool). Prometheus scrapes
// these from /metrics (mapped below); no OTLP push for metrics, so no collector hop (ADR-0023).
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddMeter("System.Runtime")
.AddPrometheusExporter());
var keycloakAuthority = builder.Configuration["Keycloak:Authority"]
?? throw new InvalidOperationException("Missing configuration 'Keycloak:Authority'");
// Behandelaars authenticate against a *different* Keycloak realm (medewerker) than citizens (digid),
@@ -16,6 +40,10 @@ var domainBaseUrl = builder.Configuration["Downstream:Domain:BaseUrl"]
?? throw new InvalidOperationException("Missing configuration 'Downstream:Domain:BaseUrl'");
var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"]
?? throw new InvalidOperationException("Missing configuration 'Downstream:Projection:BaseUrl'");
// The beheer portal's read-only catalogus view reaches the ACL directly (ADR-0025): the catalogus is
// not a domain concern, and only the ACL may read the ZGW Catalogi API (§8.1).
var aclBaseUrl = builder.Configuration["Downstream:Acl:BaseUrl"]
?? throw new InvalidOperationException("Missing configuration 'Downstream:Acl:BaseUrl'");
// Validate Keycloak-issued tokens (ADR-0010). Audience validation is off for the walking skeleton —
// Keycloak's audience mapping is a later hardening; signature/issuer/expiry are validated.
@@ -43,14 +71,24 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy(BehandelAuth.Policy, policy => policy
.AddAuthenticationSchemes(BehandelAuth.Scheme)
.RequireAuthenticatedUser()
.RequireRole(BehandelAuth.BehandelaarRole)));
.RequireRole(BehandelAuth.BehandelaarRole));
// Beheer endpoints reuse the medewerker scheme (same realm, same realm-role lifting) but require the
// beheerder role rather than behandelaar (S-15a).
options.AddPolicy(BeheerAuth.Policy, policy => policy
.AddAuthenticationSchemes(BehandelAuth.Scheme)
.RequireAuthenticatedUser()
.RequireRole(BeheerAuth.BeheerderRole));
});
// The BFF is the portals' only backend; it fans out to the domain and projection (§8.3).
// The BFF is the portals' only backend; it fans out to the domain and projection (§8.3), and reaches
// the ACL for the beheer catalogus read (ADR-0025).
builder.Services.AddHttpClient<IDomainClient, DomainClient>(c => c.BaseAddress = new Uri(domainBaseUrl));
builder.Services.AddHttpClient<IProjectionClient, ProjectionClient>(c => c.BaseAddress = new Uri(projectionBaseUrl));
builder.Services.AddHttpClient<IAclClient, AclClient>(c => c.BaseAddress = new Uri(aclBaseUrl));
builder.Services.AddHealthChecks();
// Clear the auto-populated `servers` block so the committed spec is stable regardless of the host
@@ -68,6 +106,9 @@ app.UseAuthentication();
app.UseAuthorization();
app.MapHealthChecks("/health");
// Prometheus scrape endpoint (S-16c): exposes the OTel metrics above in Prometheus text format.
app.MapPrometheusScrapingEndpoint();
app.MapOpenApi();
// Self-service submit: requires a valid digid token; the bsn comes from the token, not the body,
@@ -178,6 +219,34 @@ app.MapPost("/behandel/registrations/{id}/decide",
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden);
// Beheer catalogus viewer (S-15a): the published zaaktypen, read-only. Reached only with a medewerker-
// realm token carrying the beheerder role; the BFF proxies the ACL's read (ADR-0025). Public-safe.
app.MapGet("/beheer/catalogi/zaaktypen", async (IAclClient acl, CancellationToken ct) =>
Results.Ok(await acl.GetZaaktypenAsync(ct)))
.RequireAuthorization(BeheerAuth.Policy)
.Produces<IReadOnlyList<BeheerZaaktype>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden);
// Beheer default-fill config (S-15b): read + edit the ACL's default-fill values. Behind medewerker-
// realm + beheerder authorization; the BFF proxies the ACL (ADR-0025). The ACL validates the values.
app.MapGet("/beheer/default-fill", async (IAclClient acl, CancellationToken ct) =>
Results.Ok(await acl.GetDefaultFillAsync(ct)))
.RequireAuthorization(BeheerAuth.Policy)
.Produces<BeheerDefaultFill>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden);
app.MapPut("/beheer/default-fill", async (BeheerDefaultFill body, IAclClient acl, CancellationToken ct) =>
{
await acl.UpdateDefaultFillAsync(body, ct);
return Results.NoContent();
})
.RequireAuthorization(BeheerAuth.Policy)
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden);
app.Run();
/// <summary>The behandelaar's decision on a registration.</summary>
@@ -230,5 +299,13 @@ internal static class BehandelAuth
private sealed record RealmAccess([property: JsonPropertyName("roles")] string[] Roles);
}
// Beheer (medewerker-realm) authorization wiring (S-15a). Reuses the "medewerker" bearer scheme
// (BehandelAuth.Scheme) and its realm-role lifting; only the required role differs.
internal static class BeheerAuth
{
public const string Policy = "beheerder";
public const string BeheerderRole = "beheerder";
}
// Exposed so the test host (WebApplicationFactory<Program>) can boot the app.
public partial class Program;
+2 -1
View File
@@ -12,6 +12,7 @@
},
"Downstream": {
"Domain": { "BaseUrl": "http://localhost:8130/" },
"Projection": { "BaseUrl": "http://localhost:8120/" }
"Projection": { "BaseUrl": "http://localhost:8120/" },
"Acl": { "BaseUrl": "http://localhost:8100/" }
}
}
@@ -0,0 +1,84 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Bff.Api;
namespace Bff.Tests;
/// <summary>
/// The beheer default-fill config endpoints (S-15b): read (GET) and edit (PUT) the ACL's default-fill,
/// reached only with a medewerker-realm token carrying the <c>beheerder</c> role. Missing token → 401;
/// a medewerker without the role → 403; a beheerder reads and updates via the ACL client.
/// </summary>
public class BeheerDefaultFillEndpointTests
{
private static HttpRequestMessage Get(string? bearer)
{
var r = new HttpRequestMessage(HttpMethod.Get, "/beheer/default-fill");
if (bearer is not null) r.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
return r;
}
private static HttpRequestMessage Put(string? bearer, object body)
{
var r = new HttpRequestMessage(HttpMethod.Put, "/beheer/default-fill") { Content = JsonContent.Create(body) };
if (bearer is not null) r.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
return r;
}
[Fact]
public async Task Rejects_read_without_a_token()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Get(bearer: null));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Rejects_a_medewerker_without_the_beheerder_role()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Get(TestTokens.Medewerker("behandelaar")));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task Serves_the_current_default_fill_to_a_beheerder()
{
using var factory = new BffFactory();
factory.Acl.DefaultFill = new BeheerDefaultFill("517439943", "517439943", "openbaar");
var response = await factory.CreateClient().SendAsync(Get(TestTokens.Medewerker("beheerder")));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<BeheerDefaultFill>();
Assert.Equal("517439943", body!.Bronorganisatie);
Assert.Equal("openbaar", body.Vertrouwelijkheidaanduiding);
}
[Fact]
public async Task Updates_the_default_fill_via_the_acl_for_a_beheerder()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(
Put(TestTokens.Medewerker("beheerder"),
new { bronorganisatie = "999999999", verantwoordelijkeOrganisatie = "888888888", vertrouwelijkheidaanduiding = "vertrouwelijk" }));
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
Assert.Equal("999999999", factory.Acl.Updated!.Bronorganisatie);
Assert.Equal("vertrouwelijk", factory.Acl.Updated.Vertrouwelijkheidaanduiding);
}
[Fact]
public async Task Rejects_an_update_from_a_non_beheerder()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(
Put(TestTokens.Medewerker("behandelaar"), new { bronorganisatie = "1", verantwoordelijkeOrganisatie = "2", vertrouwelijkheidaanduiding = "openbaar" }));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Null(factory.Acl.Updated);
}
}
@@ -0,0 +1,57 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Bff.Api;
namespace Bff.Tests;
/// <summary>
/// The beheer catalogus viewer (S-15a): reached only with a medewerker-realm token carrying the
/// <c>beheerder</c> role. A missing token is 401; an authenticated medewerker without the role (e.g.
/// a plain behandelaar) is 403; a beheerder gets the read-only list of published zaaktypen.
/// </summary>
public class BeheerEndpointTests
{
private static HttpRequestMessage Zaaktypen(string? bearer)
{
var request = new HttpRequestMessage(HttpMethod.Get, "/beheer/catalogi/zaaktypen");
if (bearer is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
return request;
}
[Fact]
public async Task Rejects_the_catalogus_without_a_token()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Zaaktypen(bearer: null));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Rejects_a_medewerker_without_the_beheerder_role()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Zaaktypen(TestTokens.Medewerker("behandelaar")));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task Serves_the_published_zaaktypen_to_a_beheerder()
{
using var factory = new BffFactory();
factory.Acl.Zaaktypen.Add(new BeheerZaaktype("BIG-REGISTRATIE", "BIG-registratie"));
var response = await factory.CreateClient().SendAsync(Zaaktypen(TestTokens.Medewerker("beheerder")));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var items = await response.Content.ReadFromJsonAsync<List<BeheerZaaktype>>();
var item = Assert.Single(items!);
Assert.Equal("BIG-REGISTRATIE", item.Identificatie);
Assert.Equal("BIG-registratie", item.Omschrijving);
}
}

Some files were not shown because too many files have changed in this diff Show More