Compare commits

..

4 Commits

Author SHA1 Message Date
4b2af5c635 feat(acl): ACL skeleton — OpenZaak default-fill (refs #5) (#45)
Some checks failed
CI / lint (push) Has been cancelled
CI / build (push) Has been cancelled
CI / unit (push) Has been cancelled
CI / compose-smoke (push) Has been cancelled
2026-06-04 07:50:45 +00:00
71b76a0ef9 feat(workflow): Flowable + registratie.bpmn external task (closes #4) (#44)
Some checks failed
CI / lint (push) Has been cancelled
CI / compose-smoke (push) Has been cancelled
CI / build (push) Has been cancelled
CI / unit (push) Has been cancelled
2026-06-04 07:16:48 +00:00
c904c64597 feat(infra): Keycloak with four mock realms (closes #3) (#43)
Some checks failed
CI / lint (push) Has been cancelled
CI / build (push) Has been cancelled
CI / unit (push) Has been cancelled
CI / compose-smoke (push) Has been cancelled
2026-06-03 14:16:49 +00:00
195a76aaf2 feat(infra): Open Notificaties up + shared network (closes #2) (#42)
Some checks failed
CI / lint (push) Has been cancelled
CI / build (push) Has been cancelled
CI / unit (push) Has been cancelled
CI / compose-smoke (push) Has been cancelled
2026-06-03 13:59:02 +00:00
36 changed files with 1023 additions and 2 deletions

View File

@@ -5,13 +5,17 @@
# `make ci` locally runs exactly what the pipeline runs — no drift. Until a # `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). # self-hosted runner is registered, `make ci` is the gate (see docs/runbooks/ci.md).
SLN := services/bff/Bff.slnx SLN := register-referentie.slnx
COMPOSE := infra/docker-compose.yml COMPOSE := infra/docker-compose.yml
HEALTH_URL := http://localhost:8080/health HEALTH_URL := http://localhost:8080/health
OZ_COMPOSE := infra/openzaak/docker-compose.yml OZ_COMPOSE := infra/openzaak/docker-compose.yml
OZ_BASE := http://localhost:8000 OZ_BASE := http://localhost:8000
NRC_COMPOSE := infra/opennotificaties/docker-compose.yml NRC_COMPOSE := infra/opennotificaties/docker-compose.yml
NRC_BASE := http://localhost:8001 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) STACK_FILES := -f $(OZ_COMPOSE) -f $(NRC_COMPOSE)
# On a rootless Podman dev box, point Docker CLI/Compose at the Podman socket — # On a rootless Podman dev box, point Docker CLI/Compose at the Podman socket —
@@ -24,7 +28,7 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
endif endif
endif endif
.PHONY: ci lint build unit smoke down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down help .PHONY: ci lint build unit smoke 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, smoke (mirrors Gitea Actions) ## ci: run the full pipeline — lint, build, unit, smoke (mirrors Gitea Actions)
ci: lint build unit smoke ci: lint build unit smoke
@@ -107,6 +111,36 @@ stack-smoke: stack-up
stack-down: stack-down:
docker compose $(STACK_FILES) down --volumes docker compose $(STACK_FILES) down --volumes
## keycloak-up: start Keycloak with the four imported realms
keycloak-up:
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
## flowable-up: start Flowable (deploys registratie.bpmn on boot)
flowable-up:
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
## help: list available targets ## help: list available targets
help: help:
@grep -E '^## ' $(MAKEFILE_LIST) | sed 's/^## //' @grep -E '^## ' $(MAKEFILE_LIST) | sed 's/^## //'

View File

@@ -0,0 +1,44 @@
# 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.

40
docs/runbooks/flowable.md Normal file
View File

@@ -0,0 +1,40 @@
# 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
```

37
docs/runbooks/keycloak.md Normal file
View File

@@ -0,0 +1,37 @@
# 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`.

35
docs/synthetic-data.md Normal file
View File

@@ -0,0 +1,35 @@
# 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

@@ -0,0 +1,65 @@
# 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"
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]
volumes:
flowable-db:
networks:
cg:

54
infra/flowable/verify.py Normal file
View File

@@ -0,0 +1,54 @@
#!/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

@@ -0,0 +1,60 @@
#!/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

@@ -0,0 +1,29 @@
# 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"
volumes:
- ./realms:/opt/keycloak/data/import:ro,z
networks: [cg]
networks:
cg:

View File

@@ -0,0 +1,43 @@
{
"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

@@ -0,0 +1,43 @@
{
"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

@@ -0,0 +1,43 @@
{
"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

@@ -0,0 +1,44 @@
{
"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"]
}
]
}

13
register-referentie.slnx Normal file
View File

@@ -0,0 +1,13 @@
<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.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>
</Solution>

View File

@@ -0,0 +1,14 @@
<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

@@ -0,0 +1,31 @@
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

@@ -0,0 +1,23 @@
{
"$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

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
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

@@ -0,0 +1,20 @@
namespace Acl.Application;
/// <summary>The ACL's single operation: open a zaak from a domain payload,
/// default-filling the ZGW-mandatory fields (ADR-0003).</summary>
public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, 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

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

View File

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

View File

@@ -0,0 +1,8 @@
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

@@ -0,0 +1,9 @@
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

@@ -0,0 +1,13 @@
<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

@@ -0,0 +1,48 @@
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");
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

@@ -0,0 +1,9 @@
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

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

View File

@@ -0,0 +1,29 @@
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

@@ -0,0 +1,26 @@
<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

@@ -0,0 +1,47 @@
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);
}
}

View File

@@ -0,0 +1,51 @@
using System.Net;
using System.Net.Http.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);
}
[Fact]
public async Task Posts_zaak_to_openzaak_with_bearer_and_default_fields_and_returns_url()
{
HttpRequestMessage? seen = null;
string? body = null;
var handler = new StubHandler(async req =>
{
seen = req;
body = await req.Content!.ReadAsStringAsync();
return new HttpResponseMessage(HttpStatusCode.Created)
{
Content = JsonContent.Create(new { url = "http://openzaak/zaken/api/v1/zaken/xyz" }),
};
});
var gateway = new OpenZaakGateway(
new HttpClient(handler),
new OpenZaakOptions { BaseUrl = new("http://openzaak"), ClientId = "cid", Secret = "sec" });
var request = new ZaakRequest(
"517439943", "517439943", "openbaar",
new("http://openzaak/catalogi/api/v1/zaaktypen/big"), new DateOnly(2026, 6, 4));
var url = await gateway.OpenZaakAsync(request);
Assert.Equal("http://openzaak/zaken/api/v1/zaken/xyz", url.ToString());
Assert.Equal(HttpMethod.Post, seen!.Method);
Assert.Equal("http://openzaak/zaken/api/v1/zaken", seen.RequestUri!.ToString());
Assert.Equal("Bearer", seen.Headers.Authorization!.Scheme);
Assert.False(string.IsNullOrWhiteSpace(seen.Headers.Authorization.Parameter));
Assert.Contains("\"bronorganisatie\":\"517439943\"", body);
Assert.Contains("\"verantwoordelijkeOrganisatie\":\"517439943\"", body);
Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", body);
Assert.Contains("\"startdatum\":\"2026-06-04\"", body);
Assert.Contains("\"zaaktype\":\"http://openzaak/catalogi/api/v1/zaaktypen/big\"", body);
}
}

6
services/acl/Acl.slnx Normal file
View File

@@ -0,0 +1,6 @@
<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.Tests/Acl.Tests.csproj" />
</Solution>

View File

@@ -0,0 +1,48 @@
<?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>