Compare commits

..

1 Commits

Author SHA1 Message Date
202ee34ef3 chore: remove bootstrap scripts from main
The tools/seed-gitea.sh seeder + README reached main via an earlier chore
commit, but the agreed decision is to manage Gitea entirely with the tea
CLI and not keep bespoke scripts in the repo. Remove them; the backlog
they seeded already exists in Gitea.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:40:05 +02:00
80 changed files with 13 additions and 4014 deletions

View File

@@ -1,13 +0,0 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-stryker": {
"version": "4.15.0",
"commands": [
"dotnet-stryker"
],
"rollForward": false
}
}
}

View File

@@ -1,24 +0,0 @@
---
name: ADR proposal
about: Propose a decision that needs recording before coding (CLAUDE.md §14)
title: "ADR: "
labels:
- type:adr-proposal
---
**Decision to be made:**
**Context / forces:** <!-- what makes this non-obvious; constraints, trade-offs -->
**Options considered:**
1.
2.
**Proposed option + why:**
**Consequences:** <!-- what becomes easier/harder; what we commit to -->
**Coupling rules touched (CLAUDE.md §8):** <!-- none, or which and why -->
> On acceptance, the ADR file (`docs/architecture/adr-NNNN-title.md`, Nygard
> template) lands in the PR that implements the decision.

View File

@@ -1,21 +0,0 @@
---
name: Bug
about: Something behaves incorrectly
title: ""
labels:
- type:bug
---
**What happened:**
**What you expected:**
**Steps to reproduce:**
1.
2.
**Environment:** <!-- branch/commit, OS, container engine, anything relevant -->
**Logs / evidence:**
**Suspected area:** <!-- e.g. area:bff, area:acl — add the matching area label -->

View File

@@ -1,31 +0,0 @@
---
name: Slice (user story)
about: A backlog slice — independently demoable, encodes the Definition of Done
title: "S-NN · "
labels:
- type:slice
---
**Outcome:** <!-- one sentence; user-visible if possible -->
**Acceptance:**
<!-- Gherkin scenarios or testable assertions -->
-
**Touches:** <!-- services and folders -->
**Out of scope:** <!-- explicit non-goals -->
## Definition of Done
- [ ] This linked Gitea issue exists and is on the right milestone.
- [ ] Failing test written and committed first (`test(scope): … (refs #NN)`).
- [ ] Implementation makes the test pass (`feat(scope): … (refs #NN)`).
- [ ] Refactor commit follows if structure improved.
- [ ] Conventional Commit messages referencing this issue.
- [ ] All Gitea Actions CI jobs green (or `make ci` green while no runner exists).
- [ ] `docker compose up` from a fresh clone reaches green health checks within 3 minutes.
- [ ] Docs touched if behaviour, contracts, or operations changed.
- [ ] ADR added in `docs/architecture/` if a non-obvious decision was made.
- [ ] Demo note appended to `docs/demo-script.md` if the slice is user-visible.
- [ ] This issue closed by the merging PR (`closes #NN`).

View File

@@ -1,23 +0,0 @@
<!-- Title: Conventional Commit style, e.g. feat(bff): … (closes #NN) -->
## What & why
<!-- Summary of the change and the slice/bug it addresses. -->
Closes #
## Definition of Done
- [ ] Linked Gitea issue (above).
- [ ] Failing test committed before the implementation.
- [ ] Implementation makes the test pass; refactor commit if structure improved.
- [ ] Conventional Commits referencing the issue (`refs #NN`).
- [ ] CI green — all Gitea Actions jobs (or `make ci` green while no runner exists).
- [ ] `docker compose up` from a fresh clone reaches green health checks within 3 minutes.
- [ ] Docs updated if behaviour, contracts, or operations changed.
- [ ] ADR added in `docs/architecture/` if a non-obvious decision was made.
- [ ] Demo note in `docs/demo-script.md` if user-visible.
## Notes for reviewers
<!-- Anything that helps review: trade-offs, follow-ups, known gaps. -->

View File

@@ -1,95 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
# Self-hosted runner — see docs/runbooks/ci.md for the runner setup.
# `uses:` are absolute, tag-pinned URLs (CLAUDE.md §8.7 / §15).
# Each job calls a `make` target — the same one developers run locally
# (`make ci`). The Makefile is the single source of truth; see docs/runbooks/ci.md.
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- run: make lint
build:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- run: make build
unit:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- run: make unit
mutation:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- run: make mutation
# Publish the Stryker HTML report. `if: always()` uploads it even when the
# ratchet fails — that is exactly when you want to inspect the survivors.
# Glob handles Stryker's non-deterministic StrykerOutput/<timestamp>/ dir.
# Pinned to @v3 deliberately: @v4 refuses to run on Gitea (GHES guard) —
# see docs/runbooks/gitea-actions-gotchas.md §4.
- uses: https://github.com/actions/upload-artifact@v3
if: always()
with:
name: acl-mutation-report
path: services/acl/StrykerOutput/**/reports/mutation-report.html
if-no-files-found: warn
integration:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
# `make integration` brings the OpenZaak stack up, seeds a PUBLISHED BIG
# zaaktype (OZ_PUBLISH=1) and runs the Integration-category tests, then tears
# down. Needs Docker + python3 (both on ubuntu-latest) and outbound access to
# selectielijst.openzaak.nl from the OpenZaak container (see ADR-0006).
- run: make integration
- name: dump OpenZaak logs on failure
if: failure()
run: docker compose -f infra/openzaak/docker-compose.yml logs --no-color --tail=80 oz-init openzaak 2>&1 || true
- name: tear down on failure
if: failure()
run: docker compose -f infra/openzaak/docker-compose.yml down --volumes 2>&1 || true
compose-smoke:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- run: make smoke
- name: dump container logs on failure
if: failure()
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=80 oz-init openzaak nrc-init nrc-web flowable-init keycloak acl bff 2>&1 || true
- name: tear down on failure
if: failure()
run: docker compose -f infra/docker-compose.yml down --volumes 2>&1 || true

10
.gitignore vendored
View File

@@ -5,9 +5,6 @@ obj/
[Rr]elease/
*.user
# Reqnroll-generated test code (regenerated from *.feature on build)
*.feature.cs
# Test results / coverage
[Tt]est[Rr]esults/
*.trx
@@ -15,9 +12,6 @@ coverage*.json
coverage*.xml
*.coverage
# Stryker.NET mutation-testing reports (regenerated by `make mutation`)
StrykerOutput/
# Rider / VS / VS Code
.idea/
.vs/
@@ -28,10 +22,6 @@ node_modules/
dist/
.angular/
# Python / MkDocs
.venv/
site/
# OS
.DS_Store
Thumbs.db

View File

@@ -1,20 +0,0 @@
# Changelog
All notable changes to this project. Generated from Conventional Commits by git-cliff.
## Unreleased
### CI
- Gitea Actions pipeline + runner runbook (refs #30) (#37)
### Chores
- Add idempotent Gitea backlog seeder
- Remove bootstrap scripts from main (#35)
### Documentation
- Split S-00 into sub-slices (refs #1) (#33)
### Features
- Placeholder BFF + /health endpoint (closes #28) (#34)
- Containerize BFF + compose-up smoke (closes #29) (#36)

216
Makefile
View File

@@ -1,216 +0,0 @@
# Developer + CI entrypoints.
#
# These targets are the single source of truth for the checks. The Gitea
# Actions workflow (.gitea/workflows/ci.yaml) invokes the SAME targets, so
# `make ci` locally runs exactly what the pipeline runs — no drift. Until a
# self-hosted runner is registered, `make ci` is the gate (see docs/runbooks/ci.md).
SLN := register-referentie.slnx
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
# 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
# containerized CI runner. SEED populates them; run it before every `up`. The
# 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-kc-realms rr-fl-bpmn
# 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.
LOCAL_COMPOSE := infra/docker-compose.local.yml
OZ_COMPOSE := infra/openzaak/docker-compose.yml
OZ_BASE := http://localhost:8000
NRC_COMPOSE := infra/opennotificaties/docker-compose.yml
NRC_BASE := http://localhost:8001
KC_COMPOSE := infra/keycloak/docker-compose.yml
KC_BASE := http://localhost:8180
FL_COMPOSE := infra/flowable/docker-compose.yml
FL_BASE := http://localhost:8090/flowable-rest/service
STACK_FILES := -f $(OZ_COMPOSE) -f $(NRC_COMPOSE)
# On a rootless Podman dev box, point Docker CLI/Compose at the Podman socket —
# but only if that socket exists and DOCKER_HOST isn't already set, so real
# Docker hosts and CI runners are left untouched.
PODMAN_SOCK := /run/user/$(shell id -u)/podman/podman.sock
ifeq ($(wildcard $(PODMAN_SOCK)),$(PODMAN_SOCK))
ifeq ($(origin DOCKER_HOST),undefined)
export DOCKER_HOST := unix://$(PODMAN_SOCK)
endif
endif
.PHONY: ci lint build unit mutation integration smoke up down 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, smoke (mirrors Gitea Actions)
ci: lint build unit mutation smoke
## lint: verify formatting (no changes)
lint:
dotnet format $(SLN) --verify-no-changes
## build: release build
build:
dotnet build $(SLN) -c Release
## unit: run unit tests (excludes the container-backed Integration lane)
unit:
dotnet test $(SLN) -c Release --filter "Category!=Integration"
## mutation: run the Stryker.NET ratchet on the ACL (fails below the recorded baseline)
# Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore`
# makes `make mutation` work from a fresh clone. Config + break threshold (the ratchet,
# CLAUDE.md §5) live in services/acl/stryker-config.json. The ACL is the first service
# with branching logic, so it sets the repo-wide baseline; later slices ratchet it up.
mutation:
dotnet tool restore
cd services/acl && dotnet stryker
## smoke: seed config, bring the whole stack up, wait for health-checked services, tear down
# SEED populates the external config volumes first (upstream images used verbatim;
# only our acl/bff are built). `up -d --build` starts EVERYTHING. Readiness is
# checked by infra/wait-healthy.sh polling the durable, health-checked services
# ($(WAIT_SVCS)) via `docker inspect` — portable across docker compose and
# 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 kc fl
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 kc fl
docker compose -f $(COMPOSE) up -d --build
## down: stop and remove the local stack (incl. the external config volumes)
down:
docker compose -f $(COMPOSE) down --volumes
-docker volume rm -f $(CFG_VOLS)
## local: bring up the bind-mount stack (no seed step) and wait for health
## (Windows / no-make users: run `docker compose -f infra/docker-compose.local.yml up -d --build` directly)
local:
docker compose -f $(LOCAL_COMPOSE) up -d --build
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
## local-down: stop and remove the bind-mount stack
local-down:
docker compose -f $(LOCAL_COMPOSE) down --volumes
## changelog: regenerate CHANGELOG.md from Conventional Commits (git-cliff)
changelog:
git-cliff --output CHANGELOG.md
## integration: ACL integration tests against a real OpenZaak (S-04a, #46). Brings
## the stack up, seeds a PUBLISHED BIG zaaktype (OZ_PUBLISH=1, so OpenZaak accepts a
## real zaak POST), runs the Integration-category tests, then always tears down.
## Kept out of `unit`/`mutation` because it needs the live stack. See ADR-0006.
integration: openzaak-up
@bash -c 'set -e; \
for i in $$(seq 1 60); do \
c=$$(curl -s -o /dev/null -w "%{http_code}" $(OZ_BASE)/catalogi/api/v1/ || true); \
[ "$$c" = "200" ] && break; sleep 3; done; echo "OpenZaak ready ($$c)"; \
OZ_PUBLISH=1 python3 infra/openzaak/seed_catalogus.py; \
rc=0; dotnet test $(SLN) -c Release --filter "Category=Integration" || rc=$$?; \
docker compose -f $(OZ_COMPOSE) down --volumes >/dev/null 2>&1 || true; \
docker volume rm -f rr-oz-config >/dev/null 2>&1 || true; \
exit $$rc'
## openzaak-up: start the OpenZaak stack (migrations run on first start)
openzaak-up:
$(SEED) oz
docker compose -f $(OZ_COMPOSE) up -d
## openzaak-smoke: start OpenZaak, then assert it is up with auth enforced
openzaak-smoke: openzaak-up
@bash -c 'set -e; \
echo "waiting for OpenZaak to respond..."; \
for i in $$(seq 1 60); do \
code=$$(curl -s -o /dev/null -w "%{http_code}" $(OZ_BASE)/zaken/api/v1/zaken || true); \
[ -n "$$code" ] && [ "$$code" != "000" ] && break; sleep 3; \
done; \
echo "GET /zaken/api/v1/zaken (unauth) -> $$code (expect 403, auth enforced)"; test "$$code" = "403"; \
admin=$$(curl -s -o /dev/null -w "%{http_code}" $(OZ_BASE)/admin/); \
echo "GET /admin/ -> $$admin (expect 302)"; test "$$admin" = "302"; \
root=$$(curl -s -o /dev/null -w "%{http_code}" $(OZ_BASE)/zaken/api/v1/); \
echo "GET /zaken/api/v1/ -> $$root (expect 200)"; test "$$root" = "200"; \
echo "OpenZaak smoke OK"'
## openzaak-seed: bring OpenZaak up and seed the BIG catalogus (idempotent)
openzaak-seed: openzaak-up
@bash -c 'for i in $$(seq 1 50); do \
c=$$(curl -s -o /dev/null -w "%{http_code}" $(OZ_BASE)/catalogi/api/v1/ || true); \
[ "$$c" = "200" ] && break; sleep 3; done; echo "OpenZaak ready ($$c)"'
python3 infra/openzaak/seed_catalogus.py
## openzaak-down: stop and remove the OpenZaak stack (wipes data)
openzaak-down:
docker compose -f $(OZ_COMPOSE) down --volumes
-docker volume rm -f rr-oz-config
## stack-up: start OpenZaak + Open Notificaties together (shared network)
stack-up:
$(SEED) oz
docker compose $(STACK_FILES) up -d
## stack-smoke: start both, assert OpenZaak (403/302/200) and NRC (302) are reachable
stack-smoke: stack-up
@bash -c 'set -e; \
echo "waiting for OpenZaak + Open Notificaties..."; \
for i in $$(seq 1 60); do \
oz=$$(curl -s -o /dev/null -w "%{http_code}" $(OZ_BASE)/admin/ || true); \
nrc=$$(curl -s -o /dev/null -w "%{http_code}" $(NRC_BASE)/admin/ || true); \
[ "$$oz" = "302" ] && [ "$$nrc" = "302" ] && break; sleep 3; done; \
z=$$(curl -s -o /dev/null -w "%{http_code}" $(OZ_BASE)/zaken/api/v1/zaken); \
echo "OpenZaak /zaken (unauth) -> $$z (expect 403)"; test "$$z" = "403"; \
echo "OpenZaak /admin/ -> $$oz (expect 302)"; test "$$oz" = "302"; \
echo "Open Notificaties /admin/-> $$nrc (expect 302)"; test "$$nrc" = "302"; \
echo "stack smoke OK"'
## stack-down: stop and remove both stacks (wipes data)
stack-down:
docker compose $(STACK_FILES) down --volumes
-docker volume rm -f rr-oz-config
## keycloak-up: start Keycloak with the four imported realms
keycloak-up:
$(SEED) kc
docker compose -f $(KC_COMPOSE) up -d
## keycloak-smoke: start Keycloak, then verify each realm logs in + returns its claim
keycloak-smoke: keycloak-up
@bash -c 'for i in $$(seq 1 60); do \
c=$$(curl -s -o /dev/null -w "%{http_code}" $(KC_BASE)/realms/digid/.well-known/openid-configuration || true); \
[ "$$c" = "200" ] && break; sleep 3; done; echo "Keycloak ready ($$c)"'
python3 infra/keycloak/check_realms.py
## keycloak-down: stop and remove Keycloak
keycloak-down:
docker compose -f $(KC_COMPOSE) down --volumes
-docker volume rm -f rr-kc-realms
## flowable-up: start Flowable (deploys registratie.bpmn on boot)
flowable-up:
$(SEED) fl
docker compose -f $(FL_COMPOSE) up -d
## flowable-smoke: start Flowable, then verify a started instance waits on the external task
flowable-smoke: flowable-up
@bash -c 'for i in $$(seq 1 80); do \
c=$$(curl -s -o /dev/null -w "%{http_code}" -u rest-admin:test $(FL_BASE)/repository/process-definitions?key=registratie || true); \
[ "$$c" = "200" ] && break; sleep 3; done; echo "Flowable ready ($$c)"'
python3 infra/flowable/verify.py
## flowable-down: stop and remove Flowable
flowable-down:
docker compose -f $(FL_COMPOSE) down --volumes
-docker volume rm -f rr-fl-bpmn
## help: list available targets
help:
@grep -E '^## ' $(MAKEFILE_LIST) | sed 's/^## //'

View File

@@ -41,32 +41,22 @@ For the architecture rationale, see [docs/PRD.md §3](docs/PRD.md) and [docs/arc
**Prerequisites**
- .NET 10 SDK (for `make lint/build/unit`)
- A container engine with Compose v2 — Docker, or rootless Podman (see [docs/runbooks/ci.md](docs/runbooks/ci.md) for the Podman + Compose-provider setup)
- `make`, `curl`, `git`
- ~4 GB free RAM, ~5 GB free disk (grows as services land)
- Docker Engine (or Docker Desktop) with Compose v2
- ~8 GB free RAM, ~10 GB free disk
- Bash or PowerShell
**Clone**
**Bring the stack up**
```bash
git clone git@git.labs.respellion.tech:eho/register-referentie.git
cd register-referentie
git clone https://gitea.respellion.local/respellion/register-reference.git
cd register-reference
cp .env.example .env # edit if you change ports
docker compose -f infra/docker-compose.yml up -d
```
**Wired today (Iteration 0):** only the placeholder BFF exists so far. Get to green in under 10 minutes — run the full check gate, or just the running service:
Health checks should be green within ~3 minutes on a developer machine. If something fails, see [docs/runbooks/local-startup.md](docs/runbooks/local-startup.md).
```bash
make ci # lint + build + unit + container smoke — the CI gate
```
```bash
docker compose -f infra/docker-compose.yml up -d --build --wait
curl http://localhost:8080/health # -> Healthy
```
`--wait` exits non-zero unless the container reports healthy, so it doubles as the compose-up smoke test. The remaining services and the URLs below land in later slices.
**Target service URLs** *(most land in later slices)*
**Default URLs**
| Service | URL |
|---|---|
@@ -74,7 +64,7 @@ curl http://localhost:8080/health # -> Healthy
| Openbaar register | http://localhost:4201 |
| Behandel-portal | http://localhost:4202 |
| Beheer-portal | http://localhost:4203 |
| BFF | http://localhost:8080 |
| BFF | http://localhost:5000 |
| OpenZaak | http://localhost:8000 |
| Open Notificaties | http://localhost:8001 |
| Flowable | http://localhost:8080 |
@@ -83,12 +73,10 @@ curl http://localhost:8080/health # -> Healthy
Test credentials, BSNs, and personas: see [docs/synthetic-data.md](docs/synthetic-data.md).
**Build the docs site**
**Re-seed synthetic data**
```bash
python3 -m venv .venv && .venv/bin/pip install mkdocs-material
.venv/bin/mkdocs serve # live preview at http://localhost:8000
.venv/bin/mkdocs build # static site in ./site
./tools/seed.sh # or pwsh ./tools/seed.ps1
```
---

View File

@@ -1,45 +0,0 @@
# git-cliff configuration — generates CHANGELOG.md from Conventional Commits.
# Run via `make changelog`. See https://git-cliff.org.
[changelog]
header = """
# Changelog
All notable changes to this project. Generated from Conventional Commits by git-cliff.\n
"""
body = """
{% if version %}\
## {{ version }} — {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## Unreleased
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits %}\
- {{ commit.message | upper_first }}{% if commit.breaking %} **[BREAKING]**{% endif %}
{% endfor %}\
{% endfor %}\n
"""
trim = true
[git]
conventional_commits = true
filter_unconventional = true
split_commits = false
protect_breaking_commits = true
tag_pattern = "v[0-9]*"
# CalVer tags: YYYY.MM.PATCH
filter_commits = false
commit_parsers = [
{ message = "^feat", group = "Features" },
{ message = "^fix", group = "Bug Fixes" },
{ message = "^perf", group = "Performance" },
{ message = "^refactor", group = "Refactor" },
{ message = "^docs", group = "Documentation" },
{ message = "^test", group = "Tests" },
{ message = "^ci", group = "CI" },
{ message = "^build", group = "Build" },
{ message = "^arch", group = "Architecture" },
{ message = "^chore", group = "Chores" },
{ message = ".*", group = "Other" },
]

View File

@@ -1,58 +0,0 @@
# ADR-0001: Loose coupling to upstream Common Ground modules
- **Status:** Accepted
- **Date:** 2026-06-03
- **Deciders:** Respellion engineering
- **Template note:** This is the first ADR and doubles as the worked example of
the Nygard template. Copy its shape for new ADRs (`adr-NNNN-title.md`).
## Context
This reference application orchestrates several upstream Common Ground modules —
OpenZaak (ZGW APIs), Open Notificaties (NRC), Objecten/Objecttypen, Flowable,
Keycloak. Each is an independently developed, independently deployed peer. The
temptation in a demo is to reach straight into a peer's database or couple to its
internal schema to move faster. That coupling is exactly what makes Common Ground
landscapes brittle and un-upgradeable in practice.
We need a stance, recorded up front, on how our services may talk to these peers.
## Decision
**We integrate with upstream modules only through their documented public APIs, and
we isolate that integration behind explicit anti-corruption boundaries.**
Concretely (mirrors CLAUDE.md §8):
1. The **ACL** is the only code that talks to ZGW APIs; no other service constructs
ZGW URLs.
2. The **Workflow Client** is the only code that talks to Flowable; BPMN models hold
no OpenZaak knowledge.
3. **Portals talk only to the BFF** — never directly to a backend or a peer module.
4. **No direct database access across services or to any peer.** Each service owns
its schema; the Read Projection is a rebuildable derived artefact.
5. **Idempotency at every event boundary** (the Event Subscriber tolerates duplicate
and out-of-order NRC events).
Bending any of these is an ADR-worthy moment (CLAUDE.md §14): stop and open an
`adr-proposal` issue first.
## Consequences
**Positive**
- Upstream modules can be upgraded or swapped behind their APIs without rippling
through our services.
- Coupling is visible and minimal — anti-corruption code lives in one named place.
- The architecture teaches the Common Ground pattern by enforcing it.
**Negative / costs**
- More indirection: a translation layer (ACL, Workflow Client) instead of direct
calls. Accepted — it's the point.
- Eventual consistency across aggregates must be designed for, not assumed away.
**Follow-up**
- Each integration slice that touches a boundary references this ADR; new boundary
decisions get their own ADR.

View File

@@ -1,53 +0,0 @@
# ADR-0002: BIG catalogus design and OpenZaak seeding
- **Status:** Accepted
- **Date:** 2026-06-03
- **Deciders:** Respellion engineering
- **Relates to:** S-01 (#2)
## Context
S-01 needs a reproducible `BIG` catalogus in OpenZaak with a **lean** `BIG-registratie`
zaaktype (only schema-mandatory fields) plus a `bsn` eigenschap, and a JWT client that
can list zaaktypen. We had to decide *how* to provision this idempotently at startup.
Findings from the OpenZaak image (`openzaak/open-zaak:latest`):
- `setup_configuration` (run by the init container) is declarative and idempotent, with
steps for **JWT secrets** and **applicaties** (`vng_api_common_credentials`,
`vng_api_common_applicaties`) — but **no step for catalogi/zaaktypen**.
- Catalogus/zaaktype/eigenschap can only be created through the **ZTC REST API**.
- Publishing a zaaktype requires ≥1 roltype, ≥1 resultaattype and ≥2 statustypen.
## Decision
1. **Provision the JWT client declaratively** via `infra/openzaak/setup_configuration/data.yaml`:
a `JWTSecret` (`big-reference-seed` / dev secret) and an `Applicatie` with
`heeft_alle_autorisaties: true`. Idempotent, runs in the init container.
2. **Seed the catalogus/zaaktype/eigenschap via the ZTC API** with an idempotent,
stdlib-only script (`infra/openzaak/seed_catalogus.py`, `make openzaak-seed`). It mints
a ZGW JWT from the provisioned client and matches existing objects (by `domein` /
`identificatie` / `naam`, querying `status=alles` so concepts are seen) before creating.
3. **Keep the zaaktype a CONCEPT (not published).** Publishing pulls in roltypen,
statustypen and resultaattypen, which go beyond "schema-mandatory"; those arrive with
the workflow/zaak slices that actually need a published type. Listing uses `status=alles`.
4. **Disable outbound notifications** (`NOTIFICATIONS_DISABLED=true`) until Open Notificaties
(NRC) lands in S-01-c — otherwise every ZTC write 500s trying to notify.
5. **Fixed dev values:** RSIN `517439943` (elfproef-valid test value); the JWT secret is
dev-only and documented as such.
## Consequences
- **Reproducible & version-robust:** the API-driven seed doesn't depend on fixture PKs or
a catalogi `setup_configuration` step that may change between versions.
- **Teaches the pattern:** the seed talks to OpenZaak exactly the way the ACL will later —
through the documented ZGW API, with a JWT (ADR-0001).
- The seed is a script, but a **data loader is explicitly anticipated** (PRD §8); it lives
under `infra/openzaak/`, not as ad-hoc tooling.
- **Follow-ups:** re-enable notifications when NRC is up (S-01-c); publish the zaaktype (add
the related types) when a slice needs to create real zaken; pin the OpenZaak image tag.
## Alternatives considered
- **Fully declarative in `data.yaml`** — rejected: no catalogi/zaaktype step exists.
- **Django `loaddata` fixture** — rejected: brittle, tied to model PKs and the exact image
version; bypasses the API the rest of the system uses.

View File

@@ -1,44 +0,0 @@
# ADR-0003: ACL default-fill strategy
- **Status:** Accepted
- **Date:** 2026-06-04
- **Deciders:** Respellion engineering
- **Relates to:** S-04 (#5); builds on ADR-0001 (loose coupling)
## Context
The ACL is the only code that talks to ZGW APIs (ADR-0001 / CLAUDE.md §8.1). When the
domain asks it to "open a zaak", the domain payload is intentionally free of ZGW
specifics — it carries domain facts (e.g. the registrant's BSN), not OpenZaak fields. But
OpenZaak's `POST /zaken` requires ZGW-mandatory fields: `bronorganisatie`,
`verantwoordelijkeOrganisatie`, `startdatum`, `vertrouwelijkheidaanduiding`, and a
`zaaktype` URL. Something has to supply those, and it must not leak into the domain.
## Decision
**The ACL default-fills the ZGW-mandatory zaak fields; the domain never sees them.**
- `bronorganisatie`, `verantwoordelijkeOrganisatie`, `vertrouwelijkheidaanduiding`, and the
`zaaktype` URL come from **ACL configuration** (`AclDefaults` options) — not hardcoded,
not from the domain. This keeps them operationally manageable (the beheer portal will
edit them in S-15) and environment-specific (the seeded BIG zaaktype URL differs per env).
- `startdatum` is derived from an injected **clock** (today's date), so it is
deterministic in tests.
- The mapping from domain payload → ZGW `ZaakRequest` lives entirely inside the ACL
(`Application` builds the request from payload + defaults; `Infrastructure` serialises and
POSTs it). No other service constructs ZGW payloads or URLs.
## Consequences
- **Positive:** the domain stays ZGW-agnostic; ZGW knowledge is in one named place; defaults
are config (testable, env-specific, later editable via the beheer portal).
- **Cost:** the ACL must be configured per environment (the seeded zaaktype URL, the
organisation RSINs). Missing/invalid config fails fast at the ACL boundary.
- **Follow-ups:** mapping the BSN onto the zaak (eigenschap/rol), status transitions, and
documents are explicitly out of scope for S-04 and get their own slices.
## Alternatives considered
- **Defaults in the domain payload** — rejected: leaks ZGW concerns into the domain,
violating ADR-0001.
- **Hardcoded defaults in code** — rejected: not env-specific, not operationally editable.

View File

@@ -1,52 +0,0 @@
# ADR-0004: Reqnroll as the BDD acceptance framework
- **Status:** Accepted
- **Date:** 2026-06-04
- **Deciders:** Respellion engineering
- **Relates to:** S-04 (#5); supports CLAUDE.md §3 (BDD at the use-case level) and §11 (tests pyramid)
## Context
CLAUDE.md §11 mandates that each user-visible flow is driven by a Gherkin acceptance
scenario living in `tests/acceptance/`, and §3 names "BDD at the use-case level" as a core
engineering principle. The foundational slices (S-00…S-03) added no acceptance layer; S-04
is the first slice with real domain behaviour to drive, so it is where the BDD framework is
introduced. We need a .NET tool that:
- parses Gherkin `.feature` files and binds steps to C#,
- integrates with the existing xUnit test runner (the repo standardises on xUnit), so
acceptance tests run under the same `dotnet test` / `make ci` gate as everything else,
- is actively maintained on modern .NET (we target net10.0).
## Decision
**Use [Reqnroll](https://reqnroll.net/) (`Reqnroll.xUnit`) for acceptance tests.**
- Reqnroll is the actively-maintained, open-source successor to SpecFlow (which is no longer
maintained). It keeps the same Gherkin + `[Binding]` model, so the knowledge transfers.
- `Reqnroll.xUnit` generates one xUnit test per scenario, so acceptance tests are discovered
and run by the same runner as the unit tests — no second test framework, no extra CI step.
- Acceptance projects live under `tests/acceptance/` per the PRD §9 layout. Generated
`*.feature.cs` files are build artefacts and are git-ignored.
## Consequences
- **Positive:** one assertion/runner stack (xUnit) across unit and acceptance tests; scenarios
are written in business language (Dutch domain terms inline) and reviewed as the slice's
contract; maintained tooling on net10.0.
- **Cost:** a new dependency (`Reqnroll.xUnit`) and its xUnit v2 transitive graph. Reqnroll
pulls `xunit.core` but not the assertion library, so the `xunit` metapackage is referenced
explicitly to get `Assert`.
- **Replaceable by:** hand-written xUnit "scenario" tests with a Given/When/Then helper, at
the cost of losing Gherkin as the shared, readable contract — which is the whole point of §3.
- **Follow-ups:** the real-OpenZaak integration test (Testcontainers) and the Stryker mutation
baseline for S-04 are tracked as their own issues split off #5.
## Alternatives considered
- **SpecFlow** — rejected: unmaintained and without an official net10.0 story; Reqnroll is its
drop-in successor.
- **Plain xUnit Given/When/Then helpers** — rejected for user-visible flows: loses the
business-readable Gherkin contract that §3/§11 require. Still fine for unit-level tests.
- **Xunit.Gherkin.Quick** — rejected: lighter but less featureful (no hooks/scoped contexts,
smaller community) than Reqnroll.

View File

@@ -1,68 +0,0 @@
# ADR-0005: Stryker.NET for mutation testing, baseline on the ACL
- **Status:** Accepted
- **Date:** 2026-06-25
- **Deciders:** Respellion engineering
- **Relates to:** S-04b (#47); proposed in #51; supports CLAUDE.md §5 (mutation ratchet) and §3 (Definition of Done)
## Context
CLAUDE.md §5 mandates Stryker on every PR with a **ratchet**: CI fails on a regression
below the established baseline, and the baseline only ever moves up. §3 lists "mutation
(ratchet)" as a Definition-of-Done gate for **every** slice. Yet no baseline existed — so,
strictly, no slice could satisfy that gate. S-04b establishes it.
The ACL is the natural place to set the first baseline: it is the first service with real
branching logic — `OpenZaakGateway` (HTTP contract, geo CRS headers, error handling),
`ZgwToken` (HS256 JWT minting), and the `AclService` default-fill mapping. We need a tool
that:
- mutates C# and runs the existing xUnit suite per mutant,
- is reproducible (same version locally and in CI, no global install),
- understands this repo's `.slnx` solution format (used repo-wide),
- emits a break threshold CI can gate on.
## Decision
**Use [Stryker.NET](https://stryker-mutator.io/docs/stryker-net/) (`dotnet-stryker`),
pinned as a local dotnet tool**, configured in solution mode against `Acl.slnx`.
- Pinned in `.config/dotnet-tools.json` (v4.15.0); `dotnet tool restore` makes
`make mutation` reproducible from a fresh clone, locally and in CI — no global install.
- **Solution mode** (`stryker-config.json``solution: Acl.slnx`) mutates the two projects
under test (`Acl.Application`, `Acl.Infrastructure`); `Acl.Api` is untested and skipped.
Stryker 4.15 reads `.slnx` directly, so no throwaway `.sln` shim is needed.
- A `mutation` make target runs it; it is wired into `make ci` and a parallel Gitea Actions
`mutation` job, keeping `make ci` an exact mirror of the pipeline.
**Baseline:** writing S-04b's tests surfaced that the ACL suite was thin — the initial
score was **35%** (survivors: unasserted CRS headers, null guards, error paths, and JWT
claims). Those tests were strengthened (killing the mutants honestly rather than lowering
the bar), raising the score to **95%**. The enforced `break` threshold is set to **90%**
one-mutant headroom over the ~20-mutant surface, since a single mutant is ≈5%.
## Consequences
- **Positive:** test *strength* is gated, not just coverage; the ratchet protects the ACL's
ZGW contract logic; the baseline is repo-wide and ratchets upward per §5.
- **Cost:** a new dependency (`dotnet-stryker`) and a slower CI job than unit tests (~25 s on
the small ACL). Pinned + tool-restored, so reproducible.
- **One accepted survivor:** a mutation of the empty-response *exception message string*.
Asserting exception message text is brittle and the behaviour (type + control flow) is
unchanged — treated as an equivalent mutant, not a test gap.
- **Commitment:** later slices ratchet the threshold up deliberately, never down (§5). New
services add their own mutation run as they gain branching logic (BFF, Domain, …).
- **Replaceable by:** no realistic .NET alternative — Stryker.NET is the tool §5 already
names; the fallback is no mutation testing, which §5 forbids.
## Alternatives considered
- **Global `dotnet tool install -g`** — rejected: not reproducible/pinned per clone; the
local manifest gives every checkout and the CI runner the same version.
- **Mutate the whole `register-referentie.slnx`** — rejected for this slice: scopes the
baseline to services with no logic yet (BFF skeleton), diluting the signal. Each service
opts in as it gains logic.
- **Application-only scope** — rejected: would leave `Acl.Infrastructure`'s HTTP/JWT logic —
the riskiest code — unguarded by the ratchet.
- **Coverage gate instead of mutation** — rejected: line coverage does not measure whether
tests would *catch* a regression; that is the whole point of §5.

View File

@@ -1,84 +0,0 @@
# ADR-0006: Provision the ACL integration test against the compose stack
- **Status:** Accepted
- **Date:** 2026-06-29
- **Deciders:** Respellion engineering
- **Relates to:** S-04a (#46); proposed in #53; builds on ADR-0001 (loose coupling), ADR-0002 (catalogus design), ADR-0003 (default-fill); supports CLAUDE.md §11 (integration tests via real containers)
## Context
S-04 delivered the ACL's one operation — `OpenZaakGateway.OpenZaakAsync` — with unit
tests against a stubbed `HttpMessageHandler` and a Reqnroll scenario over an in-memory
stand-in. The deferred S-04 acceptance criterion (S-04a) is the one a stub cannot meet:
> Integration test using Testcontainers against real OpenZaak passes.
The test must drive the gateway against a **real** OpenZaak — real ZGW JWT auth, the real
`POST /zaken/api/v1/zaken` contract, real CRS handling — and assert a zaak comes back.
Two ways to stand OpenZaak up were considered (the issue's open question): (a) a full
**Testcontainers** graph started by the test, or (b) target the **running compose stack**
the repo already defines (`infra/openzaak/docker-compose.yml`, `make openzaak-up`).
Investigation reversed the initially-favoured Testcontainers option:
1. **Testcontainers .NET has no docker-compose support.** OpenZaak needs PostGIS + Redis +
a `setup_configuration` one-shot (the JWT client) + the API. Honouring "full graph" would
mean re-implementing that five-service stack — init ordering, the config volume, health
gating — by hand in C#, duplicating the maintained compose file and rotting with it. That
rubs against CLAUDE.md §13 ("if a test is hard to write, the design is wrong").
2. **The test cannot be hermetic anyway.** OpenZaak's Zaken API rejects a zaak against a
*concept* zaaktype (`not-published`), and a *published* zaaktype requires ≥1 resultaattype,
which OpenZaak validates by fetching the external **Selectielijst** reference API
(`selectielijst.openzaak.nl`). So a real zaak POST already depends on outbound internet
from the OpenZaak container — the self-containment that motivated Testcontainers is lost
regardless of how the containers are started.
## Decision
**The ACL integration test targets the running compose stack; it does not start containers
itself. No new test dependency is added.**
- A gated test project `Acl.IntegrationTests` (`[Trait("Category","Integration")]`) talks to
OpenZaak with a plain `HttpClient`, reusing the same endpoint + JWT-client config the seed
uses (`OZ_BASE` / `OZ_CLIENT_ID` / `OZ_SECRET`, defaulting to the local stack). It locates
the published `BIG-REGISTRATIE` zaaktype via the Catalogi API and exercises the real
`OpenZaakGateway` against it.
- **The lane is kept out of the fast checks.** `make unit` runs with
`--filter "Category!=Integration"`; Stryker is pinned to `Acl.Tests` (`test-projects`), so
neither the unit nor the mutation lane needs a live stack. A new `make integration` target
brings the stack up, seeds, runs the lane, and always tears down — mirrored by a Gitea
Actions `integration` job. This matches `make` being the single source of truth (ADR-0005).
- **Publishing is opt-in in the seed.** `infra/openzaak/seed_catalogus.py` gains an
`OZ_PUBLISH=1` path that adds the relations OpenZaak's publish requires — two statustypen
(begin/eind), a roltype, and a resultaattype whose Selectielijst procestype is matched onto
the zaaktype — then publishes. The default seed (S-01 / ADR-0002) still leaves the zaaktype
a concept; only `make integration` flips the switch.
## Consequences
- **Positive:** a small, honest test over the real ZGW contract with no bespoke orchestration
to maintain; the compose stack is exercised exactly as operators run it; no new dependency.
- **It caught a real bug.** The gateway sent the zaak body via `JsonContent` without a
`Content-Length`, so .NET framed it as `Transfer-Encoding: chunked`, which OpenZaak's uwsgi
rejects with 400. A stubbed handler accepts either framing, so only a real OpenZaak surfaced
it. Fixed by buffering the body (`LoadIntoBufferAsync`); guarded in the fast lane by a unit
test asserting a `Content-Length` is set. This is the concrete justification for §11's
integration tier.
- **External dependency:** the integration job needs the OpenZaak container to reach
`selectielijst.openzaak.nl`. It is a stable public reference API (the same one OpenZaak uses
in production) but it is a network touchpoint, and a CI environment without egress would need
a local Selectielijst service or a recorded fixture. `OZ_SELECTIELIJST` overrides the base URL.
- **Cost:** the lane needs the stack up first; CI runs it as a dedicated job (Docker + dotnet +
python3), separate from the fast lanes.
## Alternatives considered
- **Full Testcontainers graph** — rejected: re-implements the compose stack in C# (brittle,
duplicative) for no hermeticity gain, since the Selectielijst dependency remains.
- **Single OpenZaak container (sqlite/locmem)** — rejected: diverges from the real
PostGIS-backed, Redis-cached deployment; the Zaken API is a geo API and the divergence would
undermine the contract the test exists to verify.
- **Mock OpenZaak / record-replay** — rejected: that is what the existing stubbed-handler unit
tests already do; it cannot exercise the real contract, and would not have caught the chunked
body bug.

View File

@@ -1,52 +0,0 @@
# Working with Gitea: issues, milestones, PRs
Gitea is the **system of record** (CLAUDE.md §7). `BACKLOG.md` is a human-readable
mirror of the active milestone — when they disagree, Gitea wins.
## Issues
- Open issues from the templates in `.gitea/ISSUE_TEMPLATE/`:
- **Slice** — a backlog user story (`S-NN · …`), encodes the Definition of Done.
- **Bug** — a defect.
- **ADR proposal** — a decision to record before coding (CLAUDE.md §14).
- Every issue gets `type:*` plus the relevant `area:*` label(s), and is assigned to
its iteration **milestone** (`Iteration N — …`).
- Splitting a slice that grew too big: see CLAUDE.md §13 and the "How to split a
slice" section of `BACKLOG.md`.
## Branches & commits
- Trunk-based: short-lived branches off `main`, squash-merged. Never push to `main`.
- Branch name: `<type>/<issue-number>-<slug>`, e.g. `feat/28-bff-health`.
- Conventional Commits, each referencing its issue: `feat(bff): … (refs #28)`.
- TDD order: the `test(...)` red commit precedes the `feat(...)` green commit.
## Pull requests
- Open with `tea pr create` (the Gitea CLI) or the web UI; the body uses
`.gitea/PULL_REQUEST_TEMPLATE.md` and its DoD checklist.
- The merging PR closes its issue via `closes #NN` in the squash-commit body.
Work that isn't finished (e.g. CI green pending a runner) uses `refs #NN` and the
issue stays open.
- A PR needs a linked issue. Don't open one without it.
## CI gate
Until a self-hosted `respellion-linux` runner is registered, `make ci` is the gate
(it runs the same checks the workflow does). See [runbooks/ci.md](runbooks/ci.md).
## CLI cheatsheet (`tea`)
```bash
tea issues list --state open # backlog
tea issue create --title "S-NN · …" --labels type:slice,area:bff --milestone "Iteration 1 — Walking Skeleton"
tea pr create --base main --head <branch> --title "…" --description "… closes #NN"
tea pr list # open PRs
tea pr merge <n> --style squash # merge (after review)
```
## Changelog & releases
`CHANGELOG.md` is generated from commits by `git-cliff` (`make changelog`), refreshed
on tag. Versioning is **CalVer** `YYYY.MM.PATCH`; releases are published via Gitea
Releases.

View File

@@ -1,23 +0,0 @@
# register-referentie
A reference application demonstrating Respellion's Common Ground architecture
pattern. Quality and architectural clarity over feature throughput — every commit
should teach.
## Where to go
- **[Product Requirements](PRD.md)** — what we're building and why.
- **[ADR-0001: Loose coupling](architecture/adr-0001-loose-coupling.md)** — the
non-negotiable integration stance; the template for future ADRs.
- **[Working in Gitea](gitea-workflow.md)** — issues, milestones, branches, PRs.
- **[CI runbook](runbooks/ci.md)** — the pipeline and the `make ci` local gate.
## Quickstart
See the repository `README.md`. In short: clone, then either run the checks with
`make ci`, or bring the BFF up with
`docker compose -f infra/docker-compose.yml up -d --build --wait` and
`curl http://localhost:8080/health`.
> This site is built with MkDocs Material (`mkdocs build`). It grows with the
> backlog; sections appear as their slices land.

View File

@@ -1,140 +0,0 @@
# CI runbook — Gitea Actions
> **Status: active.** The workflow `.gitea/workflows/ci.yaml` runs on Gitea's
> hosted `ubuntu-latest` runner — no self-hosted runner required.
> **`make ci` is still the local gate** — it runs the exact same checks
> (the workflow calls the same `make` targets).
## The pipeline
`.gitea/workflows/ci.yaml` runs on every push and pull request to `main`. Each job
calls a `make` target — the **single source of truth** for the checks, so local
and CI cannot drift:
| Job | Target | Needs |
|---|---|---|
| `lint` | `make lint``dotnet format … --verify-no-changes` | .NET 10 SDK |
| `build` | `make build``dotnet build … -c Release` | .NET 10 SDK |
| `unit` | `make unit``dotnet test … -c Release --filter "Category!=Integration"` | .NET 10 SDK |
| `mutation` | `make mutation``dotnet tool restore``dotnet stryker` (ACL); uploads the HTML report as an artifact | .NET 10 SDK |
| `integration` | `make integration``openzaak-up` → seed a **published** BIG zaaktype (`OZ_PUBLISH=1`) → `dotnet test … --filter "Category=Integration"` → tear down | .NET 10 SDK + container engine + python3 + egress to `selectielijst.openzaak.nl` |
| `compose-smoke` | `make smoke` → seed config volumes → `up -d` (full stack) → `up --wait` durable services → `down` | container engine + compose v2 |
All `uses:` references are absolute, tag-pinned URLs (`https://github.com/actions/checkout@v4`,
`https://github.com/actions/setup-dotnet@v4`) per CLAUDE.md §8.7 and §15 — Gitea
Actions resolves them from GitHub.
> **`compose-smoke` runs on a containerized runner.** Workspace bind mounts do
> **not** reach the sibling containers Compose starts, so config/assets are
> streamed into external named volumes via `docker cp` (`infra/seed-config.sh`),
> and the upstream images are used verbatim (no build). If you add a service that
> needs a repo file at runtime, seed it the same way — don't bind-mount it. Note:
> bare `docker compose up` no longer self-seeds; use `make up`. See
> [gitea-actions-gotchas.md](gitea-actions-gotchas.md).
## Mutation testing (the ratchet)
The `mutation` job enforces test *strength*, not just coverage (CLAUDE.md §5).
[Stryker.NET](https://stryker-mutator.io/docs/stryker-net/) is pinned as a local
dotnet tool (`.config/dotnet-tools.json`), so it runs identically locally and in CI:
```bash
make mutation # dotnet tool restore + dotnet stryker on the ACL
```
Config lives in [`services/acl/stryker-config.json`](../../services/acl/stryker-config.json).
It runs in **solution mode** against `Acl.slnx`, mutating the two projects under test
(`Acl.Application`, `Acl.Infrastructure`); `Acl.Api` has no tests and is skipped.
**Baseline (the ratchet):** the ACL is the first service with branching logic, so it
sets the repo-wide baseline. Observed score **95%**; enforced `break` threshold **90%**
(one-mutant headroom over the ~20-mutant surface). Stryker exits non-zero — failing the
job — when the score drops below `break`. Per §5 the baseline only moves **up**, and only
as a slice's stated outcome; never lower it. New services add their own mutation run as
they gain logic.
The HTML report is written to `services/acl/StrykerOutput/<timestamp>/reports/` (git-ignored);
open it to see survived vs. killed mutants.
In CI the `mutation` job publishes that report as the **`acl-mutation-report`** artifact
(download it from the run's summary page). The upload step uses `if: always()`, so the
report is available even when the ratchet *fails* — which is exactly when you want to inspect
the survivors. It is the repo's first use of `actions/upload-artifact`, pinned to **`@v3`**:
`@v4` refuses to run on Gitea (its `@actions/artifact` v2 library blocks any non-github.com
server as "GHES"), while `@v3` speaks the artifact protocol Gitea implements. See
[gitea-actions-gotchas.md §4](gitea-actions-gotchas.md) (§15).
## Running the stack locally without `make` (Windows / Docker Desktop)
`make` and the bash helpers assume a Unix shell. To bring the whole stack up on a
machine without them (e.g. Windows + Docker Desktop), use the **local compose
file**, which bind-mounts the config instead of seeding volumes — so it needs no
`make`, no seed step, and no bash:
```bash
docker compose -f infra/docker-compose.local.yml up -d --build # any engine
docker compose -f infra/docker-compose.local.yml up -d --build --wait # Docker Desktop (Compose v2)
docker compose -f infra/docker-compose.local.yml down --volumes
```
On Linux/macOS the same thing is wrapped as `make local` / `make local-down`.
`infra/docker-compose.local.yml` mirrors the canonical `infra/docker-compose.yml`
but swaps the external config volumes for bind mounts — valid locally because a
local daemon can see the working directory (the seed/volume dance only exists for
the containerized CI runner). Keep the two files in sync.
## Running CI locally (`make ci`)
Until the runner exists, run the full pipeline yourself before pushing:
```bash
make ci # lint + build + unit + mutation + smoke — the fast pipeline lanes
make lint # or a single stage
make mutation # Stryker.NET ratchet on the ACL
make smoke # compose up --wait, curl /health, tear down
make integration # ACL ↔ real OpenZaak (its own CI job; not part of `make ci`)
```
> `make integration` is a separate, heavier lane (it stands the OpenZaak stack up and
> seeds a published zaaktype), so it is **not** folded into `make ci`. Run it before
> pushing changes that touch the ACL gateway or the OpenZaak seed. See ADR-0006.
**Prerequisites:** .NET 10 SDK, a container engine with Compose v2, and `curl`.
On a **rootless Podman** box (the default dev setup here), the `smoke` target needs
the Podman API socket and a Compose provider:
```bash
systemctl --user enable --now podman.socket # start the API socket
ln -sf "$(command -v podman)" ~/.local/bin/docker # docker -> podman shim
# install Docker Compose v2 into ~/.local/bin as `docker-compose` (the provider)
```
The Makefile auto-points `DOCKER_HOST` at `/run/user/$(id -u)/podman/podman.sock`
when that socket exists and `DOCKER_HOST` is unset, so `make smoke` "just works"
locally while leaving real Docker hosts / CI runners untouched.
## Runner: `ubuntu-latest`
All jobs run on Gitea's hosted **`ubuntu-latest`** runner — no self-hosted runner
setup is required. The hosted runner ships with Docker and Docker Compose v2, so
`make smoke` (`docker compose … up --wait`) works without extra configuration.
If Gitea's hosted runners are unavailable and a self-hosted fallback is needed,
register an `act_runner` with the `ubuntu-latest` label:
```bash
VER=0.2.11
curl -fsSL -o /usr/local/bin/act_runner \
"https://dl.gitea.com/act_runner/${VER}/act_runner-${VER}-linux-amd64"
chmod +x /usr/local/bin/act_runner
act_runner register --no-interactive \
--instance https://git.labs.respellion.tech \
--token <REGISTRATION_TOKEN> \
--name respellion-ci-1 \
--labels "ubuntu-latest:docker://node:20-bookworm"
act_runner daemon
```

View File

@@ -1,40 +0,0 @@
# Flowable runbook
Flowable (`infra/flowable/docker-compose.yml`) runs the **flowable-rest** engine on
Postgres. The `workflows/registratie.bpmn` model is deployed via the REST API at boot by
the `flowable-init` container. Host port **:8090**; REST API under
`http://localhost:8090/flowable-rest/service/` (basic auth **rest-admin / test**, dev only).
## The model — `registratie`
A minimal "Registratie ontvangen" process: **start → external-worker task
`OpenZaakAanmaken` → end**. The external task is where the Workflow Client / ACL will
later create the zaak in OpenZaak (S-04/S-05); for now a started instance parks there.
## Quick test (`make`)
```bash
make flowable-up # start engine + deploy registratie.bpmn on boot
make flowable-smoke # start + verify a new instance waits on the external task
make flowable-down # stop + wipe
```
`make flowable-smoke` runs `infra/flowable/verify.py`, which:
1. waits for the `registratie` process definition to be deployed,
2. starts an instance and asserts it did **not** end immediately,
3. asserts an execution is parked at activity **`OpenZaakAanmaken`**,
4. deletes the test instance.
## Notes
- **Deploy on boot** is idempotent: `flowable-init` skips if a deployment named
`registratie` already exists (so restarts on the same volume don't pile up versions).
- **Dev creds:** `rest-admin` / `test`. Override via the flowable-rest app config for
anything beyond local dev.
- **Image** `flowable/flowable-rest:latest` — pin a tag when stabilising.
- Start an instance by hand:
```bash
curl -s -u rest-admin:test -H 'Content-Type: application/json' \
-d '{"processDefinitionKey":"registratie"}' \
http://localhost:8090/flowable-rest/service/runtime/process-instances
```

View File

@@ -1,126 +0,0 @@
# Gitea Actions gotchas
How our CI (Gitea Actions on the hosted **`ubuntu-latest`** runner) differs from a
local run, and the workarounds in this repo. Referenced by `CLAUDE.md` §8.7/§15.
**One root cause sits under most of this:** the runner executes the job **inside a
container**, so when a step runs `docker compose up`, Compose starts the stack as
**sibling containers** on the host's daemon. Anything that assumes the job and
those containers share a filesystem — or a `localhost` — breaks.
| Gotcha | Fix | Lives in |
|---|---|---|
| Bind-mounted config arrives empty | `docker cp` config into external volumes | `infra/seed-config.sh` |
| `docker compose up --wait` is unsupported / flaky | poll health with `docker inspect` | `infra/wait-healthy.sh` |
| `pg_isready` passes before PostGIS is ready | add a `PostGIS_Version()` probe | the db healthchecks |
| `upload-artifact@v4` fails ("not supported on GHES") | pin `@v3` | `.gitea/workflows/ci.yaml` (`mutation` job) |
---
## 1. Bind mounts don't reach the containers
**Symptom** — green locally, but `compose-smoke` fails with:
```
oz-init-1 | CommandError: Yaml file `/app/setup_configuration/data.yaml` does not exist.
```
Migrations run fine; only the step that reads a *mounted* file fails. The same
trap hits `nrc-init`, `flowable-init`, and `keycloak`.
**Why** — a relative bind mount like `./openzaak/setup_configuration:/app/...` is
resolved by Compose to a path *inside the job container*
(`/workspace/.../setup_configuration`). The daemon then looks for that path on
*its own host*, doesn't find it, and mounts an **empty directory**. (It works on a
runner that executes jobs on the host — which is why moving to `ubuntu-latest`
exposed it.)
**Fix** — use the upstream images verbatim (no build) and stream config into
**external named volumes** with `docker cp`, which copies over the Docker API and
so works wherever the daemon runs. `infra/seed-config.sh` creates each volume,
mounts it in a throwaway helper, and copies the files in:
| Asset | Volume | Mounted at |
|---|---|---|
| OpenZaak `data.yaml` | `rr-oz-config` | `oz-init:/app/setup_configuration` |
| Keycloak realms | `rr-kc-realms` | `keycloak:/opt/keycloak/data/import` |
| `registratie.bpmn` | `rr-fl-bpmn` | `flowable-init:/work` |
The volumes are `external: true` with fixed names, so they resolve identically
under docker compose and podman-compose. `make` seeds before every `up`; `make
down` removes them. (Open Notificaties needs nothing — `nrc-init` migrates only.)
**Consequence — bare `docker compose up` can't self-seed external volumes:**
- **CI / Linux / macOS:** `make up` or `make smoke` (seed, then start).
- **No-make / Windows:** `infra/docker-compose.local.yml` — a twin stack that
**bind-mounts** the config instead. Bind mounts are fine *locally* because a
local daemon can see your working directory, so
`docker compose -f infra/docker-compose.local.yml up -d` just works.
**Why not the obvious alternatives**
- *Bake config into an image* (incl. an inline Dockerfile) — `docker compose up`
would then work unaided, but it's a build; we wanted the upstream images as-is.
- *Compose `configs:` with inline `content`* — Compose writes a client-side temp
file and bind-mounts it, hitting the exact same problem.
- *A host-executing runner* — bind mounts would work with zero seeding, but it
reintroduces a self-hosted runner and undoes the move to `ubuntu-latest`.
---
## 2. Readiness: poll health, don't use `--wait`
`docker compose up --wait` looks ideal but fails us three ways:
- **podman-compose doesn't implement it** (`unrecognized arguments: --wait`) — so
it would break local dev.
- A project-wide `--wait` **treats a one-shot exiting `0` as a failure** unless
something `depends_on` it with `service_completed_successfully`. `flowable-init`
deploys the BPMN and exits with no dependant, so `--wait` fails the moment it
does — last line `container infra-flowable-init-1 exited (0)`.
- The containerized runner **can't reach published host ports**, so an external
`curl localhost:8080/health` can't work either.
**Fix**`infra/wait-healthy.sh` polls each durable service (`openzaak nrc-web
acl bff`, listed as `WAIT_SVCS` in the `Makefile`) with `docker ps` + `docker
inspect '{{.State.Health.Status}}'` until it reports `healthy`. It uses only
primitives both runtimes support, reads the **in-container** healthcheck (no host
port needed), and ignores the one-shots (they only need to have run).
`WAIT_TIMEOUT` defaults to 420 s — enough for the cold OpenZaak migrate (~90 s)
plus app start.
---
## 3. `pg_isready` passes before PostGIS is ready
`pg_isready` succeeds as soon as the TCP port is open — *before* the
`postgis/postgis` image has finished running `CREATE EXTENSION postgis`. An init
container that starts migrating in that window can fail on a missing PostGIS. So
the db healthchecks add a `SELECT PostGIS_Version()` probe, making dependents wait
for the extension, not just the port.
---
## 4. `actions/upload-artifact@v4` refuses to run on Gitea
**Symptom** — the `mutation` job's `make mutation` step passes (95% score), but the
upload step right after it fails the job:
```
::error::@actions/artifact v2.0.0+, upload-artifact@v4+ and download-artifact@v4+
are not currently supported on GHES.
❌ Failure - Main https://github.com/actions/upload-artifact@v4
```
**Why**`upload-artifact@v4` bundles `@actions/artifact` v2, which inspects the
server URL and **hard-aborts on anything that isn't `github.com`**, treating Gitea
as an unsupported GitHub Enterprise Server. The check fires regardless of whether
the Gitea server can actually store artifacts (1.24+ can). It is the *action*, not
the server, that refuses.
**Fix** — pin **`actions/upload-artifact@v3`** (and `download-artifact@v3` if ever
needed). v3 uses the older artifact protocol that Gitea implements, and has no GHES
guard. Inputs are the same (`name`, `path`, `if-no-files-found`), so it is a drop-in
swap. Do **not** bump to `@v4` until act_runner advertises github.com-compatible
artifact support.

View File

@@ -1,37 +0,0 @@
# Keycloak runbook
Keycloak (`infra/keycloak/docker-compose.yml`) runs in dev mode with four realms
imported at boot from `infra/keycloak/realms/`: **digid**, **eherkenning**, **eidas**,
**medewerker**. It mocks the Dutch identity brokers so portals can do real OIDC logins
locally. Host port **:8180**.
## Quick test (`make`)
```bash
make keycloak-up # start Keycloak + import realms (~30-60s first boot)
make keycloak-smoke # start + verify every realm logs in and returns its claim
make keycloak-down # stop + wipe
```
`make keycloak-smoke` runs `infra/keycloak/check_realms.py`, which does a password-grant
login per realm and asserts the identifying claim:
| Realm | User | Claim asserted |
|---|---|---|
| digid | jan-burger | `bsn` |
| eherkenning | acme-ondernemer | `kvk` |
| eidas | pierre-dupont | `eidas_id` |
| medewerker | merel-behandelaar | role `behandelaar` |
All test users / credentials are in [../synthetic-data.md](../synthetic-data.md).
## Notes
- **Admin console:** <http://localhost:8180/> — `admin` / `admin` (dev only).
- **Client `big-portal`** is public with `standardFlowEnabled` (browser redirect login)
*and* `directAccessGrantsEnabled` (password grant, used by the smoke test).
- **Dev store:** in-memory H2 via `start-dev`; realms re-import on each boot, so changes
made in the admin UI don't persist. Edit the realm JSONs to make durable changes.
- **Image** pinned to `quay.io/keycloak/keycloak:26.1`.
- Claims are injected by OIDC protocol mappers on `big-portal` (user attribute → token
claim); `medewerker` roles come through `realm_access.roles`.

View File

@@ -1,44 +0,0 @@
# Open Notificaties (NRC) runbook
The Open Notificaties stack (`infra/opennotificaties/docker-compose.yml`) is a lean
adaptation of the upstream dev compose: PostGIS db, redis (also the Celery broker), a
one-shot migrate-init, the API, and a celery worker. It shares the **`cg`** Docker
network with the OpenZaak stack so the two can reach each other by service name.
NRC is published on host **:8001** (OpenZaak holds :8000).
## Quick test (`make`) — both platforms together
```bash
make stack-up # OpenZaak + Open Notificaties on the shared network
make stack-smoke # start both + assert reachable (OZ 403/302/200, NRC 302)
make stack-down # stop + wipe both
```
`make stack-smoke` runs `docker compose -f infra/openzaak/... -f infra/opennotificaties/... up -d`
and asserts:
| Check | Expected |
|---|---|
| OpenZaak `GET /zaken/api/v1/zaken` (no JWT) | 403 (auth enforced) |
| OpenZaak `GET /admin/` | 302 |
| Open Notificaties `GET /admin/` | 302 |
NRC admin UI: <http://localhost:8001/admin/> (dev superuser **admin / admin**).
## Notification wiring is deferred to S-06
Both platforms are **up and reachable**, but OpenZaak→NRC notification *delivery* is not
wired yet, and OpenZaak still runs with `NOTIFICATIONS_DISABLED=true`. The bidirectional
auth wiring (NRC `setup_configuration`: Services + Authorization to OpenZaak's
Autorisaties API + JWT secrets + Kanalen + Abonnementen; OpenZaak's NotificationConfig)
lands with **S-06 (Event Subscriber)** — the slice that actually consumes events. NRC's
`setup_configuration/data.yaml` is intentionally minimal (migrations only) until then.
This matches S-01's acceptance, which asks only that the platforms *come up in compose*
and a health check confirms them reachable.
## Prerequisites
Same rootless-Podman setup as the rest of the repo — see [ci.md](ci.md) and
[openzaak.md](openzaak.md). `systemctl --user start podman.socket` once per session.

View File

@@ -1,82 +0,0 @@
# OpenZaak runbook
The OpenZaak stack (`infra/openzaak/docker-compose.yml`) is a lean adaptation of the
upstream open-zaak dev compose: PostGIS db, redis, a one-shot init that runs
migrations, the OpenZaak API, and a celery worker.
## Quick test (`make`)
```bash
make openzaak-up # start the stack (first run pulls images + migrates: 1-3 min)
make openzaak-smoke # start + assert it's up with auth enforced (403/302/200)
make openzaak-seed # start + seed the BIG catalogus (idempotent)
make openzaak-down # stop and wipe data
```
## Seed the BIG catalogus
`make openzaak-seed` brings the stack up and runs `infra/openzaak/seed_catalogus.py`,
which creates (idempotently, via the ZTC API):
- catalogus **BIG**
- a lean **BIG-REGISTRATIE** zaaktype (concept; only schema-mandatory fields)
- a **bsn** eigenschap on it
then confirms the JWT client can list it. See **ADR-0002** for the design (why the
zaaktype stays a concept, why notifications are disabled, why the API not a fixture).
**JWT client** (provisioned declaratively by `setup_configuration/data.yaml`, **dev only**):
| | |
|---|---|
| client_id | `big-reference-seed` |
| secret | `insecure-dev-secret-change-me` |
| authorizations | `heeft_alle_autorisaties` (all) |
The seed mints a ZGW JWT (HS256) from these and calls `/catalogi/api/v1/...`.
`make openzaak-smoke` polls until the API responds, then asserts:
| Check | Expected |
|---|---|
| `GET /zaken/api/v1/zaken` (no JWT) | **403**`PermissionDenied` ZGW fout (auth enforced) |
| `GET /admin/` | **302** — admin login redirect |
| `GET /zaken/api/v1/` | **200** — ZGW API schema root |
> **403, not 401.** OpenZaak's ZGW APIs return `403 PermissionDenied` for a missing
> or invalid JWT. The S-01 acceptance text says "401" — that's inaccurate; 403 is the
> correct auth-enforced response.
The admin UI is at <http://localhost:8000/admin/>; the dev superuser is **admin /
admin** (from the compose env — dev only).
## Prerequisites (rootless Podman)
Same setup as the rest of the repo (see [ci.md](ci.md)):
```bash
systemctl --user start podman.socket # the Docker-API socket the shim talks to
```
The Makefile auto-points `DOCKER_HOST` at the Podman socket when it exists, so the
`make openzaak-*` targets work without extra env.
## Notes
- **Not in `make ci`.** The OpenZaak smoke is a separate, heavier check (large image
pull + migrations); it is intentionally kept out of `make ci` so the core gate
stays fast. Run `make openzaak-smoke` when you touch the OpenZaak stack.
- **Notifications disabled.** `NOTIFICATIONS_DISABLED=true` — otherwise ZTC writes 500
trying to notify. Open Notificaties is now up (see [opennotificaties.md](opennotificaties.md)),
but OZ→NRC delivery wiring + re-enabling lands with **S-06**.
- **Zaaktype is a concept**, not published (publishing needs roltypen/statustypen/
resultaattypen — beyond the lean seed). List with `?status=alles`.
- **Image tag.** Pinned to `openzaak/open-zaak:1.28.2` via `${OPENZAAK_TAG}` (bump
deliberately, not via `:latest`).
- **Config arrives via a volume, not a bind mount.** `setup_configuration/data.yaml`
is streamed into the external `rr-oz-config` volume by `infra/seed-config.sh`
(`docker cp`) and mounted at `/app/setup_configuration`, so the init container
finds it on Gitea's containerized CI runner too (bind mounts don't reach sibling
containers there). The image is the upstream `openzaak/open-zaak` verbatim — no
build. Run via `make openzaak-up` (seeds first). See
[gitea-actions-gotchas.md](gitea-actions-gotchas.md).

View File

@@ -1,35 +0,0 @@
# Synthetic data
All credentials here are **dev-only** synthetic test data — never real personal data,
never used outside local development.
## Keycloak realms (S-02)
Keycloak runs at <http://localhost:8180> (admin console: **admin / admin**). Four realms
are imported at boot from `infra/keycloak/realms/`. Each has a public OIDC client
**`big-portal`** (standard flow + direct access grants enabled, redirect URIs `*` for dev).
All test users share the password **`test123`**.
| Realm | Mimics | User | Identifying claim |
|---|---|---|---|
| `digid` | DigiD (burgers) | `jan-burger` | `bsn` = `123456782` |
| `eherkenning` | eHerkenning (bedrijven) | `acme-ondernemer` | `kvk` = `12345678` |
| `eidas` | eIDAS (EU) | `pierre-dupont` | `eidas_id` = `FR/NL/AB-1234-5678` |
| `medewerker` | Internal staff | `merel-behandelaar` | role `behandelaar` |
| `medewerker` | Internal staff | `tom-teamlead` | roles `behandelaar`, `teamlead` |
The identifying claims are injected via OIDC protocol mappers on `big-portal`
(user-attribute → token claim); `medewerker` roles appear in `realm_access.roles`.
## Get a token (for testing)
```bash
curl -s -X POST \
http://localhost:8180/realms/digid/protocol/openid-connect/token \
-d grant_type=password -d client_id=big-portal \
-d username=jan-burger -d password=test123 -d scope=openid | jq -r .access_token
```
Decode the JWT payload to see the `bsn` claim. `make keycloak-smoke` checks every realm
automatically.

View File

@@ -1,296 +0,0 @@
# LOCAL development stack — runs with a plain `docker compose up`, no make / no
# seed step / no bash. Use this on a local engine (Docker Desktop on Windows or
# macOS, or rootless Podman on Linux).
#
# docker compose -f infra/docker-compose.local.yml up -d --build # podman
# docker compose -f infra/docker-compose.local.yml up -d --build --wait # Docker Desktop
# docker compose -f infra/docker-compose.local.yml down --volumes
#
# It is identical to infra/docker-compose.yml EXCEPT that the three config inputs
# (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are **bind-mounted** from
# the repo instead of being streamed into external volumes by infra/seed-config.sh.
# Bind mounts work here because a local daemon can see your working directory —
# the seed dance only exists for the containerized CI runner, where it can't. See
# docs/runbooks/gitea-actions-gotchas.md.
#
# `infra/docker-compose.yml` remains the CI-canonical stack; keep the two in sync.
#
# Port map (host):
# 8000 OpenZaak · 8001 Open Notificaties · 8080 BFF · 8090 Flowable REST
# 8100 ACL · 8180 Keycloak (all admin: admin / admin — dev only)
services:
# ── OpenZaak (S-01) ──────────────────────────────────────────────────────
oz-db:
image: docker.io/postgis/postgis:17-3.5
environment:
POSTGRES_USER: openzaak
POSTGRES_PASSWORD: openzaak
POSTGRES_DB: openzaak
command: postgres -c max_connections=300
volumes:
- oz-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U openzaak -d openzaak && psql -U openzaak -d openzaak -c 'SELECT PostGIS_Version();' -q 2>/dev/null"]
interval: 5s
timeout: 5s
retries: 30
start_period: 15s
networks: [cg]
oz-redis:
image: docker.io/library/redis:7
networks: [cg]
oz-init:
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
environment: &oz-env
DJANGO_SETTINGS_MODULE: openzaak.conf.docker
SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: oz-db
DB_NAME: openzaak
DB_USER: openzaak
DB_PASSWORD: openzaak
IS_HTTPS: "no"
ALLOWED_HOSTS: "*"
CACHE_DEFAULT: oz-redis:6379/0
CACHE_AXES: oz-redis:6379/0
CELERY_BROKER_URL: redis://oz-redis:6379/1
CELERY_RESULT_BACKEND: redis://oz-redis:6379/1
DISABLE_2FA: "true"
NOTIFICATIONS_DISABLED: "true"
OPENZAAK_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_PASSWORD: admin
OPENZAAK_SUPERUSER_EMAIL: admin@localhost
RUN_SETUP_CONFIG: "true"
command: /setup_configuration.sh
# Bind mount (`:z` relabels for SELinux on Linux; a no-op on Docker Desktop).
volumes:
- ./openzaak/setup_configuration:/app/setup_configuration:ro,z
depends_on:
oz-db:
condition: service_healthy
oz-redis:
condition: service_started
networks: [cg]
openzaak:
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
environment: *oz-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:
- "8000:8000"
depends_on:
oz-init:
condition: service_completed_successfully
networks: [cg]
oz-celery:
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
environment: *oz-env
command: /celery_worker.sh
depends_on:
oz-init:
condition: service_completed_successfully
networks: [cg]
# ── Open Notificaties / NRC (S-01-c) ─────────────────────────────────────
nrc-db:
image: docker.io/postgis/postgis:17-3.5
environment:
POSTGRES_USER: opennotificaties
POSTGRES_PASSWORD: opennotificaties
POSTGRES_DB: opennotificaties
command: postgres -c max_connections=300
volumes:
- nrc-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U opennotificaties -d opennotificaties"]
interval: 5s
timeout: 3s
retries: 10
networks: [cg]
nrc-redis:
image: docker.io/library/redis:7
networks: [cg]
nrc-init:
# Migrations only (see the canonical compose / ADR-0002); no config needed.
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: &nrc-env
DJANGO_SETTINGS_MODULE: nrc.conf.docker
SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: nrc-db
DB_NAME: opennotificaties
DB_USER: opennotificaties
DB_PASSWORD: opennotificaties
IS_HTTPS: "no"
ALLOWED_HOSTS: "*"
CACHE_DEFAULT: nrc-redis:6379/0
CACHE_AXES: nrc-redis:6379/0
CELERY_BROKER_URL: redis://nrc-redis:6379/1
CELERY_RESULT_BACKEND: redis://nrc-redis:6379/1
DISABLE_2FA: "true"
OPENNOTIFICATIES_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_PASSWORD: admin
OPENNOTIFICATIES_SUPERUSER_EMAIL: admin@localhost
command: ["sh", "-c", "/wait_for_db.sh && OTEL_SDK_DISABLED=True python src/manage.py migrate"]
depends_on:
nrc-db:
condition: service_healthy
nrc-redis:
condition: service_started
openzaak:
condition: service_healthy
networks: [cg]
nrc-web:
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: *nrc-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:
- "8001:8000"
depends_on:
nrc-init:
condition: service_completed_successfully
networks: [cg]
nrc-celery:
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: *nrc-env
command: /celery_worker.sh
depends_on:
nrc-init:
condition: service_completed_successfully
networks: [cg]
# ── Keycloak (S-02) ──────────────────────────────────────────────────────
keycloak:
image: quay.io/keycloak/keycloak:26.1
command: ["start-dev", "--import-realm"]
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
KC_HEALTH_ENABLED: "true"
KC_HTTP_ENABLED: "true"
ports:
- "8180:8080"
volumes:
- ./keycloak/realms:/opt/keycloak/data/import:ro,z
networks: [cg]
# ── Flowable (S-03) ──────────────────────────────────────────────────────
flowable-db:
image: docker.io/library/postgres:16
environment:
POSTGRES_USER: flowable
POSTGRES_PASSWORD: flowable
POSTGRES_DB: flowable
volumes:
- flowable-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U flowable -d flowable"]
interval: 5s
timeout: 3s
retries: 10
networks: [cg]
flowable-rest:
image: docker.io/flowable/flowable-rest:latest
environment:
SPRING_DATASOURCE_DRIVER-CLASS-NAME: org.postgresql.Driver
SPRING_DATASOURCE_URL: jdbc:postgresql://flowable-db:5432/flowable
SPRING_DATASOURCE_USERNAME: flowable
SPRING_DATASOURCE_PASSWORD: flowable
ports:
- "8090:8080"
depends_on:
flowable-db:
condition: service_healthy
networks: [cg]
flowable-init:
image: docker.io/curlimages/curl:latest
restart: "no"
volumes:
- ../workflows/registratie.bpmn:/work/registratie.bpmn:ro,z
command:
- sh
- -c
- |
base=http://flowable-rest:8080/flowable-rest/service/repository/deployments
until curl -sf -u rest-admin:test "$$base" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then
echo "registratie already deployed; skip"
else
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$base" >/dev/null && echo "deployed registratie"
fi
depends_on:
flowable-rest:
condition: service_started
networks: [cg]
# ── ACL ──────────────────────────────────────────────────────────────────
acl:
build:
context: ../services/acl
dockerfile: Dockerfile
image: register-referentie/acl:dev
environment:
Acl__OpenZaak__BaseUrl: http://openzaak:8000/
Acl__OpenZaak__ClientId: big-reference-seed
Acl__OpenZaak__Secret: insecure-dev-secret-change-me
Acl__Defaults__Bronorganisatie: "517439943"
Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943"
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
Acl__Defaults__ZaaktypeUrl: ${ACL_ZAAKTYPE_URL:-http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000}
ports:
- "8100:8080"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
openzaak:
condition: service_healthy
networks: [cg]
# ── BFF ──────────────────────────────────────────────────────────────────
bff:
build:
context: ../services/bff
dockerfile: Dockerfile
image: register-referentie/bff:dev
ports:
- "8080:8080"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
networks: [cg]
volumes:
oz-db:
nrc-db:
flowable-db:
networks:
cg:

View File

@@ -1,320 +0,0 @@
# Development stack — boots all infra services plus the ACL and BFF.
#
# Consolidates infra/openzaak/, infra/opennotificaties/, infra/keycloak/,
# and infra/flowable/ and adds the ACL and BFF services.
#
# Port map (host):
# 8000 OpenZaak ZGW API (admin: admin / admin)
# 8001 Open Notificaties (admin: admin / admin)
# 8080 BFF GET /health → Healthy
# 8090 Flowable REST http://localhost:8090/flowable-rest/service/
# 8100 ACL GET /health → Healthy POST /zaken
# 8180 Keycloak (admin: admin / admin)
#
# docker compose -f infra/docker-compose.yml up -d --build --wait
#
# After first boot, seed the BIG catalogus and note the zaaktype URL:
# python infra/openzaak/seed_catalogus.py
# Then set ACL_ZAAKTYPE_URL in a .env file or your shell and re-up the acl
# service:
# export ACL_ZAAKTYPE_URL=http://openzaak:8000/catalogi/api/v1/zaaktypen/<uuid>
# docker compose -f infra/docker-compose.yml up -d acl
services:
# ── OpenZaak (S-01) ──────────────────────────────────────────────────────
oz-db:
image: docker.io/postgis/postgis:17-3.5
environment:
POSTGRES_USER: openzaak
POSTGRES_PASSWORD: openzaak
POSTGRES_DB: openzaak
command: postgres -c max_connections=300
volumes:
- oz-db:/var/lib/postgresql/data
healthcheck:
# pg_isready only checks TCP; the second clause verifies PostGIS is installed
# so oz-init migrations can safely start (avoids race on cold container start).
test: ["CMD-SHELL", "pg_isready -U openzaak -d openzaak && psql -U openzaak -d openzaak -c 'SELECT PostGIS_Version();' -q 2>/dev/null"]
interval: 5s
timeout: 5s
retries: 30
start_period: 15s
networks: [cg]
oz-redis:
image: docker.io/library/redis:7
networks: [cg]
oz-init:
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
environment: &oz-env
DJANGO_SETTINGS_MODULE: openzaak.conf.docker
SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: oz-db
DB_NAME: openzaak
DB_USER: openzaak
DB_PASSWORD: openzaak
IS_HTTPS: "no"
ALLOWED_HOSTS: "*"
CACHE_DEFAULT: oz-redis:6379/0
CACHE_AXES: oz-redis:6379/0
CELERY_BROKER_URL: redis://oz-redis:6379/1
CELERY_RESULT_BACKEND: redis://oz-redis:6379/1
DISABLE_2FA: "true"
NOTIFICATIONS_DISABLED: "true"
OPENZAAK_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_PASSWORD: admin
OPENZAAK_SUPERUSER_EMAIL: admin@localhost
RUN_SETUP_CONFIG: "true"
command: /setup_configuration.sh
# data.yaml is streamed into this external volume by infra/seed-config.sh
# before start (bind mounts don't reach sibling containers on the CI runner).
volumes:
- oz-config:/app/setup_configuration:ro
depends_on:
oz-db:
condition: service_healthy
oz-redis:
condition: service_started
networks: [cg]
openzaak:
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
environment: *oz-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:
- "8000:8000"
depends_on:
oz-init:
condition: service_completed_successfully
networks: [cg]
oz-celery:
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
environment: *oz-env
command: /celery_worker.sh
depends_on:
oz-init:
condition: service_completed_successfully
networks: [cg]
# ── Open Notificaties / NRC (S-01-c) ─────────────────────────────────────
nrc-db:
image: docker.io/postgis/postgis:17-3.5
environment:
POSTGRES_USER: opennotificaties
POSTGRES_PASSWORD: opennotificaties
POSTGRES_DB: opennotificaties
command: postgres -c max_connections=300
volumes:
- nrc-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U opennotificaties -d opennotificaties"]
interval: 5s
timeout: 3s
retries: 10
networks: [cg]
nrc-redis:
image: docker.io/library/redis:7
networks: [cg]
nrc-init:
# Plain base image — nrc-init runs migrations only (see command below), so it
# needs no baked config.
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: &nrc-env
DJANGO_SETTINGS_MODULE: nrc.conf.docker
SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: nrc-db
DB_NAME: opennotificaties
DB_USER: opennotificaties
DB_PASSWORD: opennotificaties
IS_HTTPS: "no"
ALLOWED_HOSTS: "*"
CACHE_DEFAULT: nrc-redis:6379/0
CACHE_AXES: nrc-redis:6379/0
CELERY_BROKER_URL: redis://nrc-redis:6379/1
CELERY_RESULT_BACKEND: redis://nrc-redis:6379/1
DISABLE_2FA: "true"
OPENNOTIFICATIES_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_PASSWORD: admin
OPENNOTIFICATIES_SUPERUSER_EMAIL: admin@localhost
# Migrations only for now. No setup_configuration steps are enabled yet (the
# OZ<->NRC notification wiring lands in S-06), and NRC's `setup_configuration`
# aborts with "No steps enabled" on the empty data.yaml — so we run `migrate`
# directly instead of /setup_configuration.sh. See data.yaml and ADR-0002.
command: ["sh", "-c", "/wait_for_db.sh && OTEL_SDK_DISABLED=True python src/manage.py migrate"]
depends_on:
nrc-db:
condition: service_healthy
nrc-redis:
condition: service_started
openzaak:
condition: service_healthy
networks: [cg]
nrc-web:
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: *nrc-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:
- "8001:8000"
depends_on:
nrc-init:
condition: service_completed_successfully
networks: [cg]
nrc-celery:
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: *nrc-env
command: /celery_worker.sh
depends_on:
nrc-init:
condition: service_completed_successfully
networks: [cg]
# ── Keycloak (S-02) ──────────────────────────────────────────────────────
keycloak:
image: quay.io/keycloak/keycloak:26.1
command: ["start-dev", "--import-realm"]
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
KC_HEALTH_ENABLED: "true"
KC_HTTP_ENABLED: "true"
ports:
- "8180:8080"
# realm exports are streamed into this external volume by infra/seed-config.sh.
volumes:
- kc-realms:/opt/keycloak/data/import:ro
networks: [cg]
# ── Flowable (S-03) ──────────────────────────────────────────────────────
flowable-db:
image: docker.io/library/postgres:16
environment:
POSTGRES_USER: flowable
POSTGRES_PASSWORD: flowable
POSTGRES_DB: flowable
volumes:
- flowable-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U flowable -d flowable"]
interval: 5s
timeout: 3s
retries: 10
networks: [cg]
flowable-rest:
image: docker.io/flowable/flowable-rest:latest
environment:
SPRING_DATASOURCE_DRIVER-CLASS-NAME: org.postgresql.Driver
SPRING_DATASOURCE_URL: jdbc:postgresql://flowable-db:5432/flowable
SPRING_DATASOURCE_USERNAME: flowable
SPRING_DATASOURCE_PASSWORD: flowable
ports:
- "8090:8080"
depends_on:
flowable-db:
condition: service_healthy
networks: [cg]
flowable-init:
image: docker.io/curlimages/curl:latest
restart: "no"
# registratie.bpmn is streamed into this external volume by infra/seed-config.sh.
volumes:
- fl-bpmn:/work:ro
command:
- sh
- -c
- |
base=http://flowable-rest:8080/flowable-rest/service/repository/deployments
until curl -sf -u rest-admin:test "$$base" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then
echo "registratie already deployed; skip"
else
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$base" >/dev/null && echo "deployed registratie"
fi
depends_on:
flowable-rest:
condition: service_started
networks: [cg]
# ── ACL ──────────────────────────────────────────────────────────────────
acl:
build:
context: ../services/acl
dockerfile: Dockerfile
image: register-referentie/acl:dev
environment:
Acl__OpenZaak__BaseUrl: http://openzaak:8000/
Acl__OpenZaak__ClientId: big-reference-seed
Acl__OpenZaak__Secret: insecure-dev-secret-change-me
Acl__Defaults__Bronorganisatie: "517439943"
Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943"
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
# Override with the real zaaktype URL after running seed_catalogus.py.
Acl__Defaults__ZaaktypeUrl: ${ACL_ZAAKTYPE_URL:-http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000}
ports:
- "8100:8080"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
openzaak:
condition: service_healthy
networks: [cg]
# ── BFF ──────────────────────────────────────────────────────────────────
bff:
build:
context: ../services/bff
dockerfile: Dockerfile
image: register-referentie/bff:dev
ports:
- "8080:8080"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
networks: [cg]
volumes:
oz-db:
nrc-db:
flowable-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.
oz-config:
external: true
name: rr-oz-config
kc-realms:
external: true
name: rr-kc-realms
fl-bpmn:
external: true
name: rr-fl-bpmn
networks:
cg:

View File

@@ -1,70 +0,0 @@
# Flowable (S-03): the flowable-rest engine on Postgres.
# The registratie.bpmn model is deployed via the REST API on startup by flowable-init.
#
# docker compose -f infra/flowable/docker-compose.yml up -d
# # REST API (basic auth) under http://localhost:8090/flowable-rest/service/
#
# Host port 8090 (8000/8001/8080/8180 are taken by OpenZaak/NRC/BFF/Keycloak).
services:
flowable-db:
image: docker.io/library/postgres:16
environment:
POSTGRES_USER: flowable
POSTGRES_PASSWORD: flowable
POSTGRES_DB: flowable
volumes:
- flowable-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U flowable -d flowable"]
interval: 5s
timeout: 3s
retries: 10
networks: [cg]
flowable-rest:
image: docker.io/flowable/flowable-rest:latest
environment:
SPRING_DATASOURCE_DRIVER-CLASS-NAME: org.postgresql.Driver
SPRING_DATASOURCE_URL: jdbc:postgresql://flowable-db:5432/flowable
SPRING_DATASOURCE_USERNAME: flowable
SPRING_DATASOURCE_PASSWORD: flowable
ports:
- "8090:8080"
depends_on:
flowable-db:
condition: service_healthy
networks: [cg]
# Deploys workflows/registratie.bpmn via the REST API once flowable-rest is up.
# Idempotent: skips if a deployment named "registratie" already exists.
flowable-init:
image: docker.io/curlimages/curl:latest
restart: "no"
# registratie.bpmn is streamed into this external volume by infra/seed-config.sh.
volumes:
- fl-bpmn:/work:ro
command:
- sh
- -c
- |
base=http://flowable-rest:8080/flowable-rest/service/repository/deployments
until curl -sf -u rest-admin:test "$$base" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then
echo "registratie already deployed; skip"
else
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$base" >/dev/null && echo "deployed registratie"
fi
depends_on:
flowable-rest:
condition: service_started
networks: [cg]
volumes:
flowable-db:
# populated out-of-band by infra/seed-config.sh (docker cp) — see that script.
fl-bpmn:
external: true
name: rr-fl-bpmn
networks:
cg:

View File

@@ -1,54 +0,0 @@
#!/usr/bin/env python3
"""Smoke-check Flowable: the registratie process is deployed, and starting an
instance parks it on the OpenZaakAanmaken external task. Stdlib only.
"""
import base64, json, sys, time, urllib.error, urllib.request
BASE = "http://localhost:8090/flowable-rest/service"
AUTH = "Basic " + base64.b64encode(b"rest-admin:test").decode()
def call(method, path, payload=None):
data = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(BASE + path, data=data, method=method, headers={
"Authorization": AUTH, "Content-Type": "application/json", "Accept": "application/json"})
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, json.loads(r.read() or "null")
def main():
# 1. process definition deployed? (wait for the async init-container deploy)
defs = {"total": 0}
for _ in range(40):
_, defs = call("GET", "/repository/process-definitions?key=registratie")
if defs["total"] >= 1:
break
time.sleep(3)
assert defs["total"] >= 1, "registratie process definition not deployed"
print(f"process definition 'registratie' deployed (total={defs['total']})")
# 2. start an instance
st, pi = call("POST", "/runtime/process-instances", {"processDefinitionKey": "registratie"})
assert st == 201, f"start failed: {st} {pi}"
pid = pi["id"]
assert pi.get("ended") is False, "instance ended immediately — external task not reached"
print(f"started instance {pid} (ended={pi.get('ended')})")
# 3. waiting on the external task?
_, ex = call("GET", f"/runtime/executions?processInstanceId={pid}")
activities = [e.get("activityId") for e in ex["data"]]
assert "OpenZaakAanmaken" in activities, f"not waiting at OpenZaakAanmaken: {activities}"
print(f"instance is waiting at the external task: {activities}")
# 4. cleanup
try:
call("DELETE", f"/runtime/process-instances/{pid}")
print("cleaned up instance")
except urllib.error.HTTPError:
pass
print("flowable smoke OK")
if __name__ == "__main__":
main()

View File

@@ -1,60 +0,0 @@
#!/usr/bin/env python3
"""Smoke-check the Keycloak realms: each realm's OIDC login works (password grant)
and returns its expected identifying claim. Stdlib only. Exits non-zero on failure.
"""
import base64, json, sys, urllib.error, urllib.parse, urllib.request
BASE = "http://localhost:8180"
CLIENT = "big-portal"
PWD = "test123"
# realm, user, claim ("__roles__" => check realm_access.roles), expected-contains
CHECKS = [
("digid", "jan-burger", "bsn", "123456782"),
("eherkenning", "acme-ondernemer", "kvk", "12345678"),
("eidas", "pierre-dupont", "eidas_id", "FR/NL"),
("medewerker", "merel-behandelaar", "__roles__", "behandelaar"),
]
def decode(jwt):
p = jwt.split(".")[1]
p += "=" * (-len(p) % 4)
return json.loads(base64.urlsafe_b64decode(p))
def grant(realm, user):
data = urllib.parse.urlencode({
"grant_type": "password", "client_id": CLIENT,
"username": user, "password": PWD, "scope": "openid",
}).encode()
req = urllib.request.Request(
f"{BASE}/realms/{realm}/protocol/openid-connect/token", data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"})
with urllib.request.urlopen(req, timeout=20) as r:
return json.loads(r.read())
def main():
ok = True
for realm, user, claim, expect in CHECKS:
try:
at = decode(grant(realm, user)["access_token"])
if claim == "__roles__":
val = at.get("realm_access", {}).get("roles", [])
good = expect in val
else:
val = at.get(claim)
good = val is not None and expect in str(val)
print(f"{realm:12} {user:18} login OK | {claim} = {val} "
f"[{'OK' if good else 'UNEXPECTED'}]")
ok = ok and good
except urllib.error.HTTPError as e:
ok = False
print(f"{realm:12} {user:18} LOGIN FAILED {e.code}: {e.read()[:200]!r}")
print("keycloak smoke OK" if ok else "keycloak smoke FAILED")
sys.exit(0 if ok else 1)
if __name__ == "__main__":
main()

View File

@@ -1,35 +0,0 @@
# Keycloak (S-02) with four pre-seeded realms imported at boot:
# digid · eherkenning · eidas · medewerker
# Dev mode, H2 in-memory store, realm JSONs imported from ./realms.
#
# docker compose -f infra/keycloak/docker-compose.yml up -d
# curl -s -o /dev/null -w '%{http_code}\n' \
# http://localhost:8180/realms/digid/.well-known/openid-configuration # -> 200
#
# Admin console: http://localhost:8180/ (admin / admin — dev only)
services:
keycloak:
image: quay.io/keycloak/keycloak:26.1
command: ["start-dev", "--import-realm"]
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
# Older var names too, harmless on 26.x:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
KC_HEALTH_ENABLED: "true"
KC_HTTP_ENABLED: "true"
ports:
- "8180:8080"
# realm exports are streamed into this external volume by infra/seed-config.sh.
volumes:
- kc-realms:/opt/keycloak/data/import:ro
networks: [cg]
volumes:
kc-realms:
external: true
name: rr-kc-realms
networks:
cg:

View File

@@ -1,43 +0,0 @@
{
"realm": "digid",
"enabled": true,
"displayName": "Mock DigiD",
"clients": [
{
"clientId": "big-portal",
"enabled": true,
"publicClient": true,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": true,
"redirectUris": ["*"],
"webOrigins": ["*"],
"protocolMappers": [
{
"name": "bsn",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"config": {
"user.attribute": "bsn",
"claim.name": "bsn",
"jsonType.label": "String",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
}
]
}
],
"users": [
{
"username": "jan-burger",
"enabled": true,
"firstName": "Jan",
"lastName": "Burger",
"email": "jan.burger@example.nl",
"emailVerified": true,
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
"attributes": { "bsn": ["123456782"] }
}
]
}

View File

@@ -1,43 +0,0 @@
{
"realm": "eherkenning",
"enabled": true,
"displayName": "Mock eHerkenning",
"clients": [
{
"clientId": "big-portal",
"enabled": true,
"publicClient": true,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": true,
"redirectUris": ["*"],
"webOrigins": ["*"],
"protocolMappers": [
{
"name": "kvk",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"config": {
"user.attribute": "kvk",
"claim.name": "kvk",
"jsonType.label": "String",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
}
]
}
],
"users": [
{
"username": "acme-ondernemer",
"enabled": true,
"firstName": "Anita",
"lastName": "Ondernemer",
"email": "anita@acme.nl",
"emailVerified": true,
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
"attributes": { "kvk": ["12345678"] }
}
]
}

View File

@@ -1,43 +0,0 @@
{
"realm": "eidas",
"enabled": true,
"displayName": "Mock eIDAS",
"clients": [
{
"clientId": "big-portal",
"enabled": true,
"publicClient": true,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": true,
"redirectUris": ["*"],
"webOrigins": ["*"],
"protocolMappers": [
{
"name": "eidas_id",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"config": {
"user.attribute": "eidas_id",
"claim.name": "eidas_id",
"jsonType.label": "String",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
}
]
}
],
"users": [
{
"username": "pierre-dupont",
"enabled": true,
"firstName": "Pierre",
"lastName": "Dupont",
"email": "pierre.dupont@example.fr",
"emailVerified": true,
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
"attributes": { "eidas_id": ["FR/NL/AB-1234-5678"] }
}
]
}

View File

@@ -1,44 +0,0 @@
{
"realm": "medewerker",
"enabled": true,
"displayName": "Medewerkers",
"roles": {
"realm": [
{ "name": "behandelaar", "description": "Behandelt registratieaanvragen" },
{ "name": "teamlead", "description": "Teamleider behandeling" }
]
},
"clients": [
{
"clientId": "big-portal",
"enabled": true,
"publicClient": true,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": true,
"redirectUris": ["*"],
"webOrigins": ["*"]
}
],
"users": [
{
"username": "merel-behandelaar",
"enabled": true,
"firstName": "Merel",
"lastName": "Behandelaar",
"email": "merel@big.example.nl",
"emailVerified": true,
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
"realmRoles": ["behandelaar"]
},
{
"username": "tom-teamlead",
"enabled": true,
"firstName": "Tom",
"lastName": "Teamlead",
"email": "tom@big.example.nl",
"emailVerified": true,
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
"realmRoles": ["behandelaar", "teamlead"]
}
]
}

View File

@@ -1,96 +0,0 @@
# Open Notificaties (NRC) stack (S-01-c). Lean adaptation of the upstream dev compose:
# db (PostGIS) + redis (also the Celery broker) + migrate-init + the API + a celery worker.
#
# Shares the `cg` network with the OpenZaak stack so the two can reach each other.
# Run BOTH together (NRC needs OpenZaak's Autorisaties API for auth wiring):
#
# docker compose -f infra/openzaak/docker-compose.yml -f infra/opennotificaties/docker-compose.yml up -d
# curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8001/admin/ # -> 302
#
# NRC is published on host :8001 (OpenZaak holds :8000).
services:
nrc-db:
image: docker.io/postgis/postgis:17-3.5
environment:
POSTGRES_USER: opennotificaties
POSTGRES_PASSWORD: opennotificaties
POSTGRES_DB: opennotificaties
command: postgres -c max_connections=300
volumes:
- nrc-db:/var/lib/postgresql/data
healthcheck:
# pg_isready only checks TCP; the second clause verifies PostGIS is installed
# so nrc-init migrations can safely start (avoids race on cold container start).
test: ["CMD-SHELL", "pg_isready -U opennotificaties -d opennotificaties && psql -U opennotificaties -d opennotificaties -c 'SELECT PostGIS_Version();' -q 2>/dev/null"]
interval: 5s
timeout: 5s
retries: 30
start_period: 15s
networks: [cg]
nrc-redis:
image: docker.io/library/redis:7
networks: [cg]
nrc-init:
# Plain base image — nrc-init runs migrations only (see command below).
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: &nrc-env
DJANGO_SETTINGS_MODULE: nrc.conf.docker
SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: nrc-db
DB_NAME: opennotificaties
DB_USER: opennotificaties
DB_PASSWORD: opennotificaties
IS_HTTPS: "no"
ALLOWED_HOSTS: "*"
CACHE_DEFAULT: nrc-redis:6379/0
CACHE_AXES: nrc-redis:6379/0
CELERY_BROKER_URL: redis://nrc-redis:6379/1
CELERY_RESULT_BACKEND: redis://nrc-redis:6379/1
DISABLE_2FA: "true"
OPENNOTIFICATIES_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_PASSWORD: admin
OPENNOTIFICATIES_SUPERUSER_EMAIL: admin@localhost
# Migrations only for now. No setup_configuration steps are enabled yet (the
# OZ<->NRC notification wiring lands in S-06), and NRC's `setup_configuration`
# aborts with "No steps enabled" on the empty data.yaml — so we run `migrate`
# directly instead of /setup_configuration.sh. See data.yaml and ADR-0002.
command: ["sh", "-c", "/wait_for_db.sh && OTEL_SDK_DISABLED=True python src/manage.py migrate"]
depends_on:
nrc-db:
condition: service_healthy
nrc-redis:
condition: service_started
networks: [cg]
nrc-web:
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: *nrc-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:
- "8001:8000"
depends_on:
nrc-init:
condition: service_completed_successfully
networks: [cg]
nrc-celery:
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: *nrc-env
command: /celery_worker.sh
depends_on:
nrc-init:
condition: service_completed_successfully
networks: [cg]
volumes:
nrc-db:
networks:
cg:

View File

@@ -1,5 +0,0 @@
# Open Notificaties setup_configuration.
# Stage 1 (this commit): intentionally minimal — the init container runs
# migrations; no steps enabled yet. The OpenZaak<->NRC notification wiring
# (Services, Authorization, JWT, Kanalen) is added next. See ADR-0002 / S-01-c.
{}

View File

@@ -1,101 +0,0 @@
# OpenZaak stack (S-01). Lean adaptation of the upstream open-zaak dev compose:
# db (PostGIS) + redis + a one-shot init (migrations) + the API + a celery worker.
# Dropped from upstream for leanness: nginx, celery-beat, flower, OTEL.
#
# docker compose -f infra/openzaak/docker-compose.yml up -d --wait
# curl -i http://localhost:8000/zaken/api/v1/zaken # -> 401 (auth required)
#
# NOTE: image pinned to a tag (not :latest) once a known-good tag is chosen; see
# the catalogus-design ADR. Using a tag var with a sensible default for now.
services:
oz-db:
image: docker.io/postgis/postgis:17-3.5
environment:
POSTGRES_USER: openzaak
POSTGRES_PASSWORD: openzaak
POSTGRES_DB: openzaak
command: postgres -c max_connections=300
volumes:
- oz-db:/var/lib/postgresql/data
healthcheck:
# pg_isready only checks TCP; the second clause verifies PostGIS is installed
# so oz-init migrations can safely start (avoids race on cold container start).
test: ["CMD-SHELL", "pg_isready -U openzaak -d openzaak && psql -U openzaak -d openzaak -c 'SELECT PostGIS_Version();' -q 2>/dev/null"]
interval: 5s
timeout: 5s
retries: 30
start_period: 15s
networks: [cg]
oz-redis:
image: docker.io/library/redis:7
networks: [cg]
oz-init:
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
environment: &oz-env
DJANGO_SETTINGS_MODULE: openzaak.conf.docker
SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: oz-db
DB_NAME: openzaak
DB_USER: openzaak
DB_PASSWORD: openzaak
IS_HTTPS: "no"
ALLOWED_HOSTS: "*"
CACHE_DEFAULT: oz-redis:6379/0
CACHE_AXES: oz-redis:6379/0
CELERY_BROKER_URL: redis://oz-redis:6379/1
CELERY_RESULT_BACKEND: redis://oz-redis:6379/1
DISABLE_2FA: "true"
# Notifications go to Open Notificaties (NRC), which arrives in S-01-c.
# Until then, disable outbound notifications so writes don't 500.
NOTIFICATIONS_DISABLED: "true"
OPENZAAK_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_PASSWORD: admin
OPENZAAK_SUPERUSER_EMAIL: admin@localhost
RUN_SETUP_CONFIG: "true"
command: /setup_configuration.sh
# data.yaml is streamed into this external volume by infra/seed-config.sh.
volumes:
- oz-config:/app/setup_configuration:ro
depends_on:
oz-db:
condition: service_healthy
oz-redis:
condition: service_started
networks: [cg]
openzaak:
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
environment: *oz-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:
- "8000:8000"
depends_on:
oz-init:
condition: service_completed_successfully
networks: [cg]
oz-celery:
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
environment: *oz-env
command: /celery_worker.sh
depends_on:
oz-init:
condition: service_completed_successfully
networks: [cg]
volumes:
oz-db:
# populated out-of-band by infra/seed-config.sh (docker cp) — see that script.
oz-config:
external: true
name: rr-oz-config
networks:
cg:

View File

@@ -1,217 +0,0 @@
#!/usr/bin/env python3
"""Idempotent seed of the BIG catalogus into OpenZaak via the ZTC API.
Creates (if absent):
- catalogus "BIG"
- a lean "BIG-registratie" zaaktype (only schema-mandatory fields)
- a "bsn" eigenschap on that zaaktype
- then publishes the zaaktype.
Auth uses the JWT client provisioned by setup_configuration (see ADR-0002).
Stdlib only — no pip deps. Re-running is safe (matches existing by identifier).
"""
import base64, hashlib, hmac, json, os, sys, time, urllib.error, urllib.request
BASE = os.environ.get("OZ_BASE", "http://localhost:8000")
CLIENT_ID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed")
SECRET = os.environ.get("OZ_SECRET", "insecure-dev-secret-change-me")
ZTC = f"{BASE}/catalogi/api/v1"
RSIN = "517439943" # elfproef-valid test RSIN
# Opt-in: also publish the zaaktype so OpenZaak's Zaken API accepts a zaak against
# it (a concept zaaktype is rejected with `not-published`). Off by default — the
# S-01 compose seed keeps it a concept (ADR-0002). The ACL integration test
# (S-04a, #46) sets OZ_PUBLISH=1. Publishing requires ≥2 statustypen, ≥1 roltype
# and ≥1 resultaattype; the resultaattype is validated against the external
# Selectielijst reference API, so this path needs outbound access to it. See ADR-0006.
PUBLISH = os.environ.get("OZ_PUBLISH", "").lower() in ("1", "true", "yes")
SELECTIELIJST = os.environ.get(
"OZ_SELECTIELIJST", "https://selectielijst.openzaak.nl/api/v1").rstrip("/")
def token():
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=")
hdr = {"alg": "HS256", "typ": "JWT"}
pl = {"iss": CLIENT_ID, "iat": int(time.time()), "client_id": CLIENT_ID,
"user_id": "seed", "user_representation": "seed"}
seg = b64(json.dumps(hdr, separators=(",", ":")).encode()) + b"." + \
b64(json.dumps(pl, separators=(",", ":")).encode())
sig = b64(hmac.new(SECRET.encode(), seg, hashlib.sha256).digest())
return (seg + b"." + sig).decode()
def api(method, path, body=None):
url = path if path.startswith("http") else f"{ZTC}{path}"
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method, headers={
"Authorization": "Bearer " + token(),
"Content-Type": "application/json",
"Accept": "application/json",
})
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, json.loads(r.read() or "null")
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or "null")
def find(path):
status, body = api("GET", path)
if status != 200:
sys.exit(f"GET {path} -> {status}: {body}")
return body.get("results", [])
def selectielijst(path):
"""GET the external Selectielijst reference API (no auth). Used only when publishing."""
req = urllib.request.Request(f"{SELECTIELIJST}{path}", headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read())
def publish_zaaktype(zt):
"""Add the relations OpenZaak requires to publish, then publish (idempotent).
Publish validation (verified against OpenZaak 1.28.2) demands: ≥2 statustypen
(begin + eind), ≥1 roltype, ≥1 resultaattype. A resultaattype needs a
Selectielijst `selectielijstklasse` whose procestype matches the zaaktype's
`selectielijstProcestype`, plus a `resultaattypeomschrijving`.
"""
have_st = {s.get("volgnummer") for s in find(f"/statustypen?zaaktype={zt['url']}&status=alles")}
for volgnummer, omschrijving in [(1, "Ontvangen"), (2, "Afgehandeld")]:
if volgnummer not in have_st:
st, body = api("POST", "/statustypen", {
"omschrijving": omschrijving, "zaaktype": zt["url"], "volgnummer": volgnummer})
if st != 201:
sys.exit(f"create statustype {volgnummer} -> {st}: {json.dumps(body, indent=2)}")
print(f"create statustype {volgnummer} ({omschrijving})")
if find(f"/roltypen?zaaktype={zt['url']}&status=alles"):
print("skip roltype Aanvrager")
else:
st, body = api("POST", "/roltypen", {
"zaaktype": zt["url"], "omschrijving": "Aanvrager", "omschrijvingGeneriek": "initiator"})
if st != 201:
sys.exit(f"create roltype -> {st}: {json.dumps(body, indent=2)}")
print("create roltype Aanvrager")
if find(f"/resultaattypen?zaaktype={zt['url']}&status=alles"):
print("skip resultaattype Geregistreerd")
else:
resultaat = selectielijst("/resultaten?pageSize=1")["results"][0]
omschrijvingen = selectielijst("/resultaattypeomschrijvingen")
oms = (omschrijvingen if isinstance(omschrijvingen, list) else omschrijvingen["results"])[0]["url"]
# The selectielijstklasse and the zaaktype must share a procestype.
st, body = api("PATCH", zt["url"], {"selectielijstProcestype": resultaat["procesType"]})
if st != 200:
sys.exit(f"set procestype -> {st}: {json.dumps(body, indent=2)}")
st, body = api("POST", "/resultaattypen", {
"zaaktype": zt["url"], "omschrijving": "Geregistreerd",
"resultaattypeomschrijving": oms, "selectielijstklasse": resultaat["url"],
"archiefnominatie": "blijvend_bewaren",
"brondatumArchiefprocedure": {"afleidingswijze": "afgehandeld"},
})
if st != 201:
sys.exit(f"create resultaattype -> {st}: {json.dumps(body, indent=2)}")
print("create resultaattype Geregistreerd")
if zt.get("concept", True):
st, body = api("POST", f"{zt['url']}/publish")
if st != 200:
sys.exit(f"publish zaaktype -> {st}: {json.dumps(body, indent=2)}")
print(f"publish zaaktype BIG-REGISTRATIE ({zt['url']})")
else:
print("skip publish (already published)")
def main():
# 1. Catalogus
existing = [c for c in find(f"/catalogussen?domein=BIG") if c.get("domein") == "BIG"]
if existing:
cat = existing[0]
print(f"skip catalogus BIG ({cat['url']})")
else:
st, cat = api("POST", "/catalogussen", {
"domein": "BIG", "rsin": RSIN,
"contactpersoonBeheerNaam": "BIG Beheer",
})
if st != 201:
sys.exit(f"create catalogus -> {st}: {cat}")
print(f"create catalogus BIG ({cat['url']})")
# 2. Zaaktype (concept)
# status=alles so concept zaaktypen are matched too (else we'd duplicate).
zts = [z for z in find(f"/zaaktypen?catalogus={cat['url']}&status=alles")
if z.get("identificatie") == "BIG-REGISTRATIE"]
if zts:
zt = zts[0]
print(f"skip zaaktype BIG-REGISTRATIE ({zt['url']}) concept={zt.get('concept')}")
else:
st, zt = api("POST", "/zaaktypen", {
"identificatie": "BIG-REGISTRATIE",
"omschrijving": "BIG-registratie",
"vertrouwelijkheidaanduiding": "openbaar",
"doel": "Registratie van een zorgprofessional in het BIG-register",
"aanleiding": "Aanvraag tot registratie",
"indicatieInternOfExtern": "extern",
"handelingInitiator": "indienen",
"onderwerp": "BIG-registratie",
"handelingBehandelaar": "behandelen",
"doorlooptijd": "P30D",
"opschortingEnAanhoudingMogelijk": False,
"verlengingMogelijk": False,
"publicatieIndicatie": False,
"productenOfDiensten": [],
"referentieproces": {"naam": "BIG-registratie"},
"catalogus": cat["url"],
"besluittypen": [],
"gerelateerdeZaaktypen": [],
"beginGeldigheid": "2026-01-01",
"versiedatum": "2026-01-01",
"verantwoordelijke": RSIN,
})
if st != 201:
sys.exit(f"create zaaktype -> {st}: {json.dumps(zt, indent=2)}")
print(f"create zaaktype BIG-REGISTRATIE ({zt['url']})")
# 3. bsn eigenschap (only addable while concept)
eigs = [e for e in find(f"/eigenschappen?zaaktype={zt['url']}&status=alles")
if e.get("naam") == "bsn"]
if eigs:
print("skip eigenschap bsn")
elif zt.get("concept", True):
st, eig = api("POST", "/eigenschappen", {
"naam": "bsn",
"definitie": "Burgerservicenummer van de zorgprofessional",
"zaaktype": zt["url"],
"specificatie": {"groep": "aanvrager", "formaat": "tekst",
"lengte": "9", "kardinaliteit": "1", "waardenverzameling": []},
})
if st != 201:
sys.exit(f"create eigenschap -> {st}: {json.dumps(eig, indent=2)}")
print("create eigenschap bsn")
else:
print("warn zaaktype already published; cannot add bsn eigenschap")
# 4. Optionally publish. By default the zaaktype stays a concept: publishing
# requires roltypen, resultaattypen and statustypen, beyond the "lean /
# schema-mandatory" zaaktype S-01 asks for (ADR-0002). Set OZ_PUBLISH=1 to add
# those relations and publish — needed so a real zaak POST is accepted, which
# the ACL integration test (S-04a, #46) exercises. See ADR-0006.
if PUBLISH:
# Re-fetch: the bsn-eigenschap branch above may hold a stale concept flag.
zt = next(z for z in find(f"/zaaktypen?catalogus={cat['url']}&status=alles")
if z.get("identificatie") == "BIG-REGISTRATIE")
publish_zaaktype(zt)
# 5. Verify the JWT client can list the zaaktype (concepts included).
zaaktypen = find(f"/zaaktypen?catalogus={cat['url']}&status=alles")
names = [z.get("identificatie") for z in zaaktypen]
print(f"zaaktypen in BIG: {names}")
assert "BIG-REGISTRATIE" in names, "BIG-REGISTRATIE not listed"
state = "published" if PUBLISH else "concept"
print(f"OK — BIG catalogus seeded (BIG-REGISTRATIE {state} + bsn eigenschap)")
if __name__ == "__main__":
main()

View File

@@ -1,22 +0,0 @@
# OpenZaak setup_configuration (idempotent, declarative).
# Provisions the JWT client the seed + ACL use to call OpenZaak's APIs.
# Dev-only credentials — not for production.
#
# Steps come from vng_api_common.contrib.setup_configuration (see ADR-0002).
vng_api_common_credentials_config_enable: true
vng_api_common_credentials:
items:
- identifier: big-reference-seed
secret: insecure-dev-secret-change-me
vng_api_common_applicaties_config_enable: true
vng_api_common_applicaties:
items:
# uuid must be given explicitly as a string (the step's auto-default is a
# UUID object that fails its own validation).
- uuid: "11111111-1111-4111-8111-111111111111"
client_ids:
- big-reference-seed
label: BIG reference seed client
heeft_alle_autorisaties: true

View File

@@ -1,45 +0,0 @@
#!/usr/bin/env bash
#
# Populate the external named *config* volumes that the upstream services mount,
# by `docker cp`-ing files into a throwaway helper container that mounts each one.
#
# Why: the compose stack uses the upstream images verbatim (no build). On Gitea's
# containerized runner, `docker compose` starts the stack as SIBLING containers
# via the host daemon, so a workspace bind mount resolves to a path the daemon
# can't see and is mounted empty. `docker cp` instead streams bytes over the
# Docker API, so the files reach the volume regardless of where the daemon runs.
# We use plain docker primitives (volume create / run / cp / rm) rather than
# `docker compose create`, because podman-compose (local dev) lacks that
# 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 }
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HELPER="${SEED_HELPER_IMAGE:-docker.io/library/busybox:stable}"
populate() { # volume source(file or dir/.)
local vol="$1" src="$2" cid
docker volume rm -f "$vol" >/dev/null 2>&1 || true
docker volume create "$vol" >/dev/null
# A *created* (never started) helper is enough: the volume is attached at create
# time, `docker cp` writes through to it, and `docker rm` is instant (nothing to
# stop). `docker create` is a container subcommand both docker and podman have —
# unlike `docker compose create`, which podman-compose lacks.
cid="$(docker create -v "$vol:/dest" "$HELPER" true)"
docker cp "$src" "$cid:/dest/"
docker rm "$cid" >/dev/null
echo " seeded $vol"
}
[ "$#" -gt 0 ] || { echo "usage: seed-config.sh <oz|kc|fl> ..." >&2; exit 2; }
for key in "$@"; do
case "$key" in
oz) populate rr-oz-config "$here/openzaak/setup_configuration/." ;;
kc) populate rr-kc-realms "$here/keycloak/realms/." ;;
fl) populate rr-fl-bpmn "$here/../workflows/registratie.bpmn" ;;
*) echo "unknown seed key: $key" >&2; exit 2 ;;
esac
done

View File

@@ -1,36 +0,0 @@
#!/usr/bin/env bash
#
# Wait until the named compose services report a healthy healthcheck.
#
# Portable across `docker compose` (CI) and `podman-compose` (local dev): it uses
# plain `docker ps` + `docker inspect`, so it needs neither `docker compose
# up --wait` (podman-compose doesn't implement that flag) nor host port access
# (the containerized CI runner can't reach published ports). It also sidesteps the
# `--wait`-fails-when-a-one-shot-exits issue, since we only poll long-running
# services that declare a healthcheck. See docs/runbooks/gitea-actions-gotchas.md.
#
# Usage: WAIT_TIMEOUT=420 wait-healthy.sh <service> [<service> ...]
set -euo pipefail
timeout="${WAIT_TIMEOUT:-420}"
deadline=$(( $(date +%s) + timeout ))
# compose service name -> container id. The name filter matches both docker
# compose ("infra-openzaak-1") and podman-compose ("infra_openzaak_1") naming.
cid_for() { docker ps -aq --filter "name=$1" | head -1; }
for svc in "$@"; do
echo "waiting for '$svc' to be healthy (timeout ${timeout}s)..."
while :; do
cid="$(cid_for "$svc")"
status=""
[ -n "$cid" ] && status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$cid" 2>/dev/null || true)"
[ "$status" = "healthy" ] && { echo " '$svc' is healthy"; break; }
if [ "$(date +%s)" -ge "$deadline" ]; then
echo "TIMEOUT: '$svc' not healthy (status=${status:-no-container})" >&2
docker ps -a --filter "name=$svc" >&2 || true
exit 1
fi
sleep 3
done
done

View File

@@ -1,46 +0,0 @@
site_name: register-referentie
site_description: Reference application for Respellion's Common Ground architecture pattern
docs_dir: docs
theme:
name: material
features:
- navigation.sections
- navigation.top
- content.code.copy
palette:
- scheme: default
toggle:
icon: material/brightness-7
name: Switch to dark mode
- scheme: slate
toggle:
icon: material/brightness-4
name: Switch to light mode
nav:
- Home: index.md
- Product Requirements: PRD.md
- Architecture:
- "ADR-0001: Loose coupling": architecture/adr-0001-loose-coupling.md
- "ADR-0002: Catalogus design": architecture/adr-0002-catalogus-design.md
- "ADR-0003: ACL default-fill": architecture/adr-0003-default-fill.md
- "ADR-0004: BDD framework": architecture/adr-0004-bdd-framework.md
- "ADR-0005: Mutation testing": architecture/adr-0005-mutation-testing.md
- "ADR-0006: ACL integration test provisioning": architecture/adr-0006-integration-test-provisioning.md
- Working in Gitea: gitea-workflow.md
- Runbooks:
- CI: runbooks/ci.md
markdown_extensions:
- admonition
- toc:
permalink: true
- pymdownx.superfences
# Many docs referenced by PRD.md land in later slices; don't fail the build on them.
validation:
nav:
omitted_files: warn
links:
not_found: warn

View File

@@ -1,17 +0,0 @@
<Solution>
<Folder Name="/services/" />
<Folder Name="/services/acl/">
<Project Path="services/acl/Acl.Api/Acl.Api.csproj" />
<Project Path="services/acl/Acl.Application/Acl.Application.csproj" />
<Project Path="services/acl/Acl.Infrastructure/Acl.Infrastructure.csproj" />
<Project Path="services/acl/Acl.IntegrationTests/Acl.IntegrationTests.csproj" />
<Project Path="services/acl/Acl.Tests/Acl.Tests.csproj" />
</Folder>
<Folder Name="/services/bff/">
<Project Path="services/bff/Bff.Api/Bff.Api.csproj" />
<Project Path="services/bff/Bff.Tests/Bff.Tests.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/acceptance/Acceptance.csproj" />
</Folder>
</Solution>

View File

@@ -1,3 +0,0 @@
**/bin
**/obj
**/*.user

View File

@@ -1,14 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\Acl.Application\Acl.Application.csproj" />
<ProjectReference Include="..\Acl.Infrastructure\Acl.Infrastructure.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

View File

@@ -1,31 +0,0 @@
using Acl.Application;
using Acl.Infrastructure;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
.GetSection("Acl:Defaults").Get<AclDefaults>()
?? throw new InvalidOperationException("Missing configuration section 'Acl:Defaults'"));
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
.GetSection("Acl:OpenZaak").Get<OpenZaakOptions>()
?? throw new InvalidOperationException("Missing configuration section 'Acl:OpenZaak'"));
builder.Services.AddHttpClient<IZaakGateway, OpenZaakGateway>();
builder.Services.AddScoped<AclService>();
var app = builder.Build();
app.MapGet("/health", () => "Healthy");
// The ACL's single operation, exposed as a service endpoint.
app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationToken ct) =>
{
var zaakUrl = await acl.OpenZaakAsync(new DomainRegistration(body.Bsn), ct);
return Results.Ok(new { zaakUrl = zaakUrl.ToString() });
});
app.Run();
public sealed record OpenZaakRequest(string Bsn);
public partial class Program;

View File

@@ -1,23 +0,0 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5041",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7260;http://localhost:5041",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -1,9 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@@ -1,9 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -1,10 +0,0 @@
namespace Acl.Application;
/// <summary>Configured ZGW defaults the ACL fills in (ADR-0003).</summary>
public sealed class AclDefaults
{
public required string Bronorganisatie { get; init; }
public required string VerantwoordelijkeOrganisatie { get; init; }
public required string Vertrouwelijkheidaanduiding { get; init; }
public required Uri ZaaktypeUrl { get; init; }
}

View File

@@ -1,20 +0,0 @@
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, IClock clock)
{
public Task<Uri> OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(registration);
var request = new ZaakRequest(
defaults.Bronorganisatie,
defaults.VerantwoordelijkeOrganisatie,
defaults.Vertrouwelijkheidaanduiding,
defaults.ZaaktypeUrl,
clock.Today);
return gateway.OpenZaakAsync(request, ct);
}
}

View File

@@ -1,4 +0,0 @@
namespace Acl.Application;
/// <summary>Domain-language payload handed to the ACL. No ZGW concepts here.</summary>
public sealed record DomainRegistration(string Bsn);

View File

@@ -1,7 +0,0 @@
namespace Acl.Application;
/// <summary>Abstracts "today" so startdatum default-fill is deterministic in tests.</summary>
public interface IClock
{
DateOnly Today { get; }
}

View File

@@ -1,8 +0,0 @@
namespace Acl.Application;
/// <summary>Port to the ZGW Zaken API. Implemented in Infrastructure — the only
/// code that talks to OpenZaak (ADR-0001).</summary>
public interface IZaakGateway
{
Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default);
}

View File

@@ -1,9 +0,0 @@
namespace Acl.Application;
/// <summary>The fully default-filled zaak the gateway will create in OpenZaak.</summary>
public sealed record ZaakRequest(
string Bronorganisatie,
string VerantwoordelijkeOrganisatie,
string Vertrouwelijkheidaanduiding,
Uri Zaaktype,
DateOnly Startdatum);

View File

@@ -1,13 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Acl.Application\Acl.Application.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -1,53 +0,0 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Acl.Application;
namespace Acl.Infrastructure;
/// <summary>The only code that talks to OpenZaak's Zaken API (ADR-0001).</summary>
public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : IZaakGateway
{
public async Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(request);
using var message = new HttpRequestMessage(
HttpMethod.Post, new Uri(options.BaseUrl, "/zaken/api/v1/zaken"))
{
Content = JsonContent.Create(new ZaakDto(
request.Bronorganisatie,
request.Zaaktype.ToString(),
request.VerantwoordelijkeOrganisatie,
request.Startdatum.ToString("yyyy-MM-dd"),
request.Vertrouwelijkheidaanduiding)),
};
message.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
// ZRC is a geo API; it requires the CRS headers.
message.Headers.Add("Accept-Crs", "EPSG:4326");
message.Content.Headers.Add("Content-Crs", "EPSG:4326");
// OpenZaak runs behind uwsgi, which rejects a chunked request body with 400.
// JsonContent streams without a known length (→ Transfer-Encoding: chunked),
// so buffer it first to send a Content-Length instead. Only a real OpenZaak
// surfaces this — a stubbed HttpMessageHandler accepts either framing.
await message.Content.LoadIntoBufferAsync(ct);
using var response = await http.SendAsync(message, ct);
response.EnsureSuccessStatusCode();
var created = await response.Content.ReadFromJsonAsync<ZaakCreatedDto>(ct)
?? throw new InvalidOperationException("OpenZaak returned an empty zaak response");
return new Uri(created.Url);
}
private sealed record ZaakDto(
[property: JsonPropertyName("bronorganisatie")] string Bronorganisatie,
[property: JsonPropertyName("zaaktype")] string Zaaktype,
[property: JsonPropertyName("verantwoordelijkeOrganisatie")] string VerantwoordelijkeOrganisatie,
[property: JsonPropertyName("startdatum")] string Startdatum,
[property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding);
private sealed record ZaakCreatedDto(
[property: JsonPropertyName("url")] string Url);
}

View File

@@ -1,9 +0,0 @@
namespace Acl.Infrastructure;
/// <summary>Connection + credential config for OpenZaak's ZGW APIs.</summary>
public sealed class OpenZaakOptions
{
public required Uri BaseUrl { get; init; }
public required string ClientId { get; init; }
public required string Secret { get; init; }
}

View File

@@ -1,8 +0,0 @@
using Acl.Application;
namespace Acl.Infrastructure;
public sealed class SystemClock : IClock
{
public DateOnly Today => DateOnly.FromDateTime(DateTime.UtcNow);
}

View File

@@ -1,29 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace Acl.Infrastructure;
/// <summary>Mints a ZGW (vng-api-common) JWT: HS256 over the standard claims.</summary>
internal static class ZgwToken
{
public static string Mint(string clientId, string secret)
{
var header = B64Url(JsonSerializer.SerializeToUtf8Bytes(new { alg = "HS256", typ = "JWT" }));
var payload = B64Url(JsonSerializer.SerializeToUtf8Bytes(new
{
iss = clientId,
iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
client_id = clientId,
user_id = "acl",
user_representation = "acl",
}));
var signingInput = $"{header}.{payload}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var signature = B64Url(hmac.ComputeHash(Encoding.UTF8.GetBytes(signingInput)));
return $"{signingInput}.{signature}";
}
private static string B64Url(byte[] bytes) =>
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}

View File

@@ -1,30 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Integration tests: they talk to a real OpenZaak (the compose stack), so they
are gated behind [Trait("Category","Integration")] and excluded from the fast
`make unit` / mutation lanes. `make integration` brings the stack up, seeds a
published BIG zaaktype (OZ_PUBLISH=1) and runs this project. See ADR-0006. -->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Acl.Application\Acl.Application.csproj" />
<ProjectReference Include="..\Acl.Infrastructure\Acl.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,97 +0,0 @@
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Acl.Infrastructure;
namespace Acl.IntegrationTests;
/// <summary>
/// Shared connection to the running OpenZaak compose stack (ADR-0006). Reads the
/// same endpoint + JWT-client config the seed uses, and locates the published
/// BIG-REGISTRATIE zaaktype the ACL opens zaken against. Defaults match
/// `infra/openzaak/seed_catalogus.py`; override via OZ_BASE / OZ_CLIENT_ID / OZ_SECRET.
/// </summary>
public sealed class OpenZaakFixture : IDisposable
{
private static string Env(string key, string fallback) =>
Environment.GetEnvironmentVariable(key) is { Length: > 0 } v ? v : fallback;
public Uri BaseUrl { get; } = new(Env("OZ_BASE", "http://localhost:8000"));
public string ClientId { get; } = Env("OZ_CLIENT_ID", "big-reference-seed");
public string Secret { get; } = Env("OZ_SECRET", "insecure-dev-secret-change-me");
public HttpClient Http { get; } = new();
public OpenZaakOptions Options => new()
{
BaseUrl = BaseUrl,
ClientId = ClientId,
Secret = Secret,
};
/// <summary>
/// The URL of the published BIG-REGISTRATIE zaaktype, or null when none is
/// published yet (a concept-only stack). `status=definitief` returns published
/// zaaktypen only — a concept zaaktype is deliberately excluded.
/// </summary>
public async Task<Uri?> FindPublishedBigZaaktypeAsync(CancellationToken ct = default)
{
var query = new Uri(BaseUrl,
"/catalogi/api/v1/zaaktypen?identificatie=BIG-REGISTRATIE&status=definitief");
using var message = new HttpRequestMessage(HttpMethod.Get, query);
message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MintToken());
using var response = await Http.SendAsync(message, ct);
response.EnsureSuccessStatusCode();
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct));
var results = document.RootElement.GetProperty("results");
return results.GetArrayLength() == 0
? null
: new Uri(results[0].GetProperty("url").GetString()!);
}
/// <summary>GETs a previously-created zaak to prove it was really persisted.</summary>
public async Task<JsonElement> GetZaakAsync(Uri zaakUrl, CancellationToken ct = default)
{
using var message = new HttpRequestMessage(HttpMethod.Get, zaakUrl);
message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MintToken());
message.Headers.Add("Accept-Crs", "EPSG:4326");
using var response = await Http.SendAsync(message, ct);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(ct);
return JsonDocument.Parse(json).RootElement.Clone();
}
// A ZGW (vng-api-common) HS256 JWT, mirroring the seed's client. Minted here
// rather than reusing Acl.Infrastructure's internal minter to keep that internal.
private string MintToken()
{
static string B64(byte[] b) =>
Convert.ToBase64String(b).TrimEnd('=').Replace('+', '-').Replace('/', '_');
var header = B64(JsonSerializer.SerializeToUtf8Bytes(new { alg = "HS256", typ = "JWT" }));
var payload = B64(JsonSerializer.SerializeToUtf8Bytes(new
{
iss = ClientId,
iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
client_id = ClientId,
user_id = "acl-integration-test",
user_representation = "acl-integration-test",
}));
var signingInput = $"{header}.{payload}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(Secret));
var signature = B64(hmac.ComputeHash(Encoding.UTF8.GetBytes(signingInput)));
return $"{signingInput}.{signature}";
}
public void Dispose() => Http.Dispose();
}
[CollectionDefinition(Name)]
public sealed class OpenZaakCollection : ICollectionFixture<OpenZaakFixture>
{
public const string Name = "OpenZaak";
}

View File

@@ -1,45 +0,0 @@
using Acl.Application;
using Acl.Infrastructure;
namespace Acl.IntegrationTests;
/// <summary>
/// S-04a (#46): the deferred S-04 acceptance criterion — the ACL's OpenZaakGateway
/// opening a zaak against a *real* OpenZaak, exercising real ZGW JWT auth and the
/// real POST /zaken/api/v1/zaken contract (CRS headers, default-fill, the created
/// zaak URL) that the stubbed-HttpMessageHandler unit tests cannot. See ADR-0006.
/// </summary>
[Trait("Category", "Integration")]
[Collection(OpenZaakCollection.Name)]
public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
{
[Fact]
public async Task Opens_a_real_zaak_against_the_published_big_zaaktype_and_returns_its_url()
{
var zaaktype = await stack.FindPublishedBigZaaktypeAsync();
Assert.True(zaaktype is not null,
"No published BIG-REGISTRATIE zaaktype found in OpenZaak — bring the stack up and " +
"seed it with OZ_PUBLISH=1 (`make integration` does this).");
var gateway = new OpenZaakGateway(stack.Http, stack.Options);
var request = new ZaakRequest(
Bronorganisatie: "517439943",
VerantwoordelijkeOrganisatie: "517439943",
Vertrouwelijkheidaanduiding: "openbaar",
Zaaktype: zaaktype!,
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow));
var zaakUrl = await gateway.OpenZaakAsync(request);
// The gateway returns the canonical zaak URL on OpenZaak's Zaken API...
Assert.StartsWith(
new Uri(stack.BaseUrl, "/zaken/api/v1/zaken/").ToString(),
zaakUrl.ToString());
// ...and that zaak is really persisted with the default-filled fields.
var zaak = await stack.GetZaakAsync(zaakUrl);
Assert.Equal(zaaktype.ToString(), zaak.GetProperty("zaaktype").GetString());
Assert.Equal("517439943", zaak.GetProperty("bronorganisatie").GetString());
Assert.Equal("openbaar", zaak.GetProperty("vertrouwelijkheidaanduiding").GetString());
}
}

View File

@@ -1,26 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Acl.Application\Acl.Application.csproj" />
<ProjectReference Include="..\Acl.Infrastructure\Acl.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,64 +0,0 @@
using Acl.Application;
namespace Acl.Tests;
public class AclServiceTests
{
private sealed class FakeGateway : IZaakGateway
{
public ZaakRequest? Captured;
public Uri Result { get; } = new("http://openzaak/zaken/api/v1/zaken/abc");
public Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default)
{
Captured = request;
return Task.FromResult(Result);
}
}
private sealed class FixedClock(DateOnly today) : IClock
{
public DateOnly Today { get; } = today;
}
[Fact]
public async Task Opening_a_zaak_default_fills_zgw_fields_and_returns_the_zaak_url()
{
var gateway = new FakeGateway();
var defaults = new AclDefaults
{
Bronorganisatie = "517439943",
VerantwoordelijkeOrganisatie = "517439943",
Vertrouwelijkheidaanduiding = "openbaar",
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
};
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
var url = await service.OpenZaakAsync(new DomainRegistration("123456782"));
Assert.Equal(gateway.Result, url);
var req = Assert.IsType<ZaakRequest>(gateway.Captured);
Assert.Equal("517439943", req.Bronorganisatie);
Assert.Equal("517439943", req.VerantwoordelijkeOrganisatie);
Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding);
Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype);
Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum);
}
[Fact]
public async Task Rejects_a_null_registration_without_calling_the_gateway()
{
var gateway = new FakeGateway();
var defaults = new AclDefaults
{
Bronorganisatie = "517439943",
VerantwoordelijkeOrganisatie = "517439943",
Vertrouwelijkheidaanduiding = "openbaar",
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
};
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
await Assert.ThrowsAsync<ArgumentNullException>(() => service.OpenZaakAsync(null!));
Assert.Null(gateway.Captured);
}
}

View File

@@ -1,154 +0,0 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Acl.Application;
using Acl.Infrastructure;
namespace Acl.Tests;
public class OpenZaakGatewayTests
{
private sealed class StubHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> onSend)
: HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
=> onSend(request);
}
private static OpenZaakGateway Gateway(StubHandler handler) => new(
new HttpClient(handler),
new OpenZaakOptions { BaseUrl = new("http://openzaak"), ClientId = "cid", Secret = "sec" });
private static ZaakRequest SampleRequest() => new(
"517439943", "517439943", "openbaar",
new("http://openzaak/catalogi/api/v1/zaaktypen/big"), new DateOnly(2026, 6, 4));
private static StubHandler Created(out RequestCapture capture)
{
var c = new RequestCapture();
capture = c;
return new StubHandler(async req =>
{
c.Seen = req;
// Capture the length BEFORE reading the body: ReadAsStringAsync buffers the
// content and would set ContentLength as a side effect, masking the gateway's
// own buffering. Read here to assert the gateway sent a length (not chunked).
c.ContentLength = req.Content?.Headers.ContentLength;
c.Body = req.Content is null ? null : await req.Content.ReadAsStringAsync();
return new HttpResponseMessage(HttpStatusCode.Created)
{
Content = JsonContent.Create(new { url = "http://openzaak/zaken/api/v1/zaken/xyz" }),
};
});
}
private sealed class RequestCapture
{
public HttpRequestMessage? Seen;
public string? Body;
public long? ContentLength;
}
[Fact]
public async Task Posts_zaak_to_openzaak_with_bearer_and_default_fields_and_returns_url()
{
var handler = Created(out var capture);
var url = await Gateway(handler).OpenZaakAsync(SampleRequest());
Assert.Equal("http://openzaak/zaken/api/v1/zaken/xyz", url.ToString());
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
Assert.Equal("http://openzaak/zaken/api/v1/zaken", capture.Seen.RequestUri!.ToString());
Assert.Equal("Bearer", capture.Seen.Headers.Authorization!.Scheme);
Assert.False(string.IsNullOrWhiteSpace(capture.Seen.Headers.Authorization.Parameter));
Assert.Contains("\"bronorganisatie\":\"517439943\"", capture.Body);
Assert.Contains("\"verantwoordelijkeOrganisatie\":\"517439943\"", capture.Body);
Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", capture.Body);
Assert.Contains("\"startdatum\":\"2026-06-04\"", capture.Body);
Assert.Contains("\"zaaktype\":\"http://openzaak/catalogi/api/v1/zaaktypen/big\"", capture.Body);
}
[Fact]
public async Task Sends_the_geo_crs_headers_required_by_the_zaken_api()
{
var handler = Created(out var capture);
await Gateway(handler).OpenZaakAsync(SampleRequest());
Assert.Equal("EPSG:4326", Assert.Single(capture.Seen!.Headers.GetValues("Accept-Crs")));
Assert.Equal("EPSG:4326", Assert.Single(capture.Seen.Content!.Headers.GetValues("Content-Crs")));
}
[Fact]
public async Task Sends_the_body_with_a_content_length_so_it_is_not_chunked()
{
// OpenZaak's uwsgi rejects a chunked request body (400). The gateway buffers
// the body so a Content-Length is sent. JsonContent has no length until
// buffered, so this guards the fix the real-OpenZaak integration test found.
var handler = Created(out var capture);
await Gateway(handler).OpenZaakAsync(SampleRequest());
Assert.NotNull(capture.ContentLength);
Assert.True(capture.ContentLength > 0);
}
[Fact]
public async Task Mints_a_hs256_jwt_carrying_the_acl_identity_claims()
{
var handler = Created(out var capture);
await Gateway(handler).OpenZaakAsync(SampleRequest());
var parts = capture.Seen!.Headers.Authorization!.Parameter!.Split('.');
Assert.Equal(3, parts.Length);
using var header = JsonDocument.Parse(DecodeSegment(parts[0]));
Assert.Equal("HS256", header.RootElement.GetProperty("alg").GetString());
Assert.Equal("JWT", header.RootElement.GetProperty("typ").GetString());
using var payload = JsonDocument.Parse(DecodeSegment(parts[1]));
Assert.Equal("cid", payload.RootElement.GetProperty("client_id").GetString());
Assert.Equal("acl", payload.RootElement.GetProperty("user_id").GetString());
Assert.Equal("acl", payload.RootElement.GetProperty("user_representation").GetString());
}
[Fact]
public async Task Throws_when_openzaak_rejects_the_request()
{
var handler = new StubHandler(_ =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)));
await Assert.ThrowsAsync<HttpRequestException>(
() => Gateway(handler).OpenZaakAsync(SampleRequest()));
}
[Fact]
public async Task Throws_when_openzaak_returns_an_empty_body()
{
var handler = new StubHandler(_ =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.Created)
{
Content = new StringContent("null", Encoding.UTF8, "application/json"),
}));
await Assert.ThrowsAsync<InvalidOperationException>(
() => Gateway(handler).OpenZaakAsync(SampleRequest()));
}
[Fact]
public async Task Rejects_a_null_request()
{
var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
await Assert.ThrowsAsync<ArgumentNullException>(
() => Gateway(handler).OpenZaakAsync(null!));
}
// ZGW tokens are base64url with padding stripped (ZgwToken.B64Url); restore it to decode.
private static string DecodeSegment(string segment)
{
var b64 = segment.Replace('-', '+').Replace('_', '/');
b64 = (b64.Length % 4) switch { 2 => b64 + "==", 3 => b64 + "=", _ => b64 };
return Encoding.UTF8.GetString(Convert.FromBase64String(b64));
}
}

View File

@@ -1,7 +0,0 @@
<Solution>
<Project Path="Acl.Api/Acl.Api.csproj" />
<Project Path="Acl.Application/Acl.Application.csproj" />
<Project Path="Acl.Infrastructure/Acl.Infrastructure.csproj" />
<Project Path="Acl.IntegrationTests/Acl.IntegrationTests.csproj" />
<Project Path="Acl.Tests/Acl.Tests.csproj" />
</Solution>

View File

@@ -1,32 +0,0 @@
# Multi-stage build for the ACL service (.NET 10).
# Build context is services/acl (see infra/docker-compose.yml).
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Restore first (cached unless .csproj files change).
COPY Acl.Api/Acl.Api.csproj Acl.Api/
COPY Acl.Application/Acl.Application.csproj Acl.Application/
COPY Acl.Infrastructure/Acl.Infrastructure.csproj Acl.Infrastructure/
RUN dotnet restore Acl.Api/Acl.Api.csproj
COPY Acl.Api/ Acl.Api/
COPY Acl.Application/ Acl.Application/
COPY Acl.Infrastructure/ Acl.Infrastructure/
RUN dotnet publish Acl.Api/Acl.Api.csproj -c Release -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/publish .
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
HEALTHCHECK --interval=5s --timeout=3s --start-period=10s --retries=5 \
CMD curl -fsS http://localhost:8080/health || exit 1
ENTRYPOINT ["dotnet", "Acl.Api.dll"]

View File

@@ -1,12 +0,0 @@
{
"stryker-config": {
"solution": "Acl.slnx",
"test-projects": ["Acl.Tests/Acl.Tests.csproj"],
"reporters": ["progress", "html"],
"thresholds": {
"high": 95,
"low": 90,
"break": 90
}
}
}

View File

@@ -1,3 +0,0 @@
**/bin
**/obj
**/*.user

View File

@@ -1,30 +0,0 @@
# Multi-stage build for the placeholder BFF (.NET 10).
# Build context is services/bff (see infra/docker-compose.yml).
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Restore first (cached unless the csproj changes).
COPY Bff.Api/Bff.Api.csproj Bff.Api/
RUN dotnet restore Bff.Api/Bff.Api.csproj
# Then build + publish.
COPY Bff.Api/ Bff.Api/
RUN dotnet publish Bff.Api/Bff.Api.csproj -c Release -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
# curl is used by the container HEALTHCHECK / compose healthcheck.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/publish .
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
HEALTHCHECK --interval=5s --timeout=3s --start-period=10s --retries=5 \
CMD curl -fsS http://localhost:8080/health || exit 1
ENTRYPOINT ["dotnet", "Bff.Api.dll"]

View File

@@ -1,23 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Reqnroll.xUnit" Version="3.3.4" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\services\acl\Acl.Application\Acl.Application.csproj" />
<ProjectReference Include="..\..\services\acl\Acl.Infrastructure\Acl.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,27 +0,0 @@
# language: en
# Drives S-04 (#5). The ACL is the only code that talks to ZGW (ADR-0001); it
# default-fills the ZGW-mandatory zaak fields the domain never sees (ADR-0003).
# Real-OpenZaak verification is a separate slice — this scenario exercises the
# use case against an in-memory stand-in for the Zaken API.
Feature: Een zaak openen vanuit een registratie
Als domein wil ik de ACL vragen een zaak te openen
zodat de ZGW-verplichte velden worden ingevuld zonder dat het domein ze kent.
Scenario: De ACL vult de ZGW-verplichte velden default in
Given a domain registration for BSN "123456782"
And the ACL is configured with these defaults:
| field | value |
| bronorganisatie | 517439943 |
| verantwoordelijkeOrganisatie | 517439943 |
| vertrouwelijkheidaanduiding | openbaar |
| zaaktype | http://openzaak/catalogi/api/v1/zaaktypen/big |
And today is "2026-06-04"
When the domain asks the ACL to open a zaak
Then a zaak is created with these default-filled fields
| field | value |
| bronorganisatie | 517439943 |
| verantwoordelijkeOrganisatie | 517439943 |
| vertrouwelijkheidaanduiding | openbaar |
| zaaktype | http://openzaak/catalogi/api/v1/zaaktypen/big |
| startdatum | 2026-06-04 |
And the ACL returns the URL of the created zaak

View File

@@ -1,65 +0,0 @@
using Acceptance.Support;
using Acl.Application;
using Reqnroll;
using Xunit;
namespace Acceptance.Steps;
/// <summary>Bindings for <c>EenZaakOpenen.feature</c>. Reqnroll creates one
/// instance per scenario, so instance fields hold scenario-scoped state.</summary>
[Binding]
public sealed class EenZaakOpenenSteps
{
private readonly InMemoryZaakGateway _gateway = new();
private DomainRegistration? _registration;
private AclDefaults? _defaults;
private DateOnly _today;
private Uri? _returnedUrl;
[Given("a domain registration for BSN \"(.*)\"")]
public void GivenADomainRegistrationForBsn(string bsn)
=> _registration = new DomainRegistration(bsn);
[Given("the ACL is configured with these defaults:")]
public void GivenTheAclIsConfiguredWithTheseDefaults(DataTable defaults)
{
var values = ToFieldMap(defaults);
_defaults = new AclDefaults
{
Bronorganisatie = values["bronorganisatie"],
VerantwoordelijkeOrganisatie = values["verantwoordelijkeOrganisatie"],
Vertrouwelijkheidaanduiding = values["vertrouwelijkheidaanduiding"],
ZaaktypeUrl = new Uri(values["zaaktype"]),
};
}
[Given("today is \"(.*)\"")]
public void GivenTodayIs(string date)
=> _today = DateOnly.Parse(date);
[When("the domain asks the ACL to open a zaak")]
public async Task WhenTheDomainAsksTheAclToOpenAZaak()
{
var service = new AclService(_gateway, _defaults!, new FixedClock(_today));
_returnedUrl = await service.OpenZaakAsync(_registration!);
}
[Then("a zaak is created with these default-filled fields")]
public void ThenAZaakIsCreatedWithTheseDefaultFilledFields(DataTable expected)
{
var request = Assert.IsType<ZaakRequest>(_gateway.Captured);
var fields = ToFieldMap(expected);
Assert.Equal(fields["bronorganisatie"], request.Bronorganisatie);
Assert.Equal(fields["verantwoordelijkeOrganisatie"], request.VerantwoordelijkeOrganisatie);
Assert.Equal(fields["vertrouwelijkheidaanduiding"], request.Vertrouwelijkheidaanduiding);
Assert.Equal(fields["zaaktype"], request.Zaaktype.ToString());
Assert.Equal(fields["startdatum"], request.Startdatum.ToString("yyyy-MM-dd"));
}
[Then("the ACL returns the URL of the created zaak")]
public void ThenTheAclReturnsTheUrlOfTheCreatedZaak()
=> Assert.Equal(InMemoryZaakGateway.CreatedZaakUrl, _returnedUrl);
private static Dictionary<string, string> ToFieldMap(DataTable table)
=> table.Rows.ToDictionary(r => r["field"], r => r["value"]);
}

View File

@@ -1,10 +0,0 @@
using Acl.Application;
namespace Acceptance.Support;
/// <summary>A clock pinned to a known date so <c>startdatum</c> is deterministic
/// in scenarios (ADR-0003).</summary>
public sealed class FixedClock(DateOnly today) : IClock
{
public DateOnly Today { get; } = today;
}

View File

@@ -1,20 +0,0 @@
using Acl.Application;
namespace Acceptance.Support;
/// <summary>An in-memory stand-in for the OpenZaak Zaken API. It captures the
/// fully default-filled <see cref="ZaakRequest"/> the ACL builds and returns a
/// fixed zaak URL, so the acceptance scenario can verify the use case without a
/// running OpenZaak (real-OpenZaak verification is a separate slice).</summary>
public sealed class InMemoryZaakGateway : IZaakGateway
{
public static readonly Uri CreatedZaakUrl = new("http://openzaak/zaken/api/v1/zaken/created-123");
public ZaakRequest? Captured { get; private set; }
public Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default)
{
Captured = request;
return Task.FromResult(CreatedZaakUrl);
}
}

View File

@@ -1,48 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"
xmlns:flowable="http://flowable.org/bpmn"
xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
xmlns:omgdc="http://www.omg.org/spec/DD/20100524/DC"
xmlns:omgdi="http://www.omg.org/spec/DD/20100524/DI"
targetNamespace="http://respellion.nl/big">
<!-- S-03: minimal "Registratie ontvangen" flow.
start -> external-worker task (OpenZaakAanmaken) -> end.
The external task is handled by the Workflow Client / ACL in later slices. -->
<process id="registratie" name="Registratie ontvangen" isExecutable="true">
<startEvent id="start" name="Registratie ontvangen"/>
<sequenceFlow id="flow1" sourceRef="start" targetRef="OpenZaakAanmaken"/>
<serviceTask id="OpenZaakAanmaken" name="OpenZaak aanmaken"
flowable:type="external-worker"
flowable:topic="OpenZaakAanmaken"/>
<sequenceFlow id="flow2" sourceRef="OpenZaakAanmaken" targetRef="end"/>
<endEvent id="end" name="Zaak aangemaakt"/>
</process>
<bpmndi:BPMNDiagram id="diagram">
<bpmndi:BPMNPlane id="plane" bpmnElement="registratie">
<bpmndi:BPMNShape id="s_start" bpmnElement="start">
<omgdc:Bounds x="100" y="100" width="30" height="30"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_task" bpmnElement="OpenZaakAanmaken">
<omgdc:Bounds x="200" y="85" width="120" height="60"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_end" bpmnElement="end">
<omgdc:Bounds x="400" y="100" width="30" height="30"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="e_flow1" bpmnElement="flow1">
<omgdi:waypoint x="130" y="115"/>
<omgdi:waypoint x="200" y="115"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_flow2" bpmnElement="flow2">
<omgdi:waypoint x="320" y="115"/>
<omgdi:waypoint x="400" y="115"/>
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</definitions>