## What & why S-15a, the first of the S-15 (#16) split. A new **beheer** portal (medewerker realm, like behandel) shows the ZTC catalogus — the published zaaktypen — **read-only**. A beheerder logs in and sees the seeded BIG-REGISTRATIE zaaktype. Closes #130 ### The vertical portal → BFF `GET /beheer/catalogi/zaaktypen` (medewerker realm + `beheerder` role) → ACL `GET /catalogi/zaaktypen` → ZGW Catalogi API. - **ACL**: new read-only `GET /catalogi/zaaktypen` listing published zaaktypen (reuses the ADR-0021 Catalogi client; public-safe `identificatie`/`omschrijving`). - **BFF**: new typed `IAclClient` + `Downstream:Acl:BaseUrl`, and `GET /beheer/catalogi/zaaktypen` behind a new `beheerder` policy (reuses the medewerker bearer scheme + realm-role lifting). OpenAPI spec + generated Angular client regenerated. - **Keycloak**: `beheerder` realm role + `bram-beheerder` test user in the medewerker realm. - **Frontend**: new `apps/beheer` Angular app (copied from behandel) with a read-only catalogus page; `SECURE_API_ROUTES=['/beheer/']`. - **Infra**: `beheer` compose service (port 8143), added to `WAIT_SVCS` + CI log-dump; a Playwright e2e (beheerder login → catalogus shows BIG-REGISTRATIE). ### New boundary → ADR-0025 The BFF now reaches the **ACL directly** for the catalogus read — a new service-to-service edge (§14). The catalogus is neither a domain nor a projection concern, and §8.1 means only the ACL may read ZGW; routing through the domain would pollute it with a non-domain passthrough. §8.1/§8.3 stay intact. Recorded in **ADR-0025**. ## Definition of Done - [x] Failing test committed before each implementation (red→green per layer: ACL, BFF, frontend). - [x] Conventional Commits referencing #130. - [ ] CI green — pending Gitea Actions run. - [x] `docker compose up` brings up `beheer` (health-gated in `WAIT_SVCS`). - [x] Docs — ADR-0025 + demo-script S-15a note. - [x] Demo note in `docs/demo-script.md`. ## Verified locally lint (`dotnet format`) ✓ · .NET unit (Acl 57 / Big 152 / EventSubscriber 19 / Bff 40) ✓ · frontend lint+test (8 projects) ✓ · frontend build (4 apps) ✓. Mutation ratchet: added a gateway unit test for the new `ListZaaktypenAsync` mapping so the ACL score holds. verify-stack (compose smoke + e2e) runs in CI. ## Notes for reviewers - The BFF drops the ZGW URL from `BeheerZaaktype` (public-safe: identificatie + omschrijving only). - The catalogus e2e asserts on the stable seeded `BIG-REGISTRATIE` (not a per-test reference), safe on the shared verify stack. - Follow-ups: **S-15b** (#131) default-fill CRUD, **S-15c** (#132) medewerker-realm MFA. 🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #133
This commit was merged in pull request #133.
This commit is contained in:
@@ -177,7 +177,7 @@ jobs:
|
|||||||
# Log dump must precede teardown (which removes the containers).
|
# Log dump must precede teardown (which removes the containers).
|
||||||
- name: Dump container logs on failure
|
- name: Dump container logs on failure
|
||||||
if: 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
|
- name: Tear down
|
||||||
if: always()
|
if: always()
|
||||||
run: make down
|
run: make down
|
||||||
|
|||||||
+8
-2
@@ -249,10 +249,16 @@ Split (issue #11 closed) into two independently-demoable slices per §13 — the
|
|||||||
|
|
||||||
## Iteration 3 — Maintenance portal and observability *(milestone: `Iteration 3 — Beheer & Observability`)*
|
## Iteration 3 — Maintenance portal and observability *(milestone: `Iteration 3 — Beheer & Observability`)*
|
||||||
|
|
||||||
### S-15 · Beheer-portal — catalogus & default-fill rules
|
### S-15 · Beheer-portal — catalogus & default-fill rules *(split — #16 closed)*
|
||||||
|
|
||||||
**Outcome:** Beheer portal lets an admin view ZTC catalogi (read-only first), and manage the ACL's default-fill configuration via a CRUD UI. MFA on the medewerker realm enforced.
|
**Outcome:** Beheer portal lets an admin view ZTC catalogi (read-only first), and manage the ACL's default-fill configuration via a CRUD UI. MFA on the medewerker realm enforced.
|
||||||
|
|
||||||
|
Split into independently deployable sub-slices (CLAUDE.md §13):
|
||||||
|
|
||||||
|
- **S-15a** (#130) · Beheer portal skeleton + read-only catalogi viewer — new beheer Angular app (medewerker-realm login) showing ZTC catalogi/zaaktypen read-only, via a BFF `/beheer/*` read endpoint proxying a read-only ACL Catalogi endpoint (§8.1, reuses the ADR-0021 Catalogi client).
|
||||||
|
- **S-15b** (#131) · ACL default-fill configuration CRUD — the `Acl__Defaults__*` config (ADR-0003) becomes a managed store with CRUD via the BFF + a portal UI. Depends on S-15a.
|
||||||
|
- **S-15c** (#132) · Enforce MFA (OTP) on the Keycloak medewerker realm.
|
||||||
|
|
||||||
### S-16 · OpenTelemetry traces + Grafana dashboard *(split — #17 closed)*
|
### S-16 · OpenTelemetry traces + Grafana dashboard *(split — #17 closed)*
|
||||||
|
|
||||||
**Outcome:** Traces span portal → BFF → Domain → ACL → OpenZaak and portal → BFF → Domain → Flowable. Grafana dashboards pre-built for golden signals.
|
**Outcome:** Traces span portal → BFF → Domain → ACL → OpenZaak and portal → BFF → Domain → Flowable. Grafana dashboards pre-built for golden signals.
|
||||||
@@ -261,7 +267,7 @@ Split into independently deployable sub-slices (CLAUDE.md §13):
|
|||||||
|
|
||||||
- **S-16a** (#122) · Observability backplane — Grafana Tempo + Prometheus + Grafana in compose, datasources auto-provisioned (ADR-0023). No collector; config baked into built images.
|
- **S-16a** (#122) · Observability backplane — Grafana Tempo + Prometheus + Grafana in compose, datasources auto-provisioned (ADR-0023). No collector; config baked into built images.
|
||||||
- **S-16b** (#123) · Distributed traces across the five .NET services (OTLP → Tempo; traceparent propagates via the typed HttpClients). Depends on S-16a. ✅
|
- **S-16b** (#123) · Distributed traces across the five .NET services (OTLP → Tempo; traceparent propagates via the typed HttpClients). Depends on S-16a. ✅
|
||||||
- **S-16c** (#124) · Prometheus metrics + golden-signal Grafana dashboards. Depends on S-16a.
|
- **S-16c** (#124) · Prometheus metrics + golden-signal Grafana dashboards. Depends on S-16a. ✅
|
||||||
|
|
||||||
### S-17 · Quartz.NET scheduler — herregistratie reminder sweep ✅
|
### S-17 · Quartz.NET scheduler — herregistratie reminder sweep ✅
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ COMPOSE := infra/docker-compose.yml
|
|||||||
# Long-running services with a healthcheck — the smoke polls these for readiness
|
# 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)
|
# (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.
|
# 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
|
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
|
||||||
# into external named volumes via `docker cp` (infra/seed-config.sh) instead of
|
# 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
|
# bind-mounted, because bind mounts don't reach sibling containers on the
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Multi-stage build for the beheer portal (Angular → nginx).
|
||||||
|
# Build context is the repo root (the app needs the pnpm workspace + libs). See infra/docker-compose.yml.
|
||||||
|
FROM node:24-slim AS build
|
||||||
|
WORKDIR /src
|
||||||
|
RUN corepack enable && corepack prepare pnpm@11.5.2 --activate
|
||||||
|
|
||||||
|
# Restore first (cached unless the manifests change).
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml nx.json tsconfig.base.json eslint.config.mjs ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Sources (only what the app + its libs need).
|
||||||
|
COPY apps/beheer apps/beheer
|
||||||
|
COPY libs libs
|
||||||
|
RUN pnpm nx build beheer
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine AS runtime
|
||||||
|
COPY apps/beheer/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /src/dist/apps/beheer/browser /usr/share/nginx/html
|
||||||
|
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
|
||||||
|
# service name, so the token issuer matches the BFF's medewerker authority (host-consistent, ADR-0013).
|
||||||
|
RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/nginx/html/config.json
|
||||||
|
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
|
||||||
|
# the nginx image's /docker-entrypoint.d before nginx starts.
|
||||||
|
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import nx from '@nx/eslint-plugin';
|
||||||
|
import baseConfig from '../../eslint.config.mjs';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...nx.configs['flat/angular'],
|
||||||
|
...nx.configs['flat/angular-template'],
|
||||||
|
...baseConfig,
|
||||||
|
{
|
||||||
|
files: ['**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'@angular-eslint/directive-selector': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
type: 'attribute',
|
||||||
|
prefix: 'app',
|
||||||
|
style: 'camelCase',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'@angular-eslint/component-selector': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
type: 'element',
|
||||||
|
prefix: 'app',
|
||||||
|
style: 'kebab-case',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.html'],
|
||||||
|
// Override or add rules here
|
||||||
|
rules: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
|
||||||
|
# even before the BFF is up and picks up restarts — instead of failing to load the config.
|
||||||
|
resolver 127.0.0.11 ipv6=off valid=30s;
|
||||||
|
|
||||||
|
# Same-origin API: proxy the beheer endpoint group to the bff service. The api-client uses
|
||||||
|
# relative URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS, and the
|
||||||
|
# medewerker token (same-origin) is attached by the app's interceptor (ADR-0013).
|
||||||
|
location /beheer/ {
|
||||||
|
set $bff http://bff:8080;
|
||||||
|
proxy_pass $bff;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback — Angular client-side routing.
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"name": "beheer",
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
|
"projectType": "application",
|
||||||
|
"prefix": "app",
|
||||||
|
"sourceRoot": "apps/beheer/src",
|
||||||
|
"tags": [],
|
||||||
|
"targets": {
|
||||||
|
"build": {
|
||||||
|
"executor": "@angular/build:application",
|
||||||
|
"outputs": ["{options.outputPath}"],
|
||||||
|
"defaultConfiguration": "production",
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist/apps/beheer",
|
||||||
|
"browser": "apps/beheer/src/main.ts",
|
||||||
|
"tsConfig": "apps/beheer/tsconfig.app.json",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "apps/beheer/public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": ["apps/beheer/src/styles.css"]
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "1mb",
|
||||||
|
"maximumError": "2mb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "4kb",
|
||||||
|
"maximumError": "8kb"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"continuous": true,
|
||||||
|
"executor": "@angular/build:dev-server",
|
||||||
|
"defaultConfiguration": "development",
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "beheer:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "beheer:build:development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"executor": "@nx/eslint:lint"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"executor": "@angular/build:unit-test",
|
||||||
|
"options": {
|
||||||
|
"watch": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"serve-static": {
|
||||||
|
"continuous": true,
|
||||||
|
"executor": "@nx/web:file-server",
|
||||||
|
"options": {
|
||||||
|
"buildTarget": "beheer:build",
|
||||||
|
"staticFilePath": "dist/apps/beheer/browser",
|
||||||
|
"spa": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"authority": "http://localhost:8180/realms/medewerker"
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,65 @@
|
|||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { BffApiV1Service } from 'api-client';
|
||||||
|
import { authInterceptor } from 'auth';
|
||||||
|
import { AbstractSecurityStorage, ConfigurationService } from 'angular-auth-oidc-client';
|
||||||
|
import { SECURE_API_ROUTES } from './app.config';
|
||||||
|
|
||||||
|
// Guards the medewerker token wiring end-to-end. The api-client calls the BFF with RELATIVE URLs, and
|
||||||
|
// the angular-auth-oidc-client interceptor attaches the token only when `req.url` starts with a
|
||||||
|
// configured secureRoute. A regression to an absolute origin makes the relative URL never match, so
|
||||||
|
// the beheer calls go out unauthenticated and the BFF answers 401. This drives the REAL interceptor
|
||||||
|
// and the REAL api-client against the REAL production route value (SECURE_API_ROUTES); only the config
|
||||||
|
// source and token storage are faked, so the assertion turns on the actual route-matching.
|
||||||
|
describe('beheer medewerker token wiring', () => {
|
||||||
|
let http: HttpTestingController;
|
||||||
|
let bff: BffApiV1Service;
|
||||||
|
const token = 'medewerker-access-token';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(withInterceptors([authInterceptor()])),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{
|
||||||
|
provide: ConfigurationService,
|
||||||
|
useValue: {
|
||||||
|
hasAtLeastOneConfig: () => true,
|
||||||
|
getAllConfigurations: () => [{ configId: 'medewerker', secureRoutes: SECURE_API_ROUTES }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A signed-in session: the storage the interceptor's token lookup reads from.
|
||||||
|
provide: AbstractSecurityStorage,
|
||||||
|
useValue: {
|
||||||
|
read: () => JSON.stringify({ authzData: token, authnResult: { id_token: 'id-token' } }),
|
||||||
|
write: () => undefined,
|
||||||
|
remove: () => undefined,
|
||||||
|
clear: () => undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
http = TestBed.inject(HttpTestingController);
|
||||||
|
bff = TestBed.inject(BffApiV1Service);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => http.verify());
|
||||||
|
|
||||||
|
it('attaches the bearer token to the relative catalogus call', () => {
|
||||||
|
bff.getBeheerCatalogiZaaktypen().subscribe();
|
||||||
|
|
||||||
|
const req = http.expectOne('/beheer/catalogi/zaaktypen');
|
||||||
|
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
|
||||||
|
req.flush([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the anonymous openbaar register call unauthenticated', () => {
|
||||||
|
bff.getOpenbaarRegister().subscribe();
|
||||||
|
|
||||||
|
const req = http.expectOne((r) => r.url === '/openbaar/register');
|
||||||
|
expect(req.request.headers.has('Authorization')).toBe(false);
|
||||||
|
req.flush([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { authInterceptor, provideMedewerkerAuth } from 'auth';
|
||||||
|
import { appRoutes } from './app.routes';
|
||||||
|
|
||||||
|
/** Environment-specific settings fetched from /config.json at startup (see main.ts). */
|
||||||
|
export interface RuntimeConfig {
|
||||||
|
/** The Keycloak `medewerker` realm issuer as the browser reaches it (dev: localhost; compose: keycloak:8080). */
|
||||||
|
authority: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route prefixes whose requests carry the medewerker token. These MUST match the **relative** URLs
|
||||||
|
* the api-client actually calls (same-origin via the nginx proxy) — the interceptor matches on
|
||||||
|
* `req.url`, which stays relative, so an absolute origin would never match and the token would go
|
||||||
|
* unattached. Only `/beheer/` is secured; the app calls no other endpoint group.
|
||||||
|
*/
|
||||||
|
export const SECURE_API_ROUTES = ['/beheer/'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the app providers from runtime config. `redirectUrl` is the app's own origin (where Keycloak
|
||||||
|
* redirects back). `secureRoutes` uses {@link SECURE_API_ROUTES} — relative prefixes, not the origin.
|
||||||
|
*/
|
||||||
|
export function appConfig(runtime: RuntimeConfig): ApplicationConfig {
|
||||||
|
const origin = typeof window !== 'undefined' ? window.location.origin : '/';
|
||||||
|
return {
|
||||||
|
providers: [
|
||||||
|
provideBrowserGlobalErrorListeners(),
|
||||||
|
provideRouter(appRoutes),
|
||||||
|
provideHttpClient(withInterceptors([authInterceptor()])),
|
||||||
|
provideMedewerkerAuth({
|
||||||
|
authority: runtime.authority,
|
||||||
|
redirectUrl: origin,
|
||||||
|
secureRoutes: SECURE_API_ROUTES,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<router-outlet></router-outlet>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Route } from '@angular/router';
|
||||||
|
import { authenticatedGuard } from 'auth';
|
||||||
|
import { CatalogusPage } from './catalogus/catalogus-page';
|
||||||
|
|
||||||
|
export const appRoutes: Route[] = [
|
||||||
|
{ path: '', component: CatalogusPage, canActivate: [authenticatedGuard] },
|
||||||
|
];
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { render, screen } from '@testing-library/angular';
|
||||||
|
import { App } from './app';
|
||||||
|
|
||||||
|
describe('App', () => {
|
||||||
|
it('renders the router outlet shell', async () => {
|
||||||
|
const { container } = await render(App, {
|
||||||
|
providers: [provideRouter([])],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The shell is a thin host for routed pages (the CatalogusPage owns the heading).
|
||||||
|
expect(container.querySelector('router-outlet')).toBeTruthy();
|
||||||
|
expect(screen).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
imports: [RouterModule],
|
||||||
|
selector: 'app-root',
|
||||||
|
templateUrl: './app.html',
|
||||||
|
styleUrl: './app.css',
|
||||||
|
})
|
||||||
|
export class App {
|
||||||
|
protected title = 'beheer';
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<main utrecht-document class="utrecht-theme">
|
||||||
|
<utrecht-article>
|
||||||
|
<utrecht-heading-1>Catalogus</utrecht-heading-1>
|
||||||
|
<p utrecht-paragraph>
|
||||||
|
De gepubliceerde zaaktypen uit de ZTC-catalogus. Alleen-lezen — beheer van de default-fill volgt
|
||||||
|
in een latere slice.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<p utrecht-paragraph role="status">Bezig met laden…</p>
|
||||||
|
} @else if (failed()) {
|
||||||
|
<p utrecht-paragraph role="alert">
|
||||||
|
Kon de catalogus niet laden. Controleer of je als beheerder bent ingelogd en probeer het
|
||||||
|
opnieuw.
|
||||||
|
</p>
|
||||||
|
} @else if (loaded() && items().length === 0) {
|
||||||
|
<p utrecht-paragraph role="status">De catalogus bevat geen gepubliceerde zaaktypen.</p>
|
||||||
|
} @else if (items().length > 0) {
|
||||||
|
<table utrecht-table>
|
||||||
|
<caption>
|
||||||
|
Gepubliceerde zaaktypen
|
||||||
|
</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Identificatie</th>
|
||||||
|
<th scope="col">Omschrijving</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (zaaktype of items(); track zaaktype.identificatie) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ zaaktype.identificatie }}</td>
|
||||||
|
<td>{{ zaaktype.omschrijving }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</utrecht-article>
|
||||||
|
</main>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { render, screen } from '@testing-library/angular';
|
||||||
|
import { of, throwError } from 'rxjs';
|
||||||
|
import { BeheerZaaktype, BffApiV1Service } from 'api-client';
|
||||||
|
import { AuthService } from 'auth';
|
||||||
|
import { axe } from 'vitest-axe';
|
||||||
|
import { CatalogusPage } from './catalogus-page';
|
||||||
|
|
||||||
|
const sample: BeheerZaaktype[] = [
|
||||||
|
{ identificatie: 'BIG-REGISTRATIE', omschrijving: 'BIG-registratie' },
|
||||||
|
{ identificatie: 'BIG-HERREGISTRATIE', omschrijving: 'BIG-herregistratie' },
|
||||||
|
];
|
||||||
|
|
||||||
|
class FakeAuth extends AuthService {
|
||||||
|
readonly isAuthenticated = signal(true);
|
||||||
|
readonly bsn = signal<string | undefined>(undefined);
|
||||||
|
override readonly roles = signal<readonly string[]>(['beheerder']);
|
||||||
|
login(): void {
|
||||||
|
/* not exercised here */
|
||||||
|
}
|
||||||
|
logout(): void {
|
||||||
|
/* not exercised here */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(overrides: { getBeheerCatalogiZaaktypen?: ReturnType<typeof vi.fn> } = {}) {
|
||||||
|
const getBeheerCatalogiZaaktypen =
|
||||||
|
overrides.getBeheerCatalogiZaaktypen ?? vi.fn().mockReturnValue(of(sample));
|
||||||
|
return {
|
||||||
|
getBeheerCatalogiZaaktypen,
|
||||||
|
providers: [
|
||||||
|
{ provide: BffApiV1Service, useValue: { getBeheerCatalogiZaaktypen } },
|
||||||
|
{ provide: AuthService, useClass: FakeAuth },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CatalogusPage', () => {
|
||||||
|
it('lists the published zaaktypen on open', async () => {
|
||||||
|
const { getBeheerCatalogiZaaktypen, providers } = setup();
|
||||||
|
await render(CatalogusPage, { providers });
|
||||||
|
|
||||||
|
expect(getBeheerCatalogiZaaktypen).toHaveBeenCalled();
|
||||||
|
expect(await screen.findByText('BIG-REGISTRATIE')).toBeTruthy();
|
||||||
|
expect(screen.getByText('BIG-registratie')).toBeTruthy();
|
||||||
|
expect(screen.getByText('BIG-HERREGISTRATIE')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an empty state when the catalogus has no published zaaktypen', async () => {
|
||||||
|
const { providers } = setup({ getBeheerCatalogiZaaktypen: vi.fn().mockReturnValue(of([])) });
|
||||||
|
await render(CatalogusPage, { providers });
|
||||||
|
|
||||||
|
expect(await screen.findByText(/geen gepubliceerde zaaktypen/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a load failure instead of swallowing it', async () => {
|
||||||
|
const { providers } = setup({
|
||||||
|
getBeheerCatalogiZaaktypen: vi.fn().mockReturnValue(throwError(() => new Error('403'))),
|
||||||
|
});
|
||||||
|
await render(CatalogusPage, { providers });
|
||||||
|
|
||||||
|
expect(await screen.findByText(/kon de catalogus niet laden/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no WCAG 2.1 AA violations', async () => {
|
||||||
|
document.documentElement.lang = 'nl';
|
||||||
|
const { container } = await render(CatalogusPage, { providers: setup().providers });
|
||||||
|
|
||||||
|
const results = await axe(container, {
|
||||||
|
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results.violations).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Component, inject, signal } from '@angular/core';
|
||||||
|
import { BeheerZaaktype, BffApiV1Service } from 'api-client';
|
||||||
|
import { UtrechtComponentsModule } from 'ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The beheer catalogus viewer (S-15a): a signed-in beheerder sees the published ZTC zaaktypen,
|
||||||
|
* read-only. The list is served by the BFF (`GET /beheer/catalogi/zaaktypen`), which proxies the ACL —
|
||||||
|
* the only code allowed to read the ZGW Catalogi API (§8.1, ADR-0025). Managing default-fill is S-15b.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-catalogus-page',
|
||||||
|
imports: [UtrechtComponentsModule],
|
||||||
|
templateUrl: './catalogus-page.html',
|
||||||
|
})
|
||||||
|
export class CatalogusPage {
|
||||||
|
private readonly bff = inject(BffApiV1Service);
|
||||||
|
|
||||||
|
protected readonly items = signal<BeheerZaaktype[]>([]);
|
||||||
|
protected readonly loading = signal(false);
|
||||||
|
protected readonly loaded = signal(false);
|
||||||
|
protected readonly failed = signal(false);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.failed.set(false);
|
||||||
|
this.bff.getBeheerCatalogiZaaktypen().subscribe({
|
||||||
|
next: (rows: BeheerZaaktype[]) => {
|
||||||
|
this.items.set(rows);
|
||||||
|
this.loading.set(false);
|
||||||
|
this.loaded.set(true);
|
||||||
|
},
|
||||||
|
// Surface the failure (e.g. 403 for a non-beheerder) instead of swallowing it.
|
||||||
|
error: () => {
|
||||||
|
this.items.set([]);
|
||||||
|
this.loading.set(false);
|
||||||
|
this.loaded.set(true);
|
||||||
|
this.failed.set(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="nl">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Beheerportaal BIG-register</title>
|
||||||
|
<base href="/" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<app-root></app-root>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { App } from './app/app';
|
||||||
|
import { appConfig, type RuntimeConfig } from './app/app.config';
|
||||||
|
|
||||||
|
// Load environment config before bootstrap so the OIDC authority is set per environment
|
||||||
|
// (dev: localhost; compose: keycloak:8080) from a single build — 12-factor (S-08d).
|
||||||
|
fetch('config.json')
|
||||||
|
.then((response) => response.json() as Promise<RuntimeConfig>)
|
||||||
|
.then((config) => bootstrapApplication(App, appConfig(config)))
|
||||||
|
.catch((err) => console.error(err));
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
|
||||||
|
@import '@utrecht/design-tokens/dist/index.css';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"target": "es2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"emitDecoratorMetadata": false,
|
||||||
|
"module": "preserve"
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
},
|
||||||
|
"files": [],
|
||||||
|
"include": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"types": ["vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts"]
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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)
|
## 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`
|
**Outcome:** the five .NET services now expose OpenTelemetry metrics in Prometheus format at `/metrics`
|
||||||
|
|||||||
@@ -380,6 +380,8 @@ services:
|
|||||||
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
|
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
|
||||||
Downstream__Domain__BaseUrl: http://domain:8080/
|
Downstream__Domain__BaseUrl: http://domain:8080/
|
||||||
Downstream__Projection__BaseUrl: http://projection-api: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:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -544,6 +546,29 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
networks: [cg]
|
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) ──────────────────────────────
|
# ── Observability backplane (S-16a, ADR-0023) ──────────────────────────────
|
||||||
# Grafana-native stack: Tempo ingests OTLP traces (the .NET services export
|
# Grafana-native stack: Tempo ingests OTLP traces (the .NET services export
|
||||||
# straight to it — no collector hop, S-16b), Prometheus scrapes service
|
# straight to it — no collector hop, S-16b), Prometheus scrapes service
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"roles": {
|
"roles": {
|
||||||
"realm": [
|
"realm": [
|
||||||
{ "name": "behandelaar", "description": "Behandelt registratieaanvragen" },
|
{ "name": "behandelaar", "description": "Behandelt registratieaanvragen" },
|
||||||
{ "name": "teamlead", "description": "Teamleider behandeling" }
|
{ "name": "teamlead", "description": "Teamleider behandeling" },
|
||||||
|
{ "name": "beheerder", "description": "Beheert catalogus en default-fill (beheer-portal, S-15)" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"clients": [
|
"clients": [
|
||||||
@@ -54,6 +55,16 @@
|
|||||||
"emailVerified": true,
|
"emailVerified": true,
|
||||||
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
||||||
"realmRoles": ["behandelaar", "teamlead"]
|
"realmRoles": ["behandelaar", "teamlead"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "bram-beheerder",
|
||||||
|
"enabled": true,
|
||||||
|
"firstName": "Bram",
|
||||||
|
"lastName": "Beheerder",
|
||||||
|
"email": "bram@big.example.nl",
|
||||||
|
"emailVerified": true,
|
||||||
|
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
||||||
|
"realmRoles": ["beheerder"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ import {
|
|||||||
Observable
|
Observable
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
|
|
||||||
|
export interface BeheerZaaktype {
|
||||||
|
identificatie: string;
|
||||||
|
omschrijving: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CurrentRegistration {
|
export interface CurrentRegistration {
|
||||||
registrationId: string;
|
registrationId: string;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -410,4 +415,35 @@ export class BffApiV1Service {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getBeheerCatalogiZaaktypen<TData = BeheerZaaktype[]>( options?: HttpClientBodyOptions): Observable<TData>;
|
||||||
|
getBeheerCatalogiZaaktypen<TData = BeheerZaaktype[]>( options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||||
|
getBeheerCatalogiZaaktypen<TData = BeheerZaaktype[]>( options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||||
|
getBeheerCatalogiZaaktypen<TData = BeheerZaaktype[]>(
|
||||||
|
options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
|
||||||
|
if (options?.observe === 'events') {
|
||||||
|
return this.http.get<TData>(
|
||||||
|
`/beheer/catalogi/zaaktypen`,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'events',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.observe === 'response') {
|
||||||
|
return this.http.get<TData>(
|
||||||
|
`/beheer/catalogi/zaaktypen`,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'response',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http.get<TData>(
|
||||||
|
`/beheer/catalogi/zaaktypen`,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'body',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -85,6 +85,12 @@ app.MapPost("/documenten", async (StoreDocumentRequest body, AclService acl, Can
|
|||||||
return Results.Ok(new { informatieobjectUrl = url.ToString() });
|
return Results.Ok(new { informatieobjectUrl = url.ToString() });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// List the published zaaktypen — the read-only catalogus the beheer portal shows (S-15a). The BFF
|
||||||
|
// proxies this behind medewerker-realm + beheerder authorization; the ACL trusts its callers (§8.3)
|
||||||
|
// and is the only code allowed to read the ZGW Catalogi API (§8.1).
|
||||||
|
app.MapGet("/catalogi/zaaktypen", async (AclService acl, CancellationToken ct) =>
|
||||||
|
Results.Ok(await acl.ListZaaktypenAsync(ct)));
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
public sealed record OpenZaakRequest(string Bsn, string Reference);
|
public sealed record OpenZaakRequest(string Bsn, string Reference);
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaak
|
|||||||
await gateway.SetZaakToCancellationStatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct);
|
await gateway.SetZaakToCancellationStatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The published zaaktypen, for the beheer catalogus viewer (S-15a). Read-only passthrough:
|
||||||
|
/// no default-fill, the ACL is simply the only code allowed to read ZGW (§8.1).</summary>
|
||||||
|
public Task<IReadOnlyList<ZaaktypeSummary>> ListZaaktypenAsync(CancellationToken ct = default) =>
|
||||||
|
gateway.ListZaaktypenAsync(ct);
|
||||||
|
|
||||||
/// <summary>The zaak's reference (its ZGW identificatie), for the read projection (#78).</summary>
|
/// <summary>The zaak's reference (its ZGW identificatie), for the read projection (#78).</summary>
|
||||||
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
|
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -40,4 +40,8 @@ public interface IZaakGateway
|
|||||||
/// <summary>Resolve the URL of the published informatieobjecttype with the given
|
/// <summary>Resolve the URL of the published informatieobjecttype with the given
|
||||||
/// <paramref name="omschrijving"/> from the Catalogi API (S-27). Throws if none matches.</summary>
|
/// <paramref name="omschrijving"/> from the Catalogi API (S-27). Throws if none matches.</summary>
|
||||||
Task<Uri> ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default);
|
Task<Uri> ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>List the published zaaktypen from the Catalogi API — the read-only catalogus the beheer
|
||||||
|
/// portal shows (S-15a). The ACL is the only code allowed to read ZGW (§8.1).</summary>
|
||||||
|
Task<IReadOnlyList<ZaaktypeSummary>> ListZaaktypenAsync(CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Acl.Application;
|
||||||
|
|
||||||
|
/// <summary>A published zaaktype as the beheer catalogus viewer shows it (S-15a). Public-safe: the
|
||||||
|
/// business <see cref="Identificatie"/> + human <see cref="Omschrijving"/> and the ZGW <see cref="Url"/>
|
||||||
|
/// (the URL is the ACL's own reference, not shown to end users).</summary>
|
||||||
|
public sealed record ZaaktypeSummary(string Identificatie, string Omschrijving, Uri Url);
|
||||||
@@ -170,6 +170,16 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
|||||||
return new Uri(match.Url);
|
return new Uri(match.Url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ZaaktypeSummary>> ListZaaktypenAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Only published zaaktypen (status=definitief excludes concepts) — the read-only catalogus the
|
||||||
|
// beheer portal shows. Public-safe fields only.
|
||||||
|
var page = await GetAsync<ZaaktypePage>("/catalogi/api/v1/zaaktypen?status=definitief", "zaaktypen", ct);
|
||||||
|
return (page.Results ?? [])
|
||||||
|
.Select(z => new ZaaktypeSummary(z.Identificatie ?? "", z.Omschrijving ?? "", new Uri(z.Url)))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
// GETs an absolute-by-path ZGW resource with auth (no CRS — catalogi is not a geo API).
|
// GETs an absolute-by-path ZGW resource with auth (no CRS — catalogi is not a geo API).
|
||||||
private async Task<T> GetAsync<T>(string pathAndQuery, string label, CancellationToken ct)
|
private async Task<T> GetAsync<T>(string pathAndQuery, string label, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -346,7 +356,8 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
|||||||
|
|
||||||
private sealed record ZaaktypeDto(
|
private sealed record ZaaktypeDto(
|
||||||
[property: JsonPropertyName("url")] string Url,
|
[property: JsonPropertyName("url")] string Url,
|
||||||
[property: JsonPropertyName("identificatie")] string? Identificatie);
|
[property: JsonPropertyName("identificatie")] string? Identificatie,
|
||||||
|
[property: JsonPropertyName("omschrijving")] string? Omschrijving = null);
|
||||||
|
|
||||||
private sealed record InformatieobjecttypePage(
|
private sealed record InformatieobjecttypePage(
|
||||||
[property: JsonPropertyName("results")] IReadOnlyList<InformatieobjecttypeDto>? Results);
|
[property: JsonPropertyName("results")] IReadOnlyList<InformatieobjecttypeDto>? Results);
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ public class AclServiceTests
|
|||||||
ResolvedByOmschrijving = omschrijving;
|
ResolvedByOmschrijving = omschrijving;
|
||||||
return Task.FromResult(ResolvedInformatieobjecttype);
|
return Task.FromResult(ResolvedInformatieobjecttype);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<ZaaktypeSummary> Zaaktypen { get; } =
|
||||||
|
[
|
||||||
|
new("BIG-REGISTRATIE", "BIG-registratie", new Uri("http://openzaak/catalogi/api/v1/zaaktypen/big")),
|
||||||
|
];
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<ZaaktypeSummary>> ListZaaktypenAsync(CancellationToken ct = default) =>
|
||||||
|
Task.FromResult(Zaaktypen);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AclDefaults Defaults() => new()
|
private static AclDefaults Defaults() => new()
|
||||||
@@ -225,4 +233,18 @@ public class AclServiceTests
|
|||||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.GetZaakReferenceAsync(null!));
|
await Assert.ThrowsAsync<ArgumentNullException>(() => service.GetZaakReferenceAsync(null!));
|
||||||
Assert.Null(gateway.ReadReferenceFor);
|
Assert.Null(gateway.ReadReferenceFor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Listing_zaaktypen_returns_the_gateways_published_zaaktypen(/* S-15a */)
|
||||||
|
{
|
||||||
|
var gateway = new FakeGateway();
|
||||||
|
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||||
|
|
||||||
|
var zaaktypen = await service.ListZaaktypenAsync();
|
||||||
|
|
||||||
|
var only = Assert.Single(zaaktypen);
|
||||||
|
Assert.Equal("BIG-REGISTRATIE", only.Identificatie);
|
||||||
|
Assert.Equal("BIG-registratie", only.Omschrijving);
|
||||||
|
Assert.Equal(new Uri("http://openzaak/catalogi/api/v1/zaaktypen/big"), only.Url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -824,4 +824,51 @@ public class OpenZaakGatewayTests
|
|||||||
await Assert.ThrowsAnyAsync<ArgumentException>(() => Gateway(handler).ResolveZaaktypeUrlAsync(" "));
|
await Assert.ThrowsAnyAsync<ArgumentException>(() => Gateway(handler).ResolveZaaktypeUrlAsync(" "));
|
||||||
await Assert.ThrowsAnyAsync<ArgumentException>(() => Gateway(handler).ResolveInformatieobjecttypeUrlAsync(" "));
|
await Assert.ThrowsAnyAsync<ArgumentException>(() => Gateway(handler).ResolveInformatieobjecttypeUrlAsync(" "));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Listing_zaaktypen_queries_published_zaaktypen_and_maps_them(/* S-15a */)
|
||||||
|
{
|
||||||
|
HttpRequestMessage? seen = null;
|
||||||
|
var handler = new StubHandler(req =>
|
||||||
|
{
|
||||||
|
seen = req;
|
||||||
|
const string json = """
|
||||||
|
{"results":[
|
||||||
|
{"url":"http://openzaak/catalogi/api/v1/zaaktypen/big","identificatie":"BIG-REGISTRATIE","omschrijving":"BIG-registratie"},
|
||||||
|
{"url":"http://openzaak/catalogi/api/v1/zaaktypen/her","identificatie":"BIG-HERREGISTRATIE","omschrijving":"BIG-herregistratie"}
|
||||||
|
]}
|
||||||
|
""";
|
||||||
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(json, Encoding.UTF8, "application/json"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var zaaktypen = await Gateway(handler).ListZaaktypenAsync();
|
||||||
|
|
||||||
|
// Only the published zaaktypen collection is queried (status=definitief excludes concepts).
|
||||||
|
Assert.Contains("/catalogi/api/v1/zaaktypen", seen!.RequestUri!.ToString());
|
||||||
|
Assert.Contains("status=definitief", seen.RequestUri!.ToString());
|
||||||
|
// Authenticated like the other catalogi reads.
|
||||||
|
Assert.Equal("Bearer", seen.Headers.Authorization!.Scheme);
|
||||||
|
// Each result maps to a public-safe summary (identificatie + omschrijving + url).
|
||||||
|
Assert.Equal(2, zaaktypen.Count);
|
||||||
|
Assert.Equal("BIG-REGISTRATIE", zaaktypen[0].Identificatie);
|
||||||
|
Assert.Equal("BIG-registratie", zaaktypen[0].Omschrijving);
|
||||||
|
Assert.Equal(new Uri("http://openzaak/catalogi/api/v1/zaaktypen/big"), zaaktypen[0].Url);
|
||||||
|
Assert.Equal("BIG-HERREGISTRATIE", zaaktypen[1].Identificatie);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Listing_zaaktypen_returns_empty_when_the_catalogus_has_none()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent("""{"results":[]}""", Encoding.UTF8, "application/json"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
var zaaktypen = await Gateway(handler).ListZaaktypenAsync();
|
||||||
|
|
||||||
|
Assert.Empty(zaaktypen);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ public class ZaaktypeCatalogTests
|
|||||||
public Task SetZaakToCancellationStatusAsync(Uri z, Uri zt, DateOnly d, CancellationToken ct = default) => throw new NotSupportedException();
|
public Task SetZaakToCancellationStatusAsync(Uri z, Uri zt, DateOnly d, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
public Task<string> GetZaakIdentificatieAsync(Uri z, CancellationToken ct = default) => throw new NotSupportedException();
|
public Task<string> GetZaakIdentificatieAsync(Uri z, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
public Task<Uri> StoreDocumentAsync(DocumentRequest r, CancellationToken ct = default) => throw new NotSupportedException();
|
public Task<Uri> StoreDocumentAsync(DocumentRequest r, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<IReadOnlyList<ZaaktypeSummary>> ListZaaktypenAsync(CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AclDefaults Defaults() => new()
|
private static AclDefaults Defaults() => new()
|
||||||
|
|||||||
@@ -54,6 +54,19 @@ public interface IProjectionClient
|
|||||||
Task<IReadOnlyList<ProjectionEntry>> GetRegisterAsync(CancellationToken ct = default);
|
Task<IReadOnlyList<ProjectionEntry>> GetRegisterAsync(CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A published zaaktype as the beheer catalogus viewer shows it (S-15a): the business
|
||||||
|
/// <c>Identificatie</c> + human <c>Omschrijving</c>. The ZGW URL the ACL also returns is dropped — an
|
||||||
|
/// internal reference, not shown in the portal.</summary>
|
||||||
|
public sealed record BeheerZaaktype(string Identificatie, string Omschrijving);
|
||||||
|
|
||||||
|
/// <summary>Port to the ACL for read-only catalogus queries (beheer portal, S-15a). The BFF reaches the
|
||||||
|
/// ACL directly for this read: the catalogus isn't a domain concern, and the ACL is the only code
|
||||||
|
/// allowed to read the ZGW Catalogi API (§8.1, ADR-0025).</summary>
|
||||||
|
public interface IAclClient
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<BeheerZaaktype>> GetZaaktypenAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Calls the Domain Service's <c>POST /registrations</c>.</summary>
|
/// <summary>Calls the Domain Service's <c>POST /registrations</c>.</summary>
|
||||||
public sealed class DomainClient(HttpClient http) : IDomainClient
|
public sealed class DomainClient(HttpClient http) : IDomainClient
|
||||||
{
|
{
|
||||||
@@ -121,3 +134,11 @@ public sealed class ProjectionClient(HttpClient http) : IProjectionClient
|
|||||||
public async Task<IReadOnlyList<ProjectionEntry>> GetRegisterAsync(CancellationToken ct = default)
|
public async Task<IReadOnlyList<ProjectionEntry>> GetRegisterAsync(CancellationToken ct = default)
|
||||||
=> await http.GetFromJsonAsync<List<ProjectionEntry>>("register", ct) ?? [];
|
=> await http.GetFromJsonAsync<List<ProjectionEntry>>("register", ct) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Calls the ACL's <c>GET /catalogi/zaaktypen</c> (S-15a). The ACL also returns each zaaktype's
|
||||||
|
/// ZGW URL; deserializing into <see cref="BeheerZaaktype"/> keeps only the public-safe fields.</summary>
|
||||||
|
public sealed class AclClient(HttpClient http) : IAclClient
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<BeheerZaaktype>> GetZaaktypenAsync(CancellationToken ct = default)
|
||||||
|
=> await http.GetFromJsonAsync<List<BeheerZaaktype>>("catalogi/zaaktypen", ct) ?? [];
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ var domainBaseUrl = builder.Configuration["Downstream:Domain:BaseUrl"]
|
|||||||
?? throw new InvalidOperationException("Missing configuration 'Downstream:Domain:BaseUrl'");
|
?? throw new InvalidOperationException("Missing configuration 'Downstream:Domain:BaseUrl'");
|
||||||
var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"]
|
var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"]
|
||||||
?? throw new InvalidOperationException("Missing configuration 'Downstream:Projection:BaseUrl'");
|
?? throw new InvalidOperationException("Missing configuration 'Downstream:Projection:BaseUrl'");
|
||||||
|
// The beheer portal's read-only catalogus view reaches the ACL directly (ADR-0025): the catalogus is
|
||||||
|
// not a domain concern, and only the ACL may read the ZGW Catalogi API (§8.1).
|
||||||
|
var aclBaseUrl = builder.Configuration["Downstream:Acl:BaseUrl"]
|
||||||
|
?? throw new InvalidOperationException("Missing configuration 'Downstream:Acl:BaseUrl'");
|
||||||
|
|
||||||
// Validate Keycloak-issued tokens (ADR-0010). Audience validation is off for the walking skeleton —
|
// Validate Keycloak-issued tokens (ADR-0010). Audience validation is off for the walking skeleton —
|
||||||
// Keycloak's audience mapping is a later hardening; signature/issuer/expiry are validated.
|
// Keycloak's audience mapping is a later hardening; signature/issuer/expiry are validated.
|
||||||
@@ -67,14 +71,24 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
builder.Services.AddAuthorization(options =>
|
builder.Services.AddAuthorization(options =>
|
||||||
|
{
|
||||||
options.AddPolicy(BehandelAuth.Policy, policy => policy
|
options.AddPolicy(BehandelAuth.Policy, policy => policy
|
||||||
.AddAuthenticationSchemes(BehandelAuth.Scheme)
|
.AddAuthenticationSchemes(BehandelAuth.Scheme)
|
||||||
.RequireAuthenticatedUser()
|
.RequireAuthenticatedUser()
|
||||||
.RequireRole(BehandelAuth.BehandelaarRole)));
|
.RequireRole(BehandelAuth.BehandelaarRole));
|
||||||
|
// Beheer endpoints reuse the medewerker scheme (same realm, same realm-role lifting) but require the
|
||||||
|
// beheerder role rather than behandelaar (S-15a).
|
||||||
|
options.AddPolicy(BeheerAuth.Policy, policy => policy
|
||||||
|
.AddAuthenticationSchemes(BehandelAuth.Scheme)
|
||||||
|
.RequireAuthenticatedUser()
|
||||||
|
.RequireRole(BeheerAuth.BeheerderRole));
|
||||||
|
});
|
||||||
|
|
||||||
// The BFF is the portals' only backend; it fans out to the domain and projection (§8.3).
|
// The BFF is the portals' only backend; it fans out to the domain and projection (§8.3), and reaches
|
||||||
|
// the ACL for the beheer catalogus read (ADR-0025).
|
||||||
builder.Services.AddHttpClient<IDomainClient, DomainClient>(c => c.BaseAddress = new Uri(domainBaseUrl));
|
builder.Services.AddHttpClient<IDomainClient, DomainClient>(c => c.BaseAddress = new Uri(domainBaseUrl));
|
||||||
builder.Services.AddHttpClient<IProjectionClient, ProjectionClient>(c => c.BaseAddress = new Uri(projectionBaseUrl));
|
builder.Services.AddHttpClient<IProjectionClient, ProjectionClient>(c => c.BaseAddress = new Uri(projectionBaseUrl));
|
||||||
|
builder.Services.AddHttpClient<IAclClient, AclClient>(c => c.BaseAddress = new Uri(aclBaseUrl));
|
||||||
|
|
||||||
builder.Services.AddHealthChecks();
|
builder.Services.AddHealthChecks();
|
||||||
// Clear the auto-populated `servers` block so the committed spec is stable regardless of the host
|
// Clear the auto-populated `servers` block so the committed spec is stable regardless of the host
|
||||||
@@ -205,6 +219,15 @@ app.MapPost("/behandel/registrations/{id}/decide",
|
|||||||
.Produces(StatusCodes.Status401Unauthorized)
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
.Produces(StatusCodes.Status403Forbidden);
|
.Produces(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
|
// Beheer catalogus viewer (S-15a): the published zaaktypen, read-only. Reached only with a medewerker-
|
||||||
|
// realm token carrying the beheerder role; the BFF proxies the ACL's read (ADR-0025). Public-safe.
|
||||||
|
app.MapGet("/beheer/catalogi/zaaktypen", async (IAclClient acl, CancellationToken ct) =>
|
||||||
|
Results.Ok(await acl.GetZaaktypenAsync(ct)))
|
||||||
|
.RequireAuthorization(BeheerAuth.Policy)
|
||||||
|
.Produces<IReadOnlyList<BeheerZaaktype>>(StatusCodes.Status200OK)
|
||||||
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
/// <summary>The behandelaar's decision on a registration.</summary>
|
/// <summary>The behandelaar's decision on a registration.</summary>
|
||||||
@@ -257,5 +280,13 @@ internal static class BehandelAuth
|
|||||||
private sealed record RealmAccess([property: JsonPropertyName("roles")] string[] Roles);
|
private sealed record RealmAccess([property: JsonPropertyName("roles")] string[] Roles);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Beheer (medewerker-realm) authorization wiring (S-15a). Reuses the "medewerker" bearer scheme
|
||||||
|
// (BehandelAuth.Scheme) and its realm-role lifting; only the required role differs.
|
||||||
|
internal static class BeheerAuth
|
||||||
|
{
|
||||||
|
public const string Policy = "beheerder";
|
||||||
|
public const string BeheerderRole = "beheerder";
|
||||||
|
}
|
||||||
|
|
||||||
// Exposed so the test host (WebApplicationFactory<Program>) can boot the app.
|
// Exposed so the test host (WebApplicationFactory<Program>) can boot the app.
|
||||||
public partial class Program;
|
public partial class Program;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
},
|
},
|
||||||
"Downstream": {
|
"Downstream": {
|
||||||
"Domain": { "BaseUrl": "http://localhost:8130/" },
|
"Domain": { "BaseUrl": "http://localhost:8130/" },
|
||||||
"Projection": { "BaseUrl": "http://localhost:8120/" }
|
"Projection": { "BaseUrl": "http://localhost:8120/" },
|
||||||
|
"Acl": { "BaseUrl": "http://localhost:8100/" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Bff.Api;
|
||||||
|
|
||||||
|
namespace Bff.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The beheer catalogus viewer (S-15a): reached only with a medewerker-realm token carrying the
|
||||||
|
/// <c>beheerder</c> role. A missing token is 401; an authenticated medewerker without the role (e.g.
|
||||||
|
/// a plain behandelaar) is 403; a beheerder gets the read-only list of published zaaktypen.
|
||||||
|
/// </summary>
|
||||||
|
public class BeheerEndpointTests
|
||||||
|
{
|
||||||
|
private static HttpRequestMessage Zaaktypen(string? bearer)
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, "/beheer/catalogi/zaaktypen");
|
||||||
|
if (bearer is not null)
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Rejects_the_catalogus_without_a_token()
|
||||||
|
{
|
||||||
|
using var factory = new BffFactory();
|
||||||
|
|
||||||
|
var response = await factory.CreateClient().SendAsync(Zaaktypen(bearer: null));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Rejects_a_medewerker_without_the_beheerder_role()
|
||||||
|
{
|
||||||
|
using var factory = new BffFactory();
|
||||||
|
|
||||||
|
var response = await factory.CreateClient().SendAsync(Zaaktypen(TestTokens.Medewerker("behandelaar")));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Serves_the_published_zaaktypen_to_a_beheerder()
|
||||||
|
{
|
||||||
|
using var factory = new BffFactory();
|
||||||
|
factory.Acl.Zaaktypen.Add(new BeheerZaaktype("BIG-REGISTRATIE", "BIG-registratie"));
|
||||||
|
|
||||||
|
var response = await factory.CreateClient().SendAsync(Zaaktypen(TestTokens.Medewerker("beheerder")));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var items = await response.Content.ReadFromJsonAsync<List<BeheerZaaktype>>();
|
||||||
|
var item = Assert.Single(items!);
|
||||||
|
Assert.Equal("BIG-REGISTRATIE", item.Identificatie);
|
||||||
|
Assert.Equal("BIG-registratie", item.Omschrijving);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ internal sealed class BffFactory : WebApplicationFactory<Program>
|
|||||||
|
|
||||||
public FakeDomainClient Domain { get; } = new();
|
public FakeDomainClient Domain { get; } = new();
|
||||||
public FakeProjectionClient Projection { get; } = new();
|
public FakeProjectionClient Projection { get; } = new();
|
||||||
|
public FakeAclClient Acl { get; } = new();
|
||||||
|
|
||||||
private static void ValidateWithTestKey(IServiceCollection services, string scheme) =>
|
private static void ValidateWithTestKey(IServiceCollection services, string scheme) =>
|
||||||
services.Configure<JwtBearerOptions>(scheme, options =>
|
services.Configure<JwtBearerOptions>(scheme, options =>
|
||||||
@@ -54,11 +55,13 @@ internal sealed class BffFactory : WebApplicationFactory<Program>
|
|||||||
builder.UseSetting("Keycloak:MedewerkerAuthority", "https://keycloak.invalid/realms/medewerker");
|
builder.UseSetting("Keycloak:MedewerkerAuthority", "https://keycloak.invalid/realms/medewerker");
|
||||||
builder.UseSetting("Downstream:Domain:BaseUrl", "http://domain.invalid/");
|
builder.UseSetting("Downstream:Domain:BaseUrl", "http://domain.invalid/");
|
||||||
builder.UseSetting("Downstream:Projection:BaseUrl", "http://projection.invalid/");
|
builder.UseSetting("Downstream:Projection:BaseUrl", "http://projection.invalid/");
|
||||||
|
builder.UseSetting("Downstream:Acl:BaseUrl", "http://acl.invalid/");
|
||||||
|
|
||||||
builder.ConfigureTestServices(services =>
|
builder.ConfigureTestServices(services =>
|
||||||
{
|
{
|
||||||
services.AddSingleton<IDomainClient>(Domain);
|
services.AddSingleton<IDomainClient>(Domain);
|
||||||
services.AddSingleton<IProjectionClient>(Projection);
|
services.AddSingleton<IProjectionClient>(Projection);
|
||||||
|
services.AddSingleton<IAclClient>(Acl);
|
||||||
|
|
||||||
// Both realms validate locally against the test key (no live Keycloak). The medewerker
|
// Both realms validate locally against the test key (no live Keycloak). The medewerker
|
||||||
// scheme keeps its OnTokenValidated role-lifting from Program.cs — only the validation
|
// scheme keeps its OnTokenValidated role-lifting from Program.cs — only the validation
|
||||||
@@ -138,3 +141,12 @@ internal sealed class FakeProjectionClient : IProjectionClient
|
|||||||
public Task<IReadOnlyList<ProjectionEntry>> GetRegisterAsync(CancellationToken ct = default)
|
public Task<IReadOnlyList<ProjectionEntry>> GetRegisterAsync(CancellationToken ct = default)
|
||||||
=> Task.FromResult<IReadOnlyList<ProjectionEntry>>(Entries);
|
=> Task.FromResult<IReadOnlyList<ProjectionEntry>>(Entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Serves a configurable set of catalogus zaaktypen (beheer viewer, S-15a).</summary>
|
||||||
|
internal sealed class FakeAclClient : IAclClient
|
||||||
|
{
|
||||||
|
public List<BeheerZaaktype> Zaaktypen { get; } = [];
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<BeheerZaaktype>> GetZaaktypenAsync(CancellationToken ct = default)
|
||||||
|
=> Task.FromResult<IReadOnlyList<BeheerZaaktype>>(Zaaktypen);
|
||||||
|
}
|
||||||
|
|||||||
@@ -227,10 +227,53 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/beheer/catalogi/zaaktypen": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Bff.Api"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/BeheerZaaktype"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
"schemas": {
|
"schemas": {
|
||||||
|
"BeheerZaaktype": {
|
||||||
|
"required": [
|
||||||
|
"identificatie",
|
||||||
|
"omschrijving"
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"identificatie": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"omschrijving": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"CurrentRegistration": {
|
"CurrentRegistration": {
|
||||||
"required": [
|
"required": [
|
||||||
"registrationId",
|
"registrationId",
|
||||||
@@ -343,4 +386,4 @@
|
|||||||
"name": "Bff.Api"
|
"name": "Bff.Api"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -48,4 +48,8 @@ public sealed class InMemoryZaakGateway : IZaakGateway
|
|||||||
|
|
||||||
public Task<Uri> ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default)
|
public Task<Uri> ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default)
|
||||||
=> Task.FromResult(ResolvedInformatieobjecttypeUrl);
|
=> Task.FromResult(ResolvedInformatieobjecttypeUrl);
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<ZaaktypeSummary>> ListZaaktypenAsync(CancellationToken ct = default)
|
||||||
|
=> Task.FromResult<IReadOnlyList<ZaaktypeSummary>>(
|
||||||
|
[new ZaaktypeSummary("BIG-REGISTRATIE", "BIG-registratie", ResolvedZaaktypeUrl)]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
// S-15a walking skeleton: a beheerder logs in to the beheer portal (medewerker realm) and sees the
|
||||||
|
// read-only ZTC catalogus. The verify stack seeds and publishes the BIG-REGISTRATIE zaaktype (the
|
||||||
|
// same one verify-domain relies on), so it must appear in the catalogus. Runs against the shared
|
||||||
|
// verify stack, so it asserts on that stable seeded zaaktype rather than anything test-specific.
|
||||||
|
test('a beheerder sees the published zaaktypen in the catalogus', async ({ page }) => {
|
||||||
|
await page.goto('http://beheer/');
|
||||||
|
|
||||||
|
// The beheer portal redirects to the Keycloak medewerker realm login (same realm as behandel).
|
||||||
|
await page.locator('#username').fill('bram-beheerder');
|
||||||
|
await page.locator('#password').fill('test123');
|
||||||
|
await page.locator('#kc-login').click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: /Catalogus/i })).toBeVisible();
|
||||||
|
|
||||||
|
// The seeded, published BIG zaaktype is shown by its business identificatie. Match the cell
|
||||||
|
// exactly (case-sensitive): getByText is case-insensitive, so it would also match the omschrijving
|
||||||
|
// cell "BIG-registratie" and trip strict mode.
|
||||||
|
await expect(page.getByRole('cell', { name: 'BIG-REGISTRATIE', exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
@@ -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);
|
// 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).
|
// 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';
|
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({
|
export default defineConfig({
|
||||||
testDir: '.',
|
testDir: '.',
|
||||||
@@ -33,7 +36,7 @@ export default defineConfig({
|
|||||||
channel: 'chromium',
|
channel: 'chromium',
|
||||||
launchOptions: {
|
launchOptions: {
|
||||||
args: [
|
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
|
// 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
|
// large DOM/heap can't crash the renderer on the memory-constrained runner (belt-and-braces
|
||||||
// alongside the single worker above).
|
// alongside the single worker above).
|
||||||
|
|||||||
Reference in New Issue
Block a user