Compare commits

...
Author SHA1 Message Date
notandClaude Opus 4.8 1113e1ebed docs: S-18b demo note + backlog — Objecten API up, wired to Objecttypen (refs #140)
CI / lint (pull_request) Successful in 1m37s
CI / build (pull_request) Successful in 7m45s
CI / unit (pull_request) Successful in 1m32s
CI / frontend (pull_request) Successful in 3m22s
CI / mutation (pull_request) Successful in 6m39s
CI / verify-stack (pull_request) Successful in 9m12s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 11:02:11 +02:00
notandClaude Opus 4.8 481a9216ce feat(infra): Objecten API up in compose, wired to Objecttypen (refs #140)
Stands up the upstream Maykin Objecten API in the stack — own PostGIS DB
+ redis, an -init that runs setup_configuration from the external config
volume (migrate + provision a static token + register the Objecttypen API
as a trusted service), and a health-checked web on host :8021. Objecten's
setup_configuration registers Objecttypen (api_type orc, api_key auth with
the S-18a dev token) so an object can reference its objecttype.

Same verbatim-image + seed-config pattern as S-18a. Wired into WAIT_SVCS,
CFG_VOLS, the SEED invocations, seed-config.sh, and the CI log-dump. Pinned
objects-api 3.4.0 (nearest release to objecttypes-api 3.4.2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 11:01:36 +02:00
notandClaude Opus 4.8 32c3d31407 test(infra): verify-objecten asserts Objecten API up + token auth + trusts Objecttypen (refs #140)
Fails against the current stack (no objecten service yet) with a clear
"no running objecten container" message. Green comes with the compose
service + seeded setup_configuration in the next commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 10:57:05 +02: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
97 changed files with 3354 additions and 43 deletions
+126 -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,37 +170,117 @@ 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: 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 }}
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 "| 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 tempo prometheus grafana 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 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
+16 -4
View File
@@ -249,10 +249,16 @@ 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.
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.
@@ -260,8 +266,8 @@ Split (issue #11 closed) into two independently-demoable slices per §13 — the
Split into independently deployable sub-slices (CLAUDE.md §13):
- **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; nginx `traceparent` passthrough). Depends on S-16a.
- **S-16c** (#124) · Prometheus metrics + golden-signal Grafana dashboards. Depends on S-16a.
- **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 ✅
@@ -271,10 +277,16 @@ Split into independently deployable sub-slices (CLAUDE.md §13):
## 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.
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
**Outcome:** Approval path writes the canonical register record to Objecten, not OpenZaak eigenschappen. Projection now sourced from Objecten events.
+29 -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
# 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-observability 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-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
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
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
docker compose -f $(COMPOSE) up -d --build
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
@@ -175,11 +176,31 @@ verify-e2e:
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: 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
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": {
@@ -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.
+161
View File
@@ -5,6 +5,167 @@ copy-pasteable walkthrough against a local `make up` stack.
---
## 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
+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.
+126
View File
@@ -560,11 +560,137 @@ 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
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]
# ── 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
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"
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:
+194
View File
@@ -296,6 +296,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/}
@@ -334,6 +338,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 +368,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 +380,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 +426,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 +459,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,6 +546,165 @@ 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
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]
# ── 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
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"
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
@@ -538,12 +719,16 @@ services:
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]
@@ -552,6 +737,7 @@ services:
build:
context: ./observability/grafana
image: register-referentie/grafana:dev
mem_limit: 512m
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: admin
@@ -568,6 +754,8 @@ volumes:
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.
@@ -583,6 +771,12 @@ volumes:
fl-bpmn:
external: true
name: rr-fl-bpmn
objecttypen-config:
external: true
name: rr-objecttypen-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,31 @@
# 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) 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,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
+2 -2
View File
@@ -1,4 +1,4 @@
# Grafana with datasources baked in via provisioning (S-16a, ADR-0023).
# Dashboards (S-16c, #124) are added under provisioning/dashboards later.
# 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}}"
}
]
}
]
}
+20 -3
View File
@@ -1,6 +1,7 @@
# Prometheus scrape config (S-16a, ADR-0023). For the backplane slice it scrapes
# only itself; the .NET services' /metrics scrape targets are added in S-16c
# (#124) when the services expose metrics.
# 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
@@ -8,3 +9,19 @@ 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']
+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"))
+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
+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
+4 -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 }
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> ..." >&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,8 @@ 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/." ;;
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>
+57
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,6 +33,15 @@ 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.AddHttpClient<IZaakGateway, OpenZaakGateway>();
// Singleton so the resolved zaaktype/informatieobjecttype URLs are cached across requests (S-27).
builder.Services.AddSingleton<IZaaktypeCatalog, CachedZaaktypeCatalog>();
@@ -19,6 +51,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 +94,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);
+21 -1
View File
@@ -2,12 +2,15 @@ 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, 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,
@@ -42,6 +45,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 +82,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);
}
@@ -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);
@@ -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);
+42 -1
View File
@@ -65,6 +65,14 @@ 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 static AclDefaults Defaults() => new()
@@ -76,8 +84,11 @@ 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));
new(gateway, FillFrom(defaults), new CachedZaaktypeCatalog(gateway, defaults), new FixedClock(today));
private sealed class FixedClock(DateOnly today) : IClock
{
@@ -105,6 +116,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()
{
@@ -225,4 +252,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);
}
}
@@ -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);
}
}
+25
View File
@@ -23,6 +23,7 @@ internal sealed class BffFactory : WebApplicationFactory<Program>
public FakeDomainClient Domain { get; } = new();
public FakeProjectionClient Projection { get; } = new();
public FakeAclClient Acl { get; } = new();
private static void ValidateWithTestKey(IServiceCollection services, string scheme) =>
services.Configure<JwtBearerOptions>(scheme, options =>
@@ -54,11 +55,13 @@ internal sealed class BffFactory : WebApplicationFactory<Program>
builder.UseSetting("Keycloak:MedewerkerAuthority", "https://keycloak.invalid/realms/medewerker");
builder.UseSetting("Downstream:Domain:BaseUrl", "http://domain.invalid/");
builder.UseSetting("Downstream:Projection:BaseUrl", "http://projection.invalid/");
builder.UseSetting("Downstream:Acl:BaseUrl", "http://acl.invalid/");
builder.ConfigureTestServices(services =>
{
services.AddSingleton<IDomainClient>(Domain);
services.AddSingleton<IProjectionClient>(Projection);
services.AddSingleton<IAclClient>(Acl);
// Both realms validate locally against the test key (no live Keycloak). The medewerker
// scheme keeps its OnTokenValidated role-lifting from Program.cs — only the validation
@@ -138,3 +141,25 @@ internal sealed class FakeProjectionClient : IProjectionClient
public Task<IReadOnlyList<ProjectionEntry>> GetRegisterAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ProjectionEntry>>(Entries);
}
/// <summary>Serves catalogus zaaktypen (S-15a) and holds the default-fill settings (S-15b).</summary>
internal sealed class FakeAclClient : IAclClient
{
public List<BeheerZaaktype> Zaaktypen { get; } = [];
public Task<IReadOnlyList<BeheerZaaktype>> GetZaaktypenAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<BeheerZaaktype>>(Zaaktypen);
public BeheerDefaultFill DefaultFill { get; set; } = new("517439943", "517439943", "openbaar");
public BeheerDefaultFill? Updated { get; private set; }
public Task<BeheerDefaultFill> GetDefaultFillAsync(CancellationToken ct = default)
=> Task.FromResult(DefaultFill);
public Task UpdateDefaultFillAsync(BeheerDefaultFill settings, CancellationToken ct = default)
{
Updated = settings;
DefaultFill = settings;
return Task.CompletedTask;
}
}
@@ -0,0 +1,28 @@
using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
namespace Bff.Tests;
/// <summary>
/// S-16c (#124): the service exposes OTel HTTP-server metrics in Prometheus text format at /metrics,
/// so Prometheus can scrape the golden signals (traffic, errors, latency) for the request path.
/// </summary>
public class MetricsEndpointTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task Metrics_endpoint_exposes_http_server_request_duration_after_traffic()
{
var client = factory.CreateClient();
// One request produces an http.server.request.duration measurement...
await client.GetAsync("/health");
// ...which the /metrics scrape endpoint then exposes in Prometheus text format.
var response = await client.GetAsync("/metrics");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Contains("http_server_request_duration", body);
}
}
+113
View File
@@ -227,10 +227,123 @@
}
}
}
},
"/beheer/catalogi/zaaktypen": {
"get": {
"tags": [
"Bff.Api"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/BeheerZaaktype"
}
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
}
},
"/beheer/default-fill": {
"get": {
"tags": [
"Bff.Api"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BeheerDefaultFill"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
},
"put": {
"tags": [
"Bff.Api"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BeheerDefaultFill"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "No Content"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
}
}
},
"components": {
"schemas": {
"BeheerDefaultFill": {
"required": [
"bronorganisatie",
"verantwoordelijkeOrganisatie",
"vertrouwelijkheidaanduiding"
],
"type": "object",
"properties": {
"bronorganisatie": {
"type": "string"
},
"verantwoordelijkeOrganisatie": {
"type": "string"
},
"vertrouwelijkheidaanduiding": {
"type": "string"
}
}
},
"BeheerZaaktype": {
"required": [
"identificatie",
"omschrijving"
],
"type": "object",
"properties": {
"identificatie": {
"type": "string"
},
"omschrijving": {
"type": "string"
}
}
},
"CurrentRegistration": {
"required": [
"registrationId",
+1 -1
View File
@@ -2,7 +2,7 @@
"stryker-config": {
"solution": "Bff.slnx",
"test-projects": ["Bff.Tests/Bff.Tests.csproj"],
"reporters": ["progress", "html"],
"reporters": ["progress", "html", "markdown"],
"mutate": [
"!**/Program.cs",
"!**/DownstreamClients.cs"
+5
View File
@@ -6,6 +6,11 @@
</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" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.18.2" />
</ItemGroup>
+28
View File
@@ -1,10 +1,35 @@
using Big.Application;
using Big.Domain;
using Big.Infrastructure;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using Quartz;
var builder = WebApplication.CreateBuilder(args);
// OpenTelemetry tracing (S-16b, ADR-0023): auto-instrument incoming ASP.NET Core requests and
// outgoing HttpClient calls, exported over OTLP to Tempo, so a request is one connected trace across
// the services. Service name + OTLP endpoint come from OTEL_* env (compose); the exporter no-ops
// harmlessly when Tempo is unreachable (e.g. a service run standalone). /health is filtered out so
// liveness polls don't flood the traces.
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());
// Options bound from configuration (compose sets Flowable__* and Acl__* env vars).
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
.GetSection("Flowable").Get<FlowableOptions>()
@@ -69,6 +94,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();
// Submit a registration. The aggregate is created (INGEDIEND) and the registratie process started;
// the zaak is opened later, off the request path, by the worker — so this returns 202 Accepted with
// a location to read the registration's progress (ADR-0009, eventual consistency).
+1 -1
View File
@@ -2,7 +2,7 @@
"stryker-config": {
"solution": "Big.slnx",
"test-projects": ["Big.Tests/Big.Tests.csproj"],
"reporters": ["progress", "html"],
"reporters": ["progress", "html", "markdown"],
"mutate": [
"!**/OpenZaakJobPump.cs",
"!**/BeoordelingEscalatiePump.cs",
@@ -5,6 +5,14 @@
<ProjectReference Include="..\..\projection-api\Projection.ReadModel\Projection.ReadModel.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>
@@ -1,9 +1,32 @@
using System.Text.Json;
using EventSubscriber.Application;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using Projection.ReadModel;
var builder = WebApplication.CreateBuilder(args);
// OpenTelemetry tracing (S-16b, ADR-0023): auto-instrument the incoming NRC notification callback and
// the outgoing ACL enrichment call, 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());
var connectionString = builder.Configuration.GetConnectionString("Projection")
?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Projection'");
// The exact Authorization header value Open Notificaties sends on each abonnement callback.
@@ -28,6 +51,9 @@ await app.Services.MigrateProjectionAsync();
app.MapGet("/health", () => "Healthy");
// Prometheus scrape endpoint (S-16c): exposes the OTel metrics above in Prometheus text format.
app.MapPrometheusScrapingEndpoint();
// The NRC abonnement callback. Open Notificaties POSTs a notification here; we project it.
// Auth-on-callback is mandatory: the auth check runs *before* the body is read, so NRC's
// registration probe (a POST without the configured Authorization, and without a valid
@@ -2,7 +2,7 @@
"stryker-config": {
"solution": "EventSubscriber.slnx",
"test-projects": ["EventSubscriber.Tests/EventSubscriber.Tests.csproj"],
"reporters": ["progress", "html"],
"reporters": ["progress", "html", "markdown"],
"thresholds": {
"high": 95,
"low": 90,
@@ -1,8 +1,31 @@
using Microsoft.EntityFrameworkCore;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using Projection.ReadModel;
var builder = WebApplication.CreateBuilder(args);
// OpenTelemetry tracing (S-16b, ADR-0023): auto-instrument incoming ASP.NET Core requests, exported
// over OTLP to Tempo, so a BFF → projection-api read is one connected trace. 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());
var connectionString = builder.Configuration.GetConnectionString("Projection")
?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Projection'");
@@ -17,6 +40,9 @@ await app.Services.MigrateProjectionAsync();
app.MapGet("/health", () => "Healthy");
// Prometheus scrape endpoint (S-16c): exposes the OTel metrics above in Prometheus text format.
app.MapPrometheusScrapingEndpoint();
// The read side of the projection. Public-safe field filtering is tightened in S-09; for now
// the minimal projection only carries id + status (bsn/naam deferred — ADR-0008).
app.MapGet("/register", async (ProjectionDbContext db, CancellationToken ct) =>
@@ -4,6 +4,14 @@
<ProjectReference Include="..\Projection.ReadModel\Projection.ReadModel.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>
+3 -1
View File
@@ -44,7 +44,9 @@ public sealed class EenZaakOpenenSteps
[When("the domain asks the ACL to open a zaak")]
public async Task WhenTheDomainAsksTheAclToOpenAZaak()
{
var service = new AclService(_gateway, _defaults!, new CachedZaaktypeCatalog(_gateway, _defaults!), new FixedClock(_today));
var fill = new InMemoryDefaultFillStore(new DefaultFillSettings(
_defaults!.Bronorganisatie, _defaults.VerantwoordelijkeOrganisatie, _defaults.Vertrouwelijkheidaanduiding));
var service = new AclService(_gateway, fill, new CachedZaaktypeCatalog(_gateway, _defaults!), new FixedClock(_today));
_returnedUrl = await service.OpenZaakAsync(_registration!);
}
@@ -48,4 +48,8 @@ public sealed class InMemoryZaakGateway : IZaakGateway
public Task<Uri> ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default)
=> Task.FromResult(ResolvedInformatieobjecttypeUrl);
public Task<IReadOnlyList<ZaaktypeSummary>> ListZaaktypenAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ZaaktypeSummary>>(
[new ZaaktypeSummary("BIG-REGISTRATIE", "BIG-registratie", ResolvedZaaktypeUrl)]);
}
+21
View File
@@ -0,0 +1,21 @@
import { expect, test } from '@playwright/test';
// S-15a walking skeleton: a beheerder logs in to the beheer portal (medewerker realm) and sees the
// read-only ZTC catalogus. The verify stack seeds and publishes the BIG-REGISTRATIE zaaktype (the
// same one verify-domain relies on), so it must appear in the catalogus. Runs against the shared
// verify stack, so it asserts on that stable seeded zaaktype rather than anything test-specific.
test('a beheerder sees the published zaaktypen in the catalogus', async ({ page }) => {
await page.goto('http://beheer/');
// The beheer portal redirects to the Keycloak medewerker realm login (same realm as behandel).
await page.locator('#username').fill('bram-beheerder');
await page.locator('#password').fill('test123');
await page.locator('#kc-login').click();
await expect(page.getByRole('heading', { name: /Catalogus/i })).toBeVisible();
// The seeded, published BIG zaaktype is shown by its business identificatie. Match the cell
// exactly (case-sensitive): getByText is case-insensitive, so it would also match the omschrijving
// cell "BIG-registratie" and trip strict mode.
await expect(page.getByRole('cell', { name: 'BIG-REGISTRATIE', exact: true })).toBeVisible();
});
+26
View File
@@ -0,0 +1,26 @@
import { expect, test } from '@playwright/test';
// S-15b: a beheerder edits the ACL default-fill in the beheer portal and gets a saved confirmation.
// Runs against the shared verify stack; it edits + saves (the ACL store is in-memory, ADR-0026) and
// asserts the confirmation, without depending on another test's state.
test('a beheerder edits and saves the default-fill', async ({ page }) => {
await page.goto('http://beheer/');
// Keycloak medewerker-realm login (same realm as behandel).
await page.locator('#username').fill('bram-beheerder');
await page.locator('#password').fill('test123');
await page.locator('#kc-login').click();
await expect(page.getByRole('heading', { name: /Catalogus/i })).toBeVisible();
// Navigate to the default-fill editor and change a value.
await page.getByRole('link', { name: /Default-fill/i }).click();
await expect(page.getByRole('heading', { name: /Default-fill/i })).toBeVisible();
const bron = page.getByLabel('Bronorganisatie');
await expect(bron).toBeVisible();
await bron.fill('517439943');
await page.getByRole('button', { name: /Opslaan/i }).click();
await expect(page.getByText(/standaardwaarden zijn opgeslagen/i)).toBeVisible();
});
+7 -2
View File
@@ -6,6 +6,9 @@ const baseURL = process.env.SELF_SERVICE_URL ?? 'http://self-service';
// The behandel portal is a second origin the happy path visits (staff approve from the werkbak);
// it needs the same insecure-origin-as-secure treatment as self-service for the PKCE login (below).
const behandelURL = process.env.BEHANDEL_URL ?? 'http://behandel';
// The beheer portal is a third medewerker-realm origin (the read-only catalogus viewer, S-15a); it
// needs the same insecure-origin-as-secure treatment as the others for the PKCE login (below).
const beheerURL = process.env.BEHEER_URL ?? 'http://beheer';
export default defineConfig({
testDir: '.',
@@ -18,7 +21,9 @@ export default defineConfig({
// OOM-killed mid-action ("Page crashed") — fixing the flakiness at its source rather than leaning
// on `retries` (CLAUDE.md §15). Only two long-running happy-path specs, so serial costs little.
workers: 1,
reporter: [['list']],
// `list` for the live log; `json` (→ /e2e/playwright-report.json in the container) is copied out
// by run-e2e-check.sh and rendered as a per-spec table in the CI job summary (#136).
reporter: [['list'], ['json', { outputFile: 'playwright-report.json' }]],
use: {
baseURL,
trace: 'on-first-retry',
@@ -33,7 +38,7 @@ export default defineConfig({
channel: 'chromium',
launchOptions: {
args: [
`--unsafely-treat-insecure-origin-as-secure=${baseURL},${behandelURL}`,
`--unsafely-treat-insecure-origin-as-secure=${baseURL},${behandelURL},${beheerURL}`,
// Write Chromium's shared memory to /tmp instead of the container's small /dev/shm, so a
// large DOM/heap can't crash the renderer on the memory-constrained runner (belt-and-braces
// alongside the single worker above).