diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 3b376cb..a08a5b4 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -177,7 +177,7 @@ jobs: # Log dump must precede teardown (which removes the containers). - name: Dump container logs on failure if: failure() - run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel tempo prometheus grafana 2>&1 || true + run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel beheer tempo prometheus grafana 2>&1 || true - name: Tear down if: always() run: make down diff --git a/Makefile b/Makefile index 4e12315..80975da 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ COMPOSE := infra/docker-compose.yml # Long-running services with a healthcheck — the smoke polls these for readiness # (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init) # are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md. -WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel +WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel beheer # 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 diff --git a/docs/architecture/adr-0025-bff-reads-catalogus-via-acl.md b/docs/architecture/adr-0025-bff-reads-catalogus-via-acl.md new file mode 100644 index 0000000..deaee7a --- /dev/null +++ b/docs/architecture/adr-0025-bff-reads-catalogus-via-acl.md @@ -0,0 +1,58 @@ +# ADR-0025: The BFF reads the catalogus directly from the ACL + +- **Status:** Accepted +- **Date:** 2026-07-24 +- **Deciders:** Respellion engineering +- **Slice:** S-15a (#130), first of the S-15 (#16) split + +## Context + +The beheer portal shows a read-only view of the ZTC catalogus (the published +zaaktypen). Two coupling rules constrain where that data can come from: + +- **§8.1** — only the ACL may talk to the ZGW APIs (Catalogi included). So the + catalogus read *must* originate in the ACL. +- **§8.3** — portals talk only to the BFF. So the portal reaches the ACL only + through the BFF. + +That leaves the question of *how the BFF gets the data*. Until now the BFF fanned +out to exactly two backends — the Domain Service and the read projection. The +catalogus is neither: it is not a registration (domain) nor a projected read model. + +## Decision + +**The BFF calls the ACL directly for the beheer catalogus read** — a new typed +`IAclClient` (`GET /catalogi/zaaktypen`), configured by `Downstream:Acl:BaseUrl`, +mirroring the existing `IDomainClient` / `IProjectionClient` pattern. + +Rejected alternative — **route it through the Domain Service** (BFF → domain → +ACL): the catalogus is not a domain concern, so the domain would gain a +pass-through endpoint that owns no aggregate and no invariant, blurring the +domain's responsibility purely to avoid a new edge. That is worse coupling, not +better. + +This adds one service-to-service edge (BFF → ACL) — an architecturally +significant boundary change (§14), hence this ADR. It does **not** bend §8: the +ACL stays the only code that reads ZGW, and the portal still talks only to the +BFF. The ACL endpoint is a plain read that trusts its callers (§8.3); the +beheerder authorization lives at the BFF (medewerker realm + `beheerder` role). + +## Consequences + +**Positive** + +- The catalogus read follows the shortest honest path; the domain stays about + registrations. +- Symmetric with the other downstream clients — nothing new to learn. + +**Negative / costs** + +- The BFF now depends on three backends instead of two. The ACL must be reachable + for the beheer portal to load (it already is — the BFF is on the same network). +- A second consumer of the ACL (alongside the domain and event-subscriber), so + ACL read endpoints are now part of more than one caller's contract. + +## Coupling rules touched (CLAUDE.md §8) + +A new BFF → ACL edge. §8.1 and §8.3 remain intact; §14 (boundary change) is the +reason this ADR exists. diff --git a/docs/demo-script.md b/docs/demo-script.md index 09d3a74..58b8770 100644 --- a/docs/demo-script.md +++ b/docs/demo-script.md @@ -5,6 +5,32 @@ copy-pasteable walkthrough against a local `make up` stack. --- +## S-15a — Beheer-portal: read-only catalogus viewer (#130, ADR-0025) + +**Outcome:** a new **beheer** portal (medewerker realm, like behandel) shows the ZTC catalogus — +the published zaaktypen — **read-only**. A beheerder logs in and sees the seeded BIG-REGISTRATIE +zaaktype. The read path is portal → BFF `GET /beheer/catalogi/zaaktypen` (medewerker realm + +`beheerder` role) → ACL `GET /catalogi/zaaktypen` → ZGW Catalogi API. The BFF reaches the ACL +directly (ADR-0025); managing the default-fill config (S-15b) and MFA (S-15c) come next. + +```bash +make up +# 1. Log in as bram-beheerder / test123 → the catalogus lists the published zaaktypen. +open http://localhost:8143 +# +# 2. Automated (a CI verify-stack e2e): a beheerder logs in and sees BIG-REGISTRATIE. +make verify-e2e # → catalogus.spec: "a beheerder sees the published zaaktypen in the catalogus" +# +# 3. The BFF endpoint is behind the beheerder role — a plain behandelaar gets 403 (BFF unit tests): +# Bff.Tests → BeheerEndpointTests. +``` + +**Auth:** the `beheerder` realm role + `bram-beheerder` user live in the medewerker realm +(`infra/keycloak/realms/medewerker-realm.json`); the BFF reuses the medewerker bearer scheme and its +realm-role lifting, requiring `beheerder` rather than `behandelaar`. + +--- + ## S-16c — Prometheus metrics + golden-signal Grafana dashboard (#124, ADR-0023) **Outcome:** the five .NET services now expose OpenTelemetry metrics in Prometheus format at `/metrics` diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 045d24f..0004266 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -380,6 +380,8 @@ services: Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker Downstream__Domain__BaseUrl: http://domain:8080/ Downstream__Projection__BaseUrl: http://projection-api:8080/ + # The beheer catalogus read reaches the ACL directly (S-15a, ADR-0025). + Downstream__Acl__BaseUrl: http://acl:8080/ ports: - "8080:8080" healthcheck: @@ -544,6 +546,29 @@ services: condition: service_started networks: [cg] + # The beheer portal: nginx serves the Angular app and reverse-proxies /beheer to the BFF. + # Beheerders log in against the Keycloak medewerker realm (same realm as behandel, S-15a). + beheer: + build: + context: .. + dockerfile: apps/beheer/Dockerfile + image: register-referentie/beheer:dev + ports: + - "8143:80" + healthcheck: + # 127.0.0.1, not localhost: nginx listens on IPv4 only, but localhost resolves to ::1 first. + test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"] + interval: 5s + timeout: 3s + retries: 5 + start_period: 10s + depends_on: + bff: + condition: service_healthy + keycloak: + condition: service_started + networks: [cg] + # ── Observability backplane (S-16a, ADR-0023) ────────────────────────────── # Grafana-native stack: Tempo ingests OTLP traces (the .NET services export # straight to it — no collector hop, S-16b), Prometheus scrapes service diff --git a/tests/e2e/catalogus.spec.ts b/tests/e2e/catalogus.spec.ts new file mode 100644 index 0000000..42a19c5 --- /dev/null +++ b/tests/e2e/catalogus.spec.ts @@ -0,0 +1,19 @@ +import { expect, test } from '@playwright/test'; + +// S-15a walking skeleton: a beheerder logs in to the beheer portal (medewerker realm) and sees the +// read-only ZTC catalogus. The verify stack seeds and publishes the BIG-REGISTRATIE zaaktype (the +// same one verify-domain relies on), so it must appear in the catalogus. Runs against the shared +// verify stack, so it asserts on that stable seeded zaaktype rather than anything test-specific. +test('a beheerder sees the published zaaktypen in the catalogus', async ({ page }) => { + await page.goto('http://beheer/'); + + // The beheer portal redirects to the Keycloak medewerker realm login (same realm as behandel). + await page.locator('#username').fill('bram-beheerder'); + await page.locator('#password').fill('test123'); + await page.locator('#kc-login').click(); + + await expect(page.getByRole('heading', { name: /Catalogus/i })).toBeVisible(); + + // The seeded, published BIG zaaktype is shown by its business identificatie. + await expect(page.getByText('BIG-REGISTRATIE')).toBeVisible(); +}); diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index 85be0a2..43298c0 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -6,6 +6,9 @@ const baseURL = process.env.SELF_SERVICE_URL ?? 'http://self-service'; // The behandel portal is a second origin the happy path visits (staff approve from the werkbak); // it needs the same insecure-origin-as-secure treatment as self-service for the PKCE login (below). const behandelURL = process.env.BEHANDEL_URL ?? 'http://behandel'; +// The beheer portal is a third medewerker-realm origin (the read-only catalogus viewer, S-15a); it +// needs the same insecure-origin-as-secure treatment as the others for the PKCE login (below). +const beheerURL = process.env.BEHEER_URL ?? 'http://beheer'; export default defineConfig({ testDir: '.', @@ -33,7 +36,7 @@ export default defineConfig({ channel: 'chromium', launchOptions: { args: [ - `--unsafely-treat-insecure-origin-as-secure=${baseURL},${behandelURL}`, + `--unsafely-treat-insecure-origin-as-secure=${baseURL},${behandelURL},${beheerURL}`, // Write Chromium's shared memory to /tmp instead of the container's small /dev/shm, so a // large DOM/heap can't crash the renderer on the memory-constrained runner (belt-and-braces // alongside the single worker above).