Compare commits

..

7 Commits

Author SHA1 Message Date
94a506cbfc fix(workflow): read task query variables from 'variables', not 'processVariables' (refs #13)
All checks were successful
CI / lint (pull_request) Successful in 1m28s
CI / build (pull_request) Successful in 1m8s
CI / unit (pull_request) Successful in 1m34s
CI / frontend (pull_request) Successful in 2m24s
CI / mutation (pull_request) Successful in 6m3s
CI / verify-stack (pull_request) Successful in 7m43s
Flowable's POST query/tasks returns the included process variables under the key
'variables' (the request opts in via includeProcessVariables). The client DTO, its
unit test, and the live-check parser all looked for 'processVariables', so the
werkbak never matched a task's registrationId and verify-domain timed out. Verified
by driving a real Flowable instance end-to-end locally: start -> complete external
job -> the Beoordelen task carries registrationId under 'variables'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:29:43 +02:00
c53354cd22 fix(workflow): query Flowable tasks at service/query/tasks, not runtime/tasks/query (refs #13)
Some checks failed
CI / lint (pull_request) Successful in 1m17s
CI / build (pull_request) Successful in 1m14s
CI / unit (pull_request) Successful in 1m17s
CI / frontend (pull_request) Successful in 2m41s
CI / mutation (pull_request) Successful in 5m9s
CI / verify-stack (pull_request) Failing after 7m53s
The task-query endpoint is service/query/tasks; the wrong path 404'd, so verify-domain's
werkbak poll got an empty body and the JSON parser aborted the check. Correct the path
in the Workflow Client (and its unit test) and make the live-check parser tolerant of a
transient empty/non-JSON body so the poll retries instead of crashing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:57:11 +02:00
57b8755f9d test(workflow): cover the besluit variable type + start empty-body throw (refs #13)
Some checks failed
CI / lint (pull_request) Successful in 1m14s
CI / build (pull_request) Successful in 58s
CI / unit (pull_request) Successful in 1m5s
CI / frontend (pull_request) Successful in 2m12s
CI / mutation (pull_request) Successful in 4m59s
CI / verify-stack (pull_request) Failing after 6m39s
Kills two mutation survivors: assert the complete besluit variable's type, and add
the missing start empty-response case (the pre-existing baseline survivor). Domain
mutation score reaches 100%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:24:53 +02:00
3d561deb05 test(workflow): verify-domain exercises the Beoordelen user task end-to-end (refs #13)
After the worker opens the zaak, the process parks at Beoordelen. The live check now
polls Flowable for the task (werkbak), claims it as merel-behandelaar, completes it
with besluit=goedkeuren, and asserts the process finishes — proving the exact REST
contract (query/claim/complete) the Workflow Client depends on against a real engine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:21:35 +02:00
5db55eebf4 feat(workflow): add the Beoordelen behandelaar user task to registratie.bpmn (refs #13)
After OpenZaakAanmaken the process now parks at a Beoordelen user task (candidate
group behandelaar) until the behandelaar claims and completes it. registrationId
rides along as a process variable so the werkbak correlates each task to its
aggregate. The walking skeleton is unaffected: the temporary /approve path sets the
zaak status directly; wiring the decision to complete this task lands in S-12c/d.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:19:19 +02:00
efd1c06b99 feat(workflow): user-task client for beoordeling — werkbak, claim, complete (refs #13)
Adds the IUserTaskClient port and implements it on the Workflow Client (the only code
that talks to Flowable, §8.2): query open Beoordelen tasks (werkbak) with their
registrationId, claim a task for a behandelaar, and complete it carrying the besluit
into the process. Registered in DI for later wiring (S-12c).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:18:03 +02:00
57cc3637c9 test(workflow): user-task client — werkbak query, claim, complete beoordeling (refs #13)
Red — GetOpenBeoordelingenAsync/ClaimAsync/CompleteBeoordelingAsync do not exist yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:16:46 +02:00
59 changed files with 59 additions and 1867 deletions

View File

@@ -157,7 +157,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 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 2>&1 || true
- name: Tear down
if: always()
run: make down

View File

@@ -10,7 +10,7 @@ COMPOSE := infra/docker-compose.yml
# Long-running services with a healthcheck — the smoke polls these for readiness
# (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init)
# are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar
# 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

View File

@@ -1,23 +0,0 @@
# Multi-stage build for the behandel 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/behandel apps/behandel
COPY libs libs
RUN pnpm nx build behandel
FROM nginx:1.27-alpine AS runtime
COPY apps/behandel/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist/apps/behandel/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
EXPOSE 80

View File

@@ -1,34 +0,0 @@
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: {},
},
];

View File

@@ -1,24 +0,0 @@
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 behandel 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 /behandel/ {
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;
}
}

View File

@@ -1,80 +0,0 @@
{
"name": "behandel",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/behandel/src",
"tags": [],
"targets": {
"build": {
"executor": "@angular/build:application",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"outputPath": "dist/apps/behandel",
"browser": "apps/behandel/src/main.ts",
"tsConfig": "apps/behandel/tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "apps/behandel/public"
}
],
"styles": ["apps/behandel/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": "behandel:build:production"
},
"development": {
"buildTarget": "behandel: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": "behandel:build",
"staticFilePath": "dist/apps/behandel/browser",
"spa": true
}
}
}
}

View File

@@ -1,3 +0,0 @@
{
"authority": "http://localhost:8180/realms/medewerker"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,73 +0,0 @@
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 behandel 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('behandel 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 werkbak call', () => {
bff.getBehandelWerkbak().subscribe();
const req = http.expectOne('/behandel/werkbak');
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
req.flush([]);
});
it('attaches the bearer token to the relative decide call', () => {
bff.postBehandelRegistrationsIdDecide('reg-1', { besluit: 'goedkeuren' }).subscribe();
const req = http.expectOne('/behandel/registrations/reg-1/decide');
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
req.flush(null);
});
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([]);
});
});

View File

@@ -1,39 +0,0 @@
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 `/behandel/` is secured; the app calls no other endpoint group.
*/
export const SECURE_API_ROUTES = ['/behandel/'];
/**
* Build the app providers from runtime config. `redirectUrl` is the app's own origin (where Keycloak
* redirects back). `secureRoutes` uses {@link SECURE_API_ROUTES} — relative prefixes, not the origin.
*/
export function appConfig(runtime: RuntimeConfig): ApplicationConfig {
const origin = typeof window !== 'undefined' ? window.location.origin : '/';
return {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(appRoutes),
provideHttpClient(withInterceptors([authInterceptor()])),
provideMedewerkerAuth({
authority: runtime.authority,
redirectUrl: origin,
secureRoutes: SECURE_API_ROUTES,
}),
],
};
}

View File

@@ -1 +0,0 @@
<router-outlet></router-outlet>

View File

@@ -1,7 +0,0 @@
import { Route } from '@angular/router';
import { authenticatedGuard } from 'auth';
import { WerkbakPage } from './werkbak/werkbak-page';
export const appRoutes: Route[] = [
{ path: '', component: WerkbakPage, canActivate: [authenticatedGuard] },
];

View File

@@ -1,15 +0,0 @@
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 WerkbakPage owns the heading).
expect(container.querySelector('router-outlet')).toBeTruthy();
expect(screen).toBeTruthy();
});
});

View File

@@ -1,12 +0,0 @@
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 = 'behandel';
}

View File

@@ -1,64 +0,0 @@
<main utrecht-document class="utrecht-theme">
<utrecht-article>
<utrecht-heading-1>Werkbak</utrecht-heading-1>
<p utrecht-paragraph>
Registraties die wachten op beoordeling. Keur elke registratie goed of wijs deze af.
</p>
@if (loading()) {
<p utrecht-paragraph role="status">Bezig met laden…</p>
} @else if (failed()) {
<p utrecht-paragraph role="alert">
Kon de werkbak niet laden. Controleer of je als behandelaar bent ingelogd en probeer het
opnieuw.
</p>
} @else if (loaded() && items().length === 0) {
<p utrecht-paragraph role="status">De werkbak is leeg.</p>
} @else if (items().length > 0) {
<table utrecht-table>
<caption>
Registraties in behandeling
</caption>
<thead>
<tr>
<th scope="col">Referentie</th>
<th scope="col">BSN</th>
<th scope="col">Status</th>
<th scope="col">Actie</th>
</tr>
</thead>
<tbody>
@for (item of items(); track item.registrationId) {
<tr>
<td>{{ item.registrationId }}</td>
<td>{{ item.bsn }}</td>
<td>{{ item.status }}</td>
<td>
<button
utrecht-button
appearance="primary-action-button"
type="button"
[attr.aria-label]="'Goedkeuren ' + item.registrationId"
[disabled]="deciding() === item.registrationId"
(click)="decide(item.registrationId, 'goedkeuren')"
>
Goedkeuren
</button>
<button
utrecht-button
appearance="secondary-action-button"
type="button"
[attr.aria-label]="'Afwijzen ' + item.registrationId"
[disabled]="deciding() === item.registrationId"
(click)="decide(item.registrationId, 'afwijzen')"
>
Afwijzen
</button>
</td>
</tr>
}
</tbody>
</table>
}
</utrecht-article>
</main>

View File

@@ -1,110 +0,0 @@
import { signal } from '@angular/core';
import { fireEvent, render, screen } from '@testing-library/angular';
import { of, throwError } from 'rxjs';
import { BffApiV1Service, type WerkbakItem } from 'api-client';
import { AuthService } from 'auth';
import { axe } from 'vitest-axe';
import { WerkbakPage } from './werkbak-page';
const sample: WerkbakItem[] = [
{ registrationId: 'reg-1', bsn: '123456782', status: 'InBehandeling' },
{ registrationId: 'reg-2', bsn: '111222333', status: 'InBehandeling' },
];
class FakeAuth extends AuthService {
readonly isAuthenticated = signal(true);
readonly bsn = signal<string | undefined>(undefined);
override readonly roles = signal<readonly string[]>(['behandelaar']);
login(): void {
/* not exercised here */
}
logout(): void {
/* spied in tests */
}
}
function setup(
overrides: {
getBehandelWerkbak?: ReturnType<typeof vi.fn>;
postBehandelRegistrationsIdDecide?: ReturnType<typeof vi.fn>;
} = {},
) {
const getBehandelWerkbak =
overrides.getBehandelWerkbak ?? vi.fn().mockReturnValue(of(sample));
const postBehandelRegistrationsIdDecide =
overrides.postBehandelRegistrationsIdDecide ?? vi.fn().mockReturnValue(of(undefined));
return {
getBehandelWerkbak,
postBehandelRegistrationsIdDecide,
providers: [
{
provide: BffApiV1Service,
useValue: { getBehandelWerkbak, postBehandelRegistrationsIdDecide },
},
{ provide: AuthService, useClass: FakeAuth },
],
};
}
describe('WerkbakPage', () => {
it('lists the registrations awaiting beoordeling on open', async () => {
const { getBehandelWerkbak, providers } = setup();
await render(WerkbakPage, { providers });
expect(getBehandelWerkbak).toHaveBeenCalled();
expect(await screen.findByText('reg-1')).toBeTruthy();
expect(screen.getByText('123456782')).toBeTruthy();
expect(screen.getByText('reg-2')).toBeTruthy();
});
it('approves a registration (goedkeuren) and refreshes the werkbak', async () => {
const { getBehandelWerkbak, postBehandelRegistrationsIdDecide, providers } = setup();
await render(WerkbakPage, { providers });
fireEvent.click((await screen.findAllByRole('button', { name: /goedkeuren/i }))[0]);
expect(postBehandelRegistrationsIdDecide).toHaveBeenCalledWith('reg-1', {
besluit: 'goedkeuren',
});
// Reloaded after the decision: once on open, once after deciding.
expect(getBehandelWerkbak).toHaveBeenCalledTimes(2);
});
it('rejects a registration (afwijzen) via the decide endpoint', async () => {
const { postBehandelRegistrationsIdDecide, providers } = setup();
await render(WerkbakPage, { providers });
fireEvent.click((await screen.findAllByRole('button', { name: /afwijzen/i }))[0]);
expect(postBehandelRegistrationsIdDecide).toHaveBeenCalledWith('reg-1', {
besluit: 'afwijzen',
});
});
it('shows an empty state when the werkbak has no items', async () => {
const { providers } = setup({ getBehandelWerkbak: vi.fn().mockReturnValue(of([])) });
await render(WerkbakPage, { providers });
expect(await screen.findByText(/werkbak is leeg/i)).toBeTruthy();
});
it('surfaces a load failure instead of swallowing it', async () => {
const { providers } = setup({
getBehandelWerkbak: vi.fn().mockReturnValue(throwError(() => new Error('403'))),
});
await render(WerkbakPage, { providers });
expect(await screen.findByText(/kon de werkbak niet laden/i)).toBeTruthy();
});
it('has no WCAG 2.1 AA violations', async () => {
document.documentElement.lang = 'nl';
const { container } = await render(WerkbakPage, { providers: setup().providers });
const results = await axe(container, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
});
expect(results.violations).toEqual([]);
});
});

View File

@@ -1,65 +0,0 @@
import { Component, inject, signal } from '@angular/core';
import { BffApiV1Service, type WerkbakItem } from 'api-client';
import { UtrechtComponentsModule } from 'ui';
/** The two decisions a behandelaar can make; the BFF validates these exact values (ADR-0013). */
type Besluit = 'goedkeuren' | 'afwijzen';
/**
* The behandel werkbak: a signed-in behandelaar sees the registrations awaiting beoordeling (the open
* Flowable `Beoordelen` tasks, read through the domain) and decides each — goedkeuren or afwijzen. A
* decision posts to the BFF, which applies the domain transition and completes the workflow task
* (ADR-0013; S-12). After a decision the werkbak refreshes so the handled item drops off the list.
*/
@Component({
selector: 'app-werkbak-page',
imports: [UtrechtComponentsModule],
templateUrl: './werkbak-page.html',
})
export class WerkbakPage {
private readonly bff = inject(BffApiV1Service);
protected readonly items = signal<WerkbakItem[]>([]);
protected readonly loading = signal(false);
protected readonly loaded = signal(false);
protected readonly failed = signal(false);
protected readonly deciding = signal<string | undefined>(undefined);
constructor() {
this.load();
}
load(): void {
this.loading.set(true);
this.failed.set(false);
this.bff.getBehandelWerkbak().subscribe({
next: (rows: WerkbakItem[]) => {
this.items.set(rows);
this.loading.set(false);
this.loaded.set(true);
},
// Surface the failure (e.g. 403 for a non-behandelaar) instead of swallowing it.
error: () => {
this.items.set([]);
this.loading.set(false);
this.loaded.set(true);
this.failed.set(true);
},
});
}
decide(registrationId: string, besluit: Besluit): void {
this.deciding.set(registrationId);
this.bff.postBehandelRegistrationsIdDecide(registrationId, { besluit }).subscribe({
// Refresh so the decided registration drops off the werkbak (its task is now completed).
next: () => {
this.deciding.set(undefined);
this.load();
},
error: () => {
this.deciding.set(undefined);
this.failed.set(true);
},
});
}
}

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<title>Behandelportaal 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>

View File

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

View File

@@ -1,2 +0,0 @@
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
@import '@utrecht/design-tokens/dist/index.css';

View File

@@ -1,9 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}

View File

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

View File

@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}

View File

@@ -1,76 +0,0 @@
# ADR-0013: Behandel-portal wiring — multi-realm BFF auth, werkbak from Flowable tasks, decision completes the task
- **Status:** Accepted
- **Date:** 2026-07-15
- **Deciders:** Respellion engineering
- **Relates to:** #84 (adr-proposal), S-12 (#13); builds on ADR-0010 (BFF OIDC), ADR-0011 (approval status flow), ADR-0009 (external-task worker), ADR-0008 (read projection)
## Context
S-12 adds the behandel-portal: a behandelaar logs in, sees a **werkbak** of registrations awaiting
beoordeling, and decides each (goedkeuren/afwijzen). Three questions had no obvious answer and shape
the whole slice.
1. **Which realm authenticates behandelaars, and how does the BFF accept it?** Citizens use the
`digid` realm (ADR-0010); staff use a separate `medewerker` realm with roles (`behandelaar`,
`teamlead`). Keycloak realms are distinct issuers with distinct signing keys, so the BFF's single
`digid`-realm JWT validation rejects a medewerker token outright.
2. **Where does the werkbak get its data?** The registrations awaiting beoordeling could come from
the read projection (status-filtered rows) or from the Flowable `Beoordelen` user tasks (S-12b).
3. **How does a decision correlate to the workflow?** The process parks at the `Beoordelen` user
task; the decision must advance it, and also apply the domain transition (ADR-0011).
## Decision
**The BFF validates a second realm for behandel endpoints; the werkbak is the set of open Flowable
`Beoordelen` tasks (read through the domain); and a decision both applies the domain transition and
completes the Flowable task.**
- **Multi-realm BFF auth.** The BFF registers a second JWT bearer scheme (`medewerker`, authority =
the medewerker realm) alongside the default `digid` scheme. `/behandel/*` endpoints require an
authorization policy bound to the `medewerker` scheme **and** the `behandelaar` role. Keycloak puts
realm roles in the nested `realm_access.roles` claim, which ASP.NET does not map automatically, so
the scheme's `OnTokenValidated` lifts those roles onto the principal as role claims. Self-service
keeps the `digid` scheme. Audience validation stays off (ADR-0010's deferred hardening).
- **Werkbak = Flowable user tasks (via the domain).** The domain's `Werkbak` query reads the open
`Beoordelen` tasks from the Workflow Client (§8.2, `IUserTaskClient`) and enriches each with its
aggregate's bsn + status; `GET /behandel/werkbak` exposes it and the BFF proxies it behind the
behandelaar policy. The list **is** the authoritative set of claimable/decidable work items, so a
decision acts on a real task with no separate correlation store. The read projection stays the
anonymous openbaar model — we do **not** project `IN_BEHANDELING` or populate staff-only personal
data (both deferred in ADR-0008) just to render a staff view.
- **Decision completes the task (S-12c-2).** A behandelaar decision applies the domain transition
(aggregate + ACL for approval, per ADR-0011) **and** completes the Flowable `Beoordelen` task
(looked up by registrationId), so the process advances. Implemented in the next sub-slice; recorded
here so the boundary is decided up front.
Delivery is split: **S-12c-1** (this PR) = multi-realm auth + werkbak read; **S-12c-2** = the decide
endpoint + task completion.
## Consequences
**Positive**
- Staff and citizens are cleanly separated by realm; the `behandelaar` role gates the behandel API.
- The werkbak reflects exactly what a behandelaar can act on; claim/decide need no extra correlation.
- No premature projection changes — the openbaar read model stays focused and personal-data-free.
- Only the ACL/Workflow Client talk to their peers; the BFF still fans out only to domain/projection
(§8.3).
**Negative / costs**
- The BFF now depends on two Keycloak realms being reachable (`Keycloak:MedewerkerAuthority`).
- Rendering the werkbak fans out to Flowable (one task query) plus a store read per task — acceptable
for the caseload sizes here; a denormalized staff read model is an additive follow-up if needed.
- Realm separation (distinct issuers/keys) is validated live, not in the BFF unit tests, where issuer
validation is off and one test key signs both realms; the tests exercise the role-based authorization.
## Alternatives considered
- **Werkbak from the read projection** — rejected for now: needs new plumbing to project
`IN_BEHANDELING` and to populate staff-only bsn/naam (deferred, ADR-0008), plus a separate way to
find the Flowable task at decide-time. Revisit if a high-volume denormalized staff view is needed.
- **One JWT scheme accepting both realms (issuer validation off)** — rejected: trusting multiple
issuers without validation is a security regression; two schemes keep each realm's issuer/key checked.
- **A dedicated behandel BFF/service** — rejected as premature; one BFF with per-endpoint policies is
enough at this size and keeps §8.3 simple.

View File

@@ -248,30 +248,3 @@ curl -fsS "http://localhost:8140/openbaar/register?q=$ref" | jq
> The openbaar register's "Referentie" column and its search now use this reference — the exact value
> the citizen saw on submit. Asserted end-to-end by the Playwright happy path.
## S-12 — Behandel portal: werkbak + beoordeling (#13, ADR-0013)
A behandelaar now works submitted registrations in a real portal instead of the temporary admin
endpoint. After a citizen submits (as above), the workflow parks the registration at the Flowable
`Beoordelen` user task, and it shows up in the **werkbak**. The behandelaar logs in against the
Keycloak `medewerker` realm and decides — **goedkeuren** (→ INGESCHREVEN via the ACL, per ADR-0011)
or **afwijzen** — which also completes the Beoordelen task so the process advances.
```text
# 1. Open the behandel portal and log in as a behandelaar (medewerker realm):
# http://localhost:8142/ → merel-behandelaar / test123
#
# 2. The werkbak lists the registrations awaiting beoordeling (referentie / bsn / status).
# Find the reference from the submit confirmation and click "Goedkeuren" on that row.
#
# 3. The row drops off the werkbak (its Beoordelen task is completed) and the openbaar register
# (http://localhost:8141/) now shows that reference as INGESCHREVEN.
```
**The path:** behandel portal → BFF `POST /behandel/registrations/{id}/decide` (behandelaar policy,
`medewerker` realm) → domain applies the decision + completes the Flowable `Beoordelen` task →
ACL → NRC → event-subscriber → projection → openbaar register shows INGESCHREVEN.
> The full round-trip — DigiD submit → public INGEDIEND → behandelaar goedkeurt in the werkbak →
> public INGESCHREVEN — is the Playwright happy path (`tests/e2e/registration.spec.ts`), which now
> drives the behandel portal in place of the old admin endpoint.

View File

@@ -117,37 +117,3 @@ with the submit form (S-08c, #67); any deviation from NL DS will be recorded her
(id + status); `bsn`/`naam` never leave the BFF. The e2e asserts the bsn never renders.
- **Loads on open, filters on search.** `RegisterPage` fetches the full register on construction and
re-queries `/openbaar/register?q=` on search — no client-side filtering, the BFF owns the query.
## Behandel portal (S-12, #13)
The staff portal where a behandelaar works the **werkbak** (registrations awaiting beoordeling) and
decides each — goedkeuren or afwijzen. `apps/behandel` mirrors `apps/self-service`; the net-new
frontend work is the medewerker realm auth and the werkbak/decide page. Wiring rationale is in
**ADR-0013**; this entry records the frontend-specific choices.
- **Medewerker realm auth, reusing `libs/auth`.** Staff authenticate against the Keycloak
`medewerker` realm (public client `big-portal`), not `digid`. Rather than fork the auth lib, the
abstract `AuthService` grew a **`roles`/`hasRole` surface** (empty for realms without roles, e.g.
`digid`), and a parallel **`MedewerkerAuthService` + `provideMedewerkerAuth`** were added — same
auth-code + PKCE config, bound to the medewerker realm, reading the nested `realm_access.roles`
claim. The library's own `authInterceptor` attaches the token to the relative `/behandel/` calls
(secure route), exactly as self-service does for `/self-service/`.
- **Roles reach the frontend via a realm mapper.** Keycloak emits realm roles in the access token by
default but not the ID token/userinfo the SPA reads, so the medewerker `big-portal` client gets a
**realm-roles protocol mapper** (`realm_access.roles`, added to id + userinfo tokens). The
**BFF remains the security boundary** (`behandelaar` policy, 401/403 on `/behandel/*`, ADR-0013);
the frontend role signal is for display/UX, and the werkbak page surfaces a load failure (e.g. a
403 for a non-behandelaar) rather than swallowing it.
- **Same-origin via nginx, like the other portals.** The compose `behandel` image serves the built
app and reverse-proxies `/behandel` to the BFF (relative calls, no CORS). Served on `:8142`,
health-checked over IPv4 (`127.0.0.1`), depends on Keycloak for the medewerker realm.
- **Werkbak = decide-and-refresh.** `WerkbakPage` loads `GET /behandel/werkbak` on open and renders a
row per registration (referentie/bsn/status). Goedkeuren/afwijzen `POST /behandel/registrations/
{id}/decide` and then reload the werkbak, so the handled item drops off (its Flowable `Beoordelen`
task is completed). Per-row decide buttons carry an `aria-label` including the reference, so the
e2e (and screen readers) can target a specific registration in a shared werkbak.
- **Testing.** Component tests use `@testing-library/angular` with `BffApiV1Service`/`AuthService`
mocked and the axe WCAG 2.1 AA check; an `app.config.spec` drives the real interceptor + api-client
to assert the medewerker token attaches to `/behandel/*` (and not to the anonymous openbaar call).
The full DigiD-submit → behandel-decide → public INGESCHREVEN round-trip is the Playwright happy
path.

View File

@@ -349,8 +349,6 @@ services:
# Keycloak (start-dev) derives the issuer from the request host, so the BFF authority and the
# verify token request both use keycloak:8080 to keep the issuer consistent.
Keycloak__Authority: http://keycloak:8080/realms/digid
# Behandelaars authenticate against the medewerker realm; the BFF validates it for /behandel/* (S-12c).
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
Downstream__Domain__BaseUrl: http://domain:8080/
Downstream__Projection__BaseUrl: http://projection-api:8080/
ports:
@@ -486,29 +484,6 @@ services:
condition: service_healthy
networks: [cg]
# The behandel portal: nginx serves the Angular app and reverse-proxies /behandel to the BFF.
# Behandelaars log in against the Keycloak medewerker realm (ADR-0013; S-12).
behandel:
build:
context: ..
dockerfile: apps/behandel/Dockerfile
image: register-referentie/behandel:dev
ports:
- "8142: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]
volumes:
oz-db:
nrc-db:

View File

@@ -16,22 +16,7 @@
"standardFlowEnabled": true,
"directAccessGrantsEnabled": true,
"redirectUris": ["*"],
"webOrigins": ["*"],
"protocolMappers": [
{
"name": "realm roles",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-realm-role-mapper",
"config": {
"multivalued": "true",
"claim.name": "realm_access.roles",
"jsonType.label": "String",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
}
]
"webOrigins": ["*"]
}
],
"users": [

View File

@@ -24,10 +24,6 @@ import {
Observable
} from 'rxjs';
export interface DecideRequest {
besluit: string;
}
export interface OpenbaarEntry {
id: string;
status: string;
@@ -40,12 +36,6 @@ export interface SubmitAccepted {
status: string;
}
export interface WerkbakItem {
registrationId: string;
bsn: string;
status: string;
}
export type GetOpenbaarRegisterParams = {
q?: string;
};
@@ -225,73 +215,4 @@ export class BffApiV1Service {
);
}
getBehandelWerkbak<TData = WerkbakItem[]>( options?: HttpClientBodyOptions): Observable<TData>;
getBehandelWerkbak<TData = WerkbakItem[]>( options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
getBehandelWerkbak<TData = WerkbakItem[]>( options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
getBehandelWerkbak<TData = WerkbakItem[]>(
options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
if (options?.observe === 'events') {
return this.http.get<TData>(
`/behandel/werkbak`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events',
}
);
}
if (options?.observe === 'response') {
return this.http.get<TData>(
`/behandel/werkbak`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response',
}
);
}
return this.http.get<TData>(
`/behandel/werkbak`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'body',
}
);
}
postBehandelRegistrationsIdDecide<TData = void>(id: string,
decideRequest: DecideRequest, options?: HttpClientBodyOptions): Observable<TData>;
postBehandelRegistrationsIdDecide<TData = void>(id: string,
decideRequest: DecideRequest, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
postBehandelRegistrationsIdDecide<TData = void>(id: string,
decideRequest: DecideRequest, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
postBehandelRegistrationsIdDecide<TData = void>(
id: string,
decideRequest: DecideRequest, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
if (options?.observe === 'events') {
return this.http.post<TData>(
`/behandel/registrations/${id}/decide`,
decideRequest,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events',
}
);
}
if (options?.observe === 'response') {
return this.http.post<TData>(
`/behandel/registrations/${id}/decide`,
decideRequest,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response',
}
);
}
return this.http.post<TData>(
`/behandel/registrations/${id}/decide`,
decideRequest,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'body',
}
);
}
};

View File

@@ -1,6 +1,4 @@
export * from './lib/auth.service';
export * from './lib/digid-auth.service';
export * from './lib/digid-auth.providers';
export * from './lib/medewerker-auth.service';
export * from './lib/medewerker-auth.providers';
export * from './lib/authenticated.guard';

View File

@@ -1,26 +1,16 @@
import { signal, Signal } from '@angular/core';
import { Signal } from '@angular/core';
/**
* The portal's view of the signed-in user. An abstraction over the OIDC library so components and
* guards depend on a small, mockable surface (the real implementations are DigiadAuthService for
* citizens and MedewerkerAuthService for staff).
* guards depend on a small, mockable surface (the real implementation is DigiadAuthService).
*/
export abstract class AuthService {
/** Whether a session is active. */
/** Whether a DigiD session is active. */
abstract readonly isAuthenticated: Signal<boolean>;
/** The citizen-service number from the DigiD token, once authenticated (staff have none). */
/** The citizen-service number from the DigiD token, once authenticated. */
abstract readonly bsn: Signal<string | undefined>;
/**
* The realm roles carried in the token. Empty for realms that don't grant roles (e.g. `digid`);
* the `medewerker` realm carries `behandelaar`/`teamlead`.
*/
readonly roles: Signal<readonly string[]> = signal<readonly string[]>([]);
/** Start login (redirects to Keycloak). */
/** Start the DigiD login (redirects to Keycloak). */
abstract login(): void;
/** End the session. */
abstract logout(): void;
/** Whether the signed-in user holds the given realm role. */
hasRole(role: string): boolean {
return this.roles().includes(role);
}
}

View File

@@ -1,49 +0,0 @@
import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';
import { LogLevel, provideAuth, withAppInitializerAuthCheck } from 'angular-auth-oidc-client';
import { AuthService } from './auth.service';
import { MedewerkerAuthService } from './medewerker-auth.service';
export interface MedewerkerAuthOptions {
/** The Keycloak `medewerker` realm issuer, as reachable from the browser. */
authority: string;
/** Where Keycloak redirects back to after login (usually the app origin). */
redirectUrl: string;
/**
* Route prefixes whose requests get the bearer token attached. The api-client calls the BFF with
* **relative** URLs (same-origin via the nginx proxy), so these must be relative path prefixes
* (e.g. `/behandel/`) — angular-auth-oidc-client matches `req.url.startsWith(route)`, and a
* relative `req.url` never starts with an absolute origin.
*/
secureRoutes: string[];
}
/**
* Configure medewerker login (Keycloak `medewerker` realm, public client `big-portal`, auth-code +
* PKCE) and bind {@link AuthService} to the medewerker-backed implementation. Register
* {@link authInterceptor} (re-exported from digid-auth.providers) in the app's HttpClient so BFF
* calls carry the token.
*/
export function provideMedewerkerAuth(options: MedewerkerAuthOptions): EnvironmentProviders {
return makeEnvironmentProviders([
provideAuth(
{
config: {
authority: options.authority,
redirectUrl: options.redirectUrl,
postLogoutRedirectUri: options.redirectUrl,
clientId: 'big-portal',
scope: 'openid profile',
responseType: 'code',
silentRenew: true,
useRefreshToken: true,
secureRoutes: options.secureRoutes,
logLevel: LogLevel.Warn,
},
},
// Run checkAuth() at startup so the login callback (?code=…) is processed before the router
// and guard run — without it the guard sees "not authenticated" and re-triggers login (loop).
withAppInitializerAuthCheck(),
),
{ provide: AuthService, useClass: MedewerkerAuthService },
]);
}

View File

@@ -1,43 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { OidcSecurityService } from 'angular-auth-oidc-client';
import { of } from 'rxjs';
import { MedewerkerAuthService } from './medewerker-auth.service';
function makeService(userData: unknown, authenticated = true) {
const oidc = {
isAuthenticated$: of({ isAuthenticated: authenticated }),
userData$: of({ userData }),
authorize: vi.fn(),
logoff: vi.fn(() => of(null)),
};
TestBed.configureTestingModule({
providers: [MedewerkerAuthService, { provide: OidcSecurityService, useValue: oidc }],
});
return { svc: TestBed.inject(MedewerkerAuthService), oidc };
}
describe('MedewerkerAuthService', () => {
it('exposes the realm roles carried in the token', () => {
const { svc } = makeService({ realm_access: { roles: ['behandelaar', 'teamlead'] } });
expect(svc.roles()).toEqual(['behandelaar', 'teamlead']);
expect(svc.hasRole('behandelaar')).toBe(true);
expect(svc.hasRole('beheerder')).toBe(false);
});
it('has no roles when the token omits realm_access', () => {
const { svc } = makeService({ preferred_username: 'merel-behandelaar' });
expect(svc.roles()).toEqual([]);
expect(svc.hasRole('behandelaar')).toBe(false);
});
it('reflects the OIDC authenticated state', () => {
const { svc } = makeService({}, true);
expect(svc.isAuthenticated()).toBe(true);
});
it('starts login by delegating to the OIDC library', () => {
const { svc, oidc } = makeService({});
svc.login();
expect(oidc.authorize).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,41 +0,0 @@
import { inject, Injectable, Signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { OidcSecurityService } from 'angular-auth-oidc-client';
import { map } from 'rxjs';
import { AuthService } from './auth.service';
/** The subset of the medewerker token the portal reads: Keycloak nests realm roles here. */
interface MedewerkerClaims {
realm_access?: { roles?: string[] };
}
/** Medewerker-backed AuthService over angular-auth-oidc-client (Keycloak `medewerker` realm). */
@Injectable()
export class MedewerkerAuthService extends AuthService {
private readonly oidc = inject(OidcSecurityService);
readonly isAuthenticated: Signal<boolean> = toSignal(
this.oidc.isAuthenticated$.pipe(map((result) => result.isAuthenticated)),
{ initialValue: false },
);
// Staff have no BSN; the abstract surface keeps this present for the shared guard/interceptor.
readonly bsn: Signal<string | undefined> = toSignal(this.oidc.userData$.pipe(map(() => undefined)), {
initialValue: undefined,
});
override readonly roles: Signal<readonly string[]> = toSignal(
this.oidc.userData$.pipe(
map((data) => (data.userData as MedewerkerClaims | null)?.realm_access?.roles ?? []),
),
{ initialValue: [] },
);
override login(): void {
this.oidc.authorize();
}
override logout(): void {
this.oidc.logoff().subscribe();
}
}

View File

@@ -13,20 +13,10 @@ public sealed record ProjectionEntry(string Id, string Status, string? Reference
/// <summary>A public-safe openbaar register row — only non-sensitive fields leave the BFF.</summary>
public sealed record OpenbaarEntry(string Id, string Status, string? Reference);
/// <summary>A behandelaar's werkbak row: a registration awaiting beoordeling, with the bsn + status a
/// behandelaar sees (staff view — reached only behind medewerker/behandelaar authorization, S-12c).</summary>
public sealed record WerkbakItem(string RegistrationId, string Bsn, string Status);
/// <summary>Port to the Domain Service (§8.3: the BFF is the portals' only backend; it fans out).</summary>
public interface IDomainClient
{
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default);
/// <summary>The behandelaar's werkbak — registrations awaiting beoordeling.</summary>
Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default);
/// <summary>Apply a behandelaar's decision (<c>goedkeuren</c>/<c>afwijzen</c>) to a registration.</summary>
Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default);
}
/// <summary>Port to the read projection.</summary>
@@ -47,16 +37,6 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
return new SubmitAccepted(dto.RegistrationId, dto.Status);
}
public async Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
=> await http.GetFromJsonAsync<List<WerkbakItem>>("behandel/werkbak", ct) ?? [];
public async Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default)
{
using var response = await http.PostAsJsonAsync(
$"registrations/{registrationId}/decide", new { besluit }, ct);
response.EnsureSuccessStatusCode();
}
private sealed record DomainResponse(string RegistrationId, string Status, string? ZaakUrl);
}

View File

@@ -1,6 +1,4 @@
using System.Security.Claims;
using System.Text.Json;
using System.Text.Json.Serialization;
using Bff.Api;
using Microsoft.AspNetCore.Authentication.JwtBearer;
@@ -8,10 +6,6 @@ var builder = WebApplication.CreateBuilder(args);
var keycloakAuthority = builder.Configuration["Keycloak:Authority"]
?? throw new InvalidOperationException("Missing configuration 'Keycloak:Authority'");
// Behandelaars authenticate against a *different* Keycloak realm (medewerker) than citizens (digid),
// so the BFF validates a second issuer for the behandel endpoints (ADR-0013).
var medewerkerAuthority = builder.Configuration["Keycloak:MedewerkerAuthority"]
?? throw new InvalidOperationException("Missing configuration 'Keycloak:MedewerkerAuthority'");
var domainBaseUrl = builder.Configuration["Downstream:Domain:BaseUrl"]
?? throw new InvalidOperationException("Missing configuration 'Downstream:Domain:BaseUrl'");
var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"]
@@ -25,28 +19,8 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
options.Authority = keycloakAuthority;
options.RequireHttpsMetadata = false;
options.TokenValidationParameters.ValidateAudience = false;
})
// The medewerker realm — behandel endpoints only. On validation we lift Keycloak's realm roles
// (the nested realm_access.roles claim) into role claims so authorization policies can require them.
.AddJwtBearer(BehandelAuth.Scheme, options =>
{
options.Authority = medewerkerAuthority;
options.RequireHttpsMetadata = false;
options.TokenValidationParameters.ValidateAudience = false;
options.Events = new JwtBearerEvents
{
OnTokenValidated = context =>
{
BehandelAuth.AddRealmRoles(context.Principal);
return Task.CompletedTask;
},
};
});
builder.Services.AddAuthorization(options =>
options.AddPolicy(BehandelAuth.Policy, policy => policy
.AddAuthenticationSchemes(BehandelAuth.Scheme)
.RequireAuthenticatedUser()
.RequireRole(BehandelAuth.BehandelaarRole)));
builder.Services.AddAuthorization();
// The BFF is the portals' only backend; it fans out to the domain and projection (§8.3).
builder.Services.AddHttpClient<IDomainClient, DomainClient>(c => c.BaseAddress = new Uri(domainBaseUrl));
@@ -94,79 +68,7 @@ app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection,
})
.Produces<IReadOnlyList<OpenbaarEntry>>(StatusCodes.Status200OK);
// Behandelaar's werkbak: registrations awaiting beoordeling. Reached only with a medewerker-realm
// token carrying the behandelaar role; the BFF proxies the domain's werkbak (staff view, ADR-0013).
app.MapGet("/behandel/werkbak", async (IDomainClient domain, CancellationToken ct) =>
Results.Ok(await domain.GetWerkbakAsync(ct)))
.RequireAuthorization(BehandelAuth.Policy)
.Produces<IReadOnlyList<WerkbakItem>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden);
// A behandelaar's beoordeling on a registration (goedkeuren/afwijzen). Forwarded to the domain, which
// applies the decision and completes the workflow task (ADR-0013). Same medewerker/behandelaar gate.
app.MapPost("/behandel/registrations/{id}/decide",
async (string id, DecideRequest body, IDomainClient domain, CancellationToken ct) =>
{
if (!BehandelAuth.IsKnownBesluit(body.Besluit))
return Results.BadRequest(new { error = $"Unknown besluit '{body.Besluit}'. Expected 'goedkeuren' or 'afwijzen'." });
await domain.DecideAsync(id, body.Besluit, ct);
return Results.NoContent();
})
.RequireAuthorization(BehandelAuth.Policy)
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden);
app.Run();
/// <summary>The behandelaar's decision on a registration.</summary>
public sealed record DecideRequest(string Besluit);
// Behandel (medewerker-realm) authentication + authorization wiring (ADR-0013).
internal static class BehandelAuth
{
public const string Scheme = "medewerker";
public const string Policy = "behandelaar";
public const string BehandelaarRole = "behandelaar";
/// <summary>The beoordeling vocabulary the BFF accepts (case-insensitive); an unknown besluit is a
/// 400 without troubling the domain. Mirrors the domain's <c>BeoordelingsBesluit</c>.</summary>
public static bool IsKnownBesluit(string? besluit) =>
string.Equals(besluit, "goedkeuren", StringComparison.OrdinalIgnoreCase) ||
string.Equals(besluit, "afwijzen", StringComparison.OrdinalIgnoreCase);
/// <summary>Lift Keycloak's realm roles (the nested <c>realm_access.roles</c> claim) onto the
/// principal as role claims, so <c>RequireRole</c> can authorize on them.</summary>
public static void AddRealmRoles(ClaimsPrincipal? principal)
{
if (principal?.Identity is not ClaimsIdentity identity)
return;
var realmAccess = principal.FindFirst("realm_access")?.Value;
if (string.IsNullOrWhiteSpace(realmAccess))
return;
// A malformed realm_access claim must not fail authentication (a throw here becomes a 401);
// it simply yields no roles, so the authorization policy answers 403.
string[] roles;
try
{
roles = JsonSerializer.Deserialize<RealmAccess>(realmAccess)?.Roles ?? [];
}
catch (JsonException)
{
return;
}
foreach (var role in roles)
identity.AddClaim(new Claim(identity.RoleClaimType, role));
}
private sealed record RealmAccess([property: JsonPropertyName("roles")] string[] Roles);
}
// Exposed so the test host (WebApplicationFactory<Program>) can boot the app.
public partial class Program;

View File

@@ -7,8 +7,7 @@
},
"AllowedHosts": "*",
"Keycloak": {
"Authority": "http://localhost:8180/realms/digid",
"MedewerkerAuthority": "http://localhost:8180/realms/medewerker"
"Authority": "http://localhost:8180/realms/digid"
},
"Downstream": {
"Domain": { "BaseUrl": "http://localhost:8130/" },

View File

@@ -1,114 +0,0 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Bff.Api;
namespace Bff.Tests;
/// <summary>
/// The behandel werkbak endpoint (S-12c): reached only with a medewerker-realm token that carries the
/// <c>behandelaar</c> role. A missing token is 401; an authenticated medewerker without the role is
/// 403; a behandelaar gets the werkbak (staff view, incl. bsn).
/// </summary>
public class BehandelEndpointTests
{
private static HttpRequestMessage Werkbak(string? bearer)
{
var request = new HttpRequestMessage(HttpMethod.Get, "/behandel/werkbak");
if (bearer is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
return request;
}
[Fact]
public async Task Rejects_the_werkbak_without_a_token()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Werkbak(bearer: null));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Rejects_a_medewerker_without_the_behandelaar_role()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Werkbak(TestTokens.Medewerker("teamlead")));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task Serves_the_werkbak_to_a_behandelaar()
{
using var factory = new BffFactory();
factory.Domain.Werkbak.Add(new WerkbakItem("reg-1", "123456782", "InBehandeling"));
var response = await factory.CreateClient().SendAsync(Werkbak(TestTokens.Medewerker("behandelaar")));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var items = await response.Content.ReadFromJsonAsync<List<WerkbakItem>>();
var item = Assert.Single(items!);
Assert.Equal("reg-1", item.RegistrationId);
Assert.Equal("123456782", item.Bsn);
}
private static HttpRequestMessage Decide(string? bearer, string id = "reg-1", string besluit = "goedkeuren")
{
var request = new HttpRequestMessage(HttpMethod.Post, $"/behandel/registrations/{id}/decide")
{
Content = JsonContent.Create(new { besluit }),
};
if (bearer is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
return request;
}
[Fact]
public async Task Rejects_a_decision_without_a_token()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Decide(bearer: null));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Null(factory.Domain.Decided);
}
[Fact]
public async Task Rejects_a_decision_from_a_medewerker_without_the_behandelaar_role()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Decide(TestTokens.Medewerker("teamlead")));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Null(factory.Domain.Decided);
}
[Fact]
public async Task Forwards_a_behandelaar_decision_to_the_domain()
{
using var factory = new BffFactory();
var response = await factory.CreateClient()
.SendAsync(Decide(TestTokens.Medewerker("behandelaar"), id: "reg-42", besluit: "afwijzen"));
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
Assert.Equal(("reg-42", "afwijzen"), factory.Domain.Decided);
}
[Fact]
public async Task Rejects_an_unknown_besluit_without_calling_the_domain()
{
using var factory = new BffFactory();
var response = await factory.CreateClient()
.SendAsync(Decide(TestTokens.Medewerker("behandelaar"), besluit: "misschien"));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Null(factory.Domain.Decided);
}
}

View File

@@ -5,7 +5,6 @@ using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
@@ -24,34 +23,9 @@ internal sealed class BffFactory : WebApplicationFactory<Program>
public FakeDomainClient Domain { get; } = new();
public FakeProjectionClient Projection { get; } = new();
private static void ValidateWithTestKey(IServiceCollection services, string scheme) =>
services.Configure<JwtBearerOptions>(scheme, options =>
{
// Validate locally against the test key and NEVER reach out for OIDC metadata. A static
// configuration manager guarantees this regardless of Configure/PostConfigure ordering —
// clearing Authority alone left the medewerker scheme fetching metadata under CI timing
// (2s hang → 401), because JwtBearer's PostConfigure could still build a ConfigurationManager.
options.Authority = null;
options.MetadataAddress = null!;
options.RequireHttpsMetadata = false;
options.Configuration = new OpenIdConnectConfiguration();
options.ConfigurationManager =
new StaticConfigurationManager<OpenIdConnectConfiguration>(new OpenIdConnectConfiguration());
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = TestSigningKey,
ClockSkew = TimeSpan.Zero,
};
});
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseSetting("Keycloak:Authority", "https://keycloak.invalid/realms/digid");
builder.UseSetting("Keycloak:MedewerkerAuthority", "https://keycloak.invalid/realms/medewerker");
builder.UseSetting("Downstream:Domain:BaseUrl", "http://domain.invalid/");
builder.UseSetting("Downstream:Projection:BaseUrl", "http://projection.invalid/");
@@ -60,11 +34,23 @@ internal sealed class BffFactory : WebApplicationFactory<Program>
services.AddSingleton<IDomainClient>(Domain);
services.AddSingleton<IProjectionClient>(Projection);
// 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
// parameters are swapped here.
ValidateWithTestKey(services, JwtBearerDefaults.AuthenticationScheme);
ValidateWithTestKey(services, "medewerker");
services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
{
// Validate locally against the test key; never reach out for OIDC metadata.
options.Authority = null;
options.MetadataAddress = null!;
options.RequireHttpsMetadata = false;
options.Configuration = new OpenIdConnectConfiguration();
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = TestSigningKey,
ClockSkew = TimeSpan.Zero,
};
});
});
}
}
@@ -74,24 +60,12 @@ internal sealed class FakeDomainClient : IDomainClient
{
public string? SubmittedBsn { get; private set; }
public SubmitAccepted Result { get; set; } = new("reg-123", "Ingediend");
public List<WerkbakItem> Werkbak { get; } = [];
public Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default)
{
SubmittedBsn = bsn;
return Task.FromResult(Result);
}
public (string RegistrationId, string Besluit)? Decided { get; private set; }
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<WerkbakItem>>(Werkbak);
public Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default)
{
Decided = (registrationId, besluit);
return Task.CompletedTask;
}
}
/// <summary>Serves a configurable set of projection rows.</summary>

View File

@@ -17,22 +17,6 @@ internal static class TestTokens
new SymmetricSecurityKey(Encoding.UTF8.GetBytes("a-different-signing-key-256-bits-long-indeed-yes!")),
expired: false);
/// <summary>A medewerker-realm token carrying the given realm roles under <c>realm_access.roles</c>
/// (Keycloak's shape), signed with the valid test key. Used to exercise behandel authorization.</summary>
public static string Medewerker(params string[] roles)
{
var handler = new JsonWebTokenHandler();
return handler.CreateToken(new SecurityTokenDescriptor
{
Claims = new Dictionary<string, object>
{
["realm_access"] = new Dictionary<string, object> { ["roles"] = roles },
},
Expires = DateTime.UtcNow.AddMinutes(30),
SigningCredentials = new SigningCredentials(BffFactory.TestSigningKey, SecurityAlgorithms.HmacSha256),
});
}
private static string Create(string bsn, SymmetricSecurityKey key, bool expired)
{
var handler = new JsonWebTokenHandler();

View File

@@ -60,90 +60,10 @@
}
}
}
},
"/behandel/werkbak": {
"get": {
"tags": [
"Bff.Api"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/WerkbakItem"
}
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
}
},
"/behandel/registrations/{id}/decide": {
"post": {
"tags": [
"Bff.Api"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DecideRequest"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "No Content"
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
}
}
},
"components": {
"schemas": {
"DecideRequest": {
"required": [
"besluit"
],
"type": "object",
"properties": {
"besluit": {
"type": "string"
}
}
},
"OpenbaarEntry": {
"required": [
"id",
@@ -180,25 +100,6 @@
"type": "string"
}
}
},
"WerkbakItem": {
"required": [
"registrationId",
"bsn",
"status"
],
"type": "object",
"properties": {
"registrationId": {
"type": "string"
},
"bsn": {
"type": "string"
},
"status": {
"type": "string"
}
}
}
}
},

View File

@@ -26,8 +26,6 @@ builder.Services.AddHttpClient<IAclClient, AclHttpClient>();
builder.Services.AddScoped<SubmitRegistration>();
builder.Services.AddScoped<ApproveRegistration>();
builder.Services.AddScoped<BeoordeelRegistratie>();
builder.Services.AddScoped<WithdrawRegistration>();
builder.Services.AddScoped<Werkbak>();
builder.Services.AddScoped<OpenZaakWorker>();
builder.Services.AddScoped<OpenZaakJobProcessor>();
@@ -75,25 +73,6 @@ app.MapPost("/registrations/{id}/decide", async (string id, DecideRequest body,
return Results.NoContent();
});
// Withdraw a registration (S-11): the zorgprofessional pulls their own still-open submission back,
// advancing it to INGETROKKEN. Idempotent. The BFF reaches this behind a digid token, owner-scoped
// to the caller's bsn (S-11c); the domain trusts its callers (§8.3). Cancelling the running Flowable
// process is a later sub-slice (S-11b).
app.MapPost("/registrations/{id}/withdraw", async (string id, WithdrawRegistration withdraw, CancellationToken ct) =>
{
if (!Guid.TryParse(id, out var guid))
return Results.NotFound();
await withdraw.HandleAsync(new WithdrawRegistrationCommand(new RegistrationId(guid)), ct);
return Results.NoContent();
});
// The behandelaar's werkbak (S-12): the registrations awaiting beoordeling, read from the open
// Beoordelen user tasks (§8.2) and enriched with bsn + status. The BFF proxies this behind
// medewerker-realm + behandelaar-role authorization; the domain trusts its callers (§8.3).
app.MapGet("/behandel/werkbak", async (Werkbak werkbak, CancellationToken ct) =>
Results.Ok(await werkbak.GetAsync(ct)));
// Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually).
app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) =>
{

View File

@@ -20,12 +20,10 @@ public sealed record BeoordeelRegistratieCommand(RegistrationId RegistrationId,
/// <see cref="BeoordelingsBesluit.Goedkeuren"/> sets the zaak's final status via the ACL (§8.1) and
/// advances the aggregate to INGESCHREVEN; <see cref="BeoordelingsBesluit.Afwijzen"/> advances it to
/// AFGEWEZEN in the domain (propagating a rejection to the zaak, so the openbaar projection reflects
/// it, is a later sub-slice of S-12). After applying the decision it completes the Flowable
/// <c>Beoordelen</c> task (found by registrationId) so the workflow advances (ADR-0013). Both
/// decisions are idempotent — a repeated or redelivered decision that matches the current terminal
/// state is a no-op, so the ACL is not called and the task not completed twice.
/// it, is a later sub-slice of S-12). Both decisions are idempotent — a repeated or redelivered
/// decision that matches the current terminal state is a no-op, so the ACL is not called twice.
/// </summary>
public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient acl, IUserTaskClient tasks)
public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient acl)
{
public async Task HandleAsync(BeoordeelRegistratieCommand command, CancellationToken ct = default)
{
@@ -55,17 +53,5 @@ public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient ac
}
await store.SaveAsync(registration, ct);
await CompleteWorkflowTaskAsync(command.RegistrationId, command.Besluit, ct);
}
// Advance the workflow: complete the open Beoordelen task for this registration. If none is open
// (already completed, or the process hasn't parked yet) the decision still stands — we complete
// nothing rather than fail.
private async Task CompleteWorkflowTaskAsync(RegistrationId registrationId, BeoordelingsBesluit besluit, CancellationToken ct)
{
var open = await tasks.GetOpenBeoordelingenAsync(ct);
var task = open.FirstOrDefault(t => t.RegistrationId == registrationId);
if (task is not null)
await tasks.CompleteBeoordelingAsync(task.TaskId, besluit, ct);
}
}

View File

@@ -1,32 +0,0 @@
namespace Big.Application;
/// <summary>One row of the behandelaar's werkbak: a registration awaiting beoordeling, with the
/// public-facing reference (its id) plus the bsn and status a behandelaar needs to triage it.</summary>
public sealed record WerkbakItem(string RegistrationId, string Bsn, string Status);
/// <summary>
/// The werkbak query (S-12c): the registrations awaiting a behandelaar's beoordeling. It reads the
/// open <c>Beoordelen</c> tasks from the workflow engine (§8.2, via <see cref="IUserTaskClient"/>) —
/// the authoritative set of work items — and enriches each with its aggregate (bsn + status). A task
/// whose registration the domain doesn't know is skipped rather than invented.
/// </summary>
public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store)
{
public async Task<IReadOnlyList<WerkbakItem>> GetAsync(CancellationToken ct = default)
{
var open = await tasks.GetOpenBeoordelingenAsync(ct);
var items = new List<WerkbakItem>(open.Count);
foreach (var task in open)
{
var registration = await store.GetAsync(task.RegistrationId, ct);
if (registration is null)
continue;
items.Add(new WerkbakItem(
registration.Id.ToString(), registration.Bsn, registration.Status.ToString()));
}
return items;
}
}

View File

@@ -1,32 +0,0 @@
using Big.Domain;
namespace Big.Application;
/// <summary>A zorgprofessional's request to withdraw their own registration ("trek aanvraag in").</summary>
public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId);
/// <summary>
/// The withdrawal use case (S-11): a zorgprofessional pulls a still-open registration back. It
/// advances the aggregate to INGETROKKEN and persists it. Idempotent — a repeated or redelivered
/// withdrawal of an already-withdrawn registration is a no-op (the aggregate is not persisted again).
/// Cancelling the running Flowable process (so the case leaves the behandelaar's werkbak) is a later
/// sub-slice (S-11b, via a BPMN message event); this sub-slice owns the domain transition only —
/// mirroring how the beoordeling's rejection deferred its zaak propagation.
/// </summary>
public sealed class WithdrawRegistration(IRegistrationStore store)
{
public async Task HandleAsync(WithdrawRegistrationCommand command, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(command);
var registration = await store.GetAsync(command.RegistrationId, ct)
?? throw new InvalidOperationException($"No registration {command.RegistrationId} to withdraw.");
// A repeated withdrawal is a no-op: don't persist the already-withdrawn aggregate again.
if (registration.Status == RegistrationStatus.Ingetrokken)
return;
registration.Withdraw();
await store.SaveAsync(registration, ct);
}
}

View File

@@ -111,23 +111,7 @@ public sealed class Registration
Status = RegistrationStatus.Afgewezen;
}
/// <summary>
/// Withdraw the registration — the zorgprofessional pulls their own submission back (S-11). Allowed
/// while it is still open (INGEDIEND or IN_BEHANDELING) and needs no zaak; a registration that has
/// already been decided (INGESCHREVEN/AFGEWEZEN) can no longer be withdrawn. Re-withdrawing one
/// already <see cref="RegistrationStatus.Ingetrokken"/> is a no-op.
/// </summary>
public void Withdraw()
{
if (Status == RegistrationStatus.Ingetrokken)
return;
RequireOpenForDecision(nameof(Withdraw));
Status = RegistrationStatus.Ingetrokken;
}
// A decision (or withdrawal) is only valid while the registration is still open (INGEDIEND or
// IN_BEHANDELING).
// A decision is only valid while the registration is still open (INGEDIEND or IN_BEHANDELING).
private void RequireOpenForDecision(string decision)
{
if (Status is not (RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling))

View File

@@ -3,8 +3,7 @@ namespace Big.Domain;
/// <summary>The lifecycle states a <see cref="Registration"/> moves through. Submission starts in
/// <see cref="Ingediend"/>; a behandelaar takes it <see cref="InBehandeling"/> and decides it into one
/// of the terminal states <see cref="Ingeschreven"/> (approved) or <see cref="Afgewezen"/> (rejected).
/// A zorgprofessional can withdraw a still-open registration into <see cref="Ingetrokken"/> (S-11).
/// The herregistratie state arrives in its own slice.</summary>
/// Withdrawal and herregistratie states arrive in their own slices (S-11+).</summary>
public enum RegistrationStatus
{
/// <summary>Submitted by the zorgprofessional; the registratie process has been started.</summary>
@@ -18,7 +17,4 @@ public enum RegistrationStatus
/// <summary>Rejected by the behandelaar. Terminal.</summary>
Afgewezen,
/// <summary>Withdrawn by the zorgprofessional before a decision (S-11). Terminal.</summary>
Ingetrokken,
}

View File

@@ -6,8 +6,8 @@ namespace Big.Tests;
/// <summary>
/// The beoordeling use case (S-12): a behandelaar's decision on a registration. Goedkeuren sets the
/// zaak's final status via the ACL (§8.1) and marks the aggregate INGESCHREVEN; Afwijzen marks it
/// AFGEWEZEN. Either way the decision also completes the Flowable Beoordelen task (found by
/// registrationId) so the process advances. All idempotent a repeated decision is a no-op.
/// AFGEWEZEN in the domain (propagating a rejection to the zaak is a later sub-slice). Both are
/// idempotent so a repeated or redelivered decision is a no-op.
/// </summary>
public class BeoordeelRegistratieTests
{
@@ -18,9 +18,6 @@ public class BeoordeelRegistratieTests
return registration;
}
private static FakeUserTaskClient TaskFor(Registration registration) =>
new([new BeoordelingTask("task-1", registration.Id)]);
[Fact]
public async Task Goedkeuren_sets_the_zaak_status_via_the_acl_and_marks_the_registration_ingeschreven()
{
@@ -28,8 +25,7 @@ public class BeoordeelRegistratieTests
var acl = new FakeAclClient();
var registration = WithZaak();
store.Seed(registration);
var tasks = TaskFor(registration);
var handler = new BeoordeelRegistratie(store, acl, tasks);
var handler = new BeoordeelRegistratie(store, acl);
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
@@ -38,8 +34,6 @@ public class BeoordeelRegistratieTests
Assert.Equal(FakeAclClient.DefaultZaakUrl, acl.ApprovedZaakUrl);
Assert.Equal(1, acl.ApproveCallCount);
Assert.Equal(1, store.SaveCount);
// The behandelaar's decision advances the workflow: the Beoordelen task is completed.
Assert.Equal(("task-1", BeoordelingsBesluit.Goedkeuren), tasks.Completed);
}
[Fact]
@@ -49,8 +43,7 @@ public class BeoordeelRegistratieTests
var acl = new FakeAclClient();
var registration = WithZaak();
store.Seed(registration);
var tasks = TaskFor(registration);
var handler = new BeoordeelRegistratie(store, acl, tasks);
var handler = new BeoordeelRegistratie(store, acl);
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
@@ -58,7 +51,6 @@ public class BeoordeelRegistratieTests
Assert.Equal(RegistrationStatus.Afgewezen, saved!.Status);
Assert.Equal(0, acl.ApproveCallCount);
Assert.Equal(1, store.SaveCount);
Assert.Equal(("task-1", BeoordelingsBesluit.Afwijzen), tasks.Completed);
}
[Fact]
@@ -69,7 +61,7 @@ public class BeoordeelRegistratieTests
var registration = WithZaak();
registration.TakeIntoBehandeling();
store.Seed(registration);
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration));
var handler = new BeoordeelRegistratie(store, acl);
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
@@ -81,7 +73,7 @@ public class BeoordeelRegistratieTests
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([]));
var handler = new BeoordeelRegistratie(store, acl);
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
Assert.Equal(0, acl.ApproveCallCount);
@@ -93,7 +85,7 @@ public class BeoordeelRegistratieTests
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([]));
var handler = new BeoordeelRegistratie(store, acl);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
handler.HandleAsync(new BeoordeelRegistratieCommand(RegistrationId.New(), BeoordelingsBesluit.Goedkeuren)));
@@ -108,7 +100,7 @@ public class BeoordeelRegistratieTests
var acl = new FakeAclClient();
var registration = Registration.Submit("123456782"); // no zaak yet
store.Seed(registration);
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration));
var handler = new BeoordeelRegistratie(store, acl);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren)));
@@ -123,7 +115,7 @@ public class BeoordeelRegistratieTests
var acl = new FakeAclClient();
var registration = WithZaak();
store.Seed(registration);
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration));
var handler = new BeoordeelRegistratie(store, acl);
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
@@ -139,7 +131,7 @@ public class BeoordeelRegistratieTests
var acl = new FakeAclClient();
var registration = WithZaak();
store.Seed(registration);
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration));
var handler = new BeoordeelRegistratie(store, acl);
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
@@ -147,22 +139,4 @@ public class BeoordeelRegistratieTests
Assert.Equal(1, store.SaveCount);
Assert.Equal(RegistrationStatus.Afgewezen, (await store.GetAsync(registration.Id))!.Status);
}
[Fact]
public async Task Deciding_completes_no_task_when_none_is_open_for_the_registration()
{
// The task may already be gone (redelivery / manual completion). The decision still applies
// and simply completes nothing rather than failing.
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var registration = WithZaak();
store.Seed(registration);
var tasks = new FakeUserTaskClient([]); // no open task for this registration
var handler = new BeoordeelRegistratie(store, acl, tasks);
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
Assert.Equal(RegistrationStatus.Ingeschreven, (await store.GetAsync(registration.Id))!.Status);
Assert.Null(tasks.Completed);
}
}

View File

@@ -41,29 +41,6 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac
}
}
/// <summary>A fake user-task client for the werkbak/decision use cases: returns a scripted set of
/// open beoordeling tasks and records claim/complete calls.</summary>
internal sealed class FakeUserTaskClient(IReadOnlyList<BeoordelingTask> open) : IUserTaskClient
{
public (string TaskId, string Behandelaar)? Claimed { get; private set; }
public (string TaskId, BeoordelingsBesluit Besluit)? Completed { get; private set; }
public Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default)
=> Task.FromResult(open);
public Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default)
{
Claimed = (taskId, behandelaar);
return Task.CompletedTask;
}
public Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default)
{
Completed = (taskId, besluit);
return Task.CompletedTask;
}
}
/// <summary>A fake ACL client that records the bsn it was asked to open a zaak for and returns a
/// fixed zaak URL.</summary>
internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient

View File

@@ -217,72 +217,4 @@ public class RegistrationTests
Assert.Contains("Afgewezen", rejectEx.Message);
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
}
[Fact]
public void Withdrawing_an_ingediend_registration_sets_it_ingetrokken()
{
var registration = Registration.Submit("123456782");
registration.Withdraw();
Assert.Equal(RegistrationStatus.Ingetrokken, registration.Status);
}
[Fact]
public void Withdrawing_an_in_behandeling_registration_sets_it_ingetrokken()
{
// A citizen can still pull a registration back while a behandelaar has it in behandeling.
var registration = Registration.Submit("123456782");
registration.TakeIntoBehandeling();
registration.Withdraw();
Assert.Equal(RegistrationStatus.Ingetrokken, registration.Status);
}
[Fact]
public void Withdrawing_needs_no_zaak()
{
// Withdrawal is the citizen's own action and does not depend on the zaak having been opened.
var registration = Registration.Submit("123456782");
registration.Withdraw();
Assert.Equal(RegistrationStatus.Ingetrokken, registration.Status);
Assert.Null(registration.ZaakUrl);
}
[Fact]
public void Re_withdrawing_an_already_ingetrokken_registration_is_idempotent()
{
var registration = Registration.Submit("123456782");
registration.Withdraw();
registration.Withdraw();
Assert.Equal(RegistrationStatus.Ingetrokken, registration.Status);
}
[Fact]
public void Withdrawing_an_approved_registration_is_rejected()
{
var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve();
var ex = Assert.Throws<InvalidOperationException>(() => registration.Withdraw());
Assert.Contains("only an INGEDIEND", ex.Message);
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
}
[Fact]
public void Withdrawing_a_rejected_registration_is_rejected()
{
var registration = Registration.Submit("123456782");
registration.Reject();
var ex = Assert.Throws<InvalidOperationException>(() => registration.Withdraw());
Assert.Contains("only an INGEDIEND", ex.Message);
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
}
}

View File

@@ -1,49 +0,0 @@
using Big.Application;
using Big.Domain;
namespace Big.Tests;
/// <summary>
/// The werkbak query (S-12c): the behandelaar's list of registrations awaiting beoordeling. It reads
/// the open Beoordelen tasks from the workflow engine (§8.2) and enriches each with its registration
/// (bsn + status) from the store. A task whose registration is unknown is skipped defensively.
/// </summary>
public class WerkbakTests
{
[Fact]
public async Task Lists_an_item_per_open_beoordeling_enriched_from_the_registration()
{
var store = new FakeRegistrationStore();
var registration = Registration.Submit("123456782");
registration.AttachZaak(FakeAclClient.DefaultZaakUrl);
registration.TakeIntoBehandeling();
store.Seed(registration);
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", registration.Id)]);
var werkbak = new Werkbak(tasks, store);
var items = await werkbak.GetAsync();
var item = Assert.Single(items);
Assert.Equal(registration.Id.ToString(), item.RegistrationId);
Assert.Equal("123456782", item.Bsn);
Assert.Equal("InBehandeling", item.Status);
}
[Fact]
public async Task Is_empty_when_no_beoordelingen_are_open()
{
var werkbak = new Werkbak(new FakeUserTaskClient([]), new FakeRegistrationStore());
Assert.Empty(await werkbak.GetAsync());
}
[Fact]
public async Task Skips_a_task_whose_registration_is_unknown()
{
// Defensive: the werkbak never invents an item for a task the domain has no aggregate for.
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", RegistrationId.New())]);
var werkbak = new Werkbak(tasks, new FakeRegistrationStore());
Assert.Empty(await werkbak.GetAsync());
}
}

View File

@@ -1,60 +0,0 @@
using Big.Application;
using Big.Domain;
namespace Big.Tests;
public class WithdrawRegistrationTests
{
[Fact]
public async Task Withdrawing_marks_the_registration_ingetrokken_and_persists_it()
{
var store = new FakeRegistrationStore();
var registration = Registration.Submit("123456782");
store.Seed(registration);
var handler = new WithdrawRegistration(store);
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
var saved = await store.GetAsync(registration.Id);
Assert.Equal(RegistrationStatus.Ingetrokken, saved!.Status);
Assert.Equal(1, store.SaveCount);
}
[Fact]
public async Task Rejects_a_null_command_without_touching_the_store()
{
var store = new FakeRegistrationStore();
var handler = new WithdrawRegistration(store);
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
Assert.Equal(0, store.SaveCount);
}
[Fact]
public async Task Withdrawing_an_unknown_registration_throws()
{
var store = new FakeRegistrationStore();
var handler = new WithdrawRegistration(store);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => handler.HandleAsync(new WithdrawRegistrationCommand(RegistrationId.New())));
Assert.Contains("No registration", ex.Message);
Assert.Equal(0, store.SaveCount);
}
[Fact]
public async Task Re_withdrawing_an_already_ingetrokken_registration_is_idempotent()
{
var store = new FakeRegistrationStore();
var registration = Registration.Submit("123456782");
store.Seed(registration);
var handler = new WithdrawRegistration(store);
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
// The second withdrawal is a no-op: the aggregate is not persisted again.
Assert.Equal(1, store.SaveCount);
Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status);
}
}

View File

@@ -15,7 +15,6 @@ Feature: Een registratie beoordelen
When the behandelaar decides "goedkeuren"
Then the registration has status "INGESCHREVEN"
And the zaak's final status is set via the ACL
And the beoordeling task is completed with "goedkeuren"
Scenario: Afwijzen wijst de registratie af zonder de ACL
Given a submitted registration with an opened zaak
@@ -23,4 +22,3 @@ Feature: Een registratie beoordelen
And the behandelaar decides "afwijzen"
Then the registration has status "AFGEWEZEN"
And the ACL is not asked to set the zaak status
And the beoordeling task is completed with "afwijzen"

View File

@@ -16,7 +16,6 @@ public sealed class EenRegistratieBeoordelenSteps
{
private readonly InMemoryAclClient _acl = new();
private readonly InMemoryRegistrationStore _store = new();
private readonly InMemoryUserTaskClient _tasks = new();
private RegistrationId _id;
[Given("a submitted registration with an opened zaak")]
@@ -26,8 +25,6 @@ public sealed class EenRegistratieBeoordelenSteps
registration.AttachZaak(InMemoryAclClient.OpenedZaakUrl);
await _store.SaveAsync(registration);
_id = registration.Id;
// The process has parked at the Beoordelen user task awaiting the behandelaar.
_tasks.Open(_id);
}
[When("the behandelaar takes it into behandeling")]
@@ -40,7 +37,7 @@ public sealed class EenRegistratieBeoordelenSteps
[When("the behandelaar decides \"(.*)\"")]
public async Task WhenTheBehandelaarDecides(string besluit)
=> await new BeoordeelRegistratie(_store, _acl, _tasks).HandleAsync(
=> await new BeoordeelRegistratie(_store, _acl).HandleAsync(
new BeoordeelRegistratieCommand(_id, Enum.Parse<BeoordelingsBesluit>(besluit, ignoreCase: true)));
[Then("the registration has status \"(.*)\"")]
@@ -58,8 +55,4 @@ public sealed class EenRegistratieBeoordelenSteps
[Then("the ACL is not asked to set the zaak status")]
public void ThenTheAclIsNotAskedToSetTheZaakStatus()
=> Assert.Null(_acl.ApprovedZaakUrl);
[Then("the beoordeling task is completed with \"(.*)\"")]
public void ThenTheBeoordelingTaskIsCompletedWith(string besluit)
=> Assert.Equal(Enum.Parse<BeoordelingsBesluit>(besluit, ignoreCase: true), _tasks.Completed?.Besluit);
}

View File

@@ -68,12 +68,6 @@ public sealed class CapturingDomainClient : IDomainClient
SubmittedBsn = bsn;
return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend"));
}
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<WerkbakItem>>([]);
public Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default)
=> Task.CompletedTask;
}
/// <summary>Serves configurable projection rows.</summary>

View File

@@ -43,29 +43,6 @@ public sealed class InMemoryAclClient : IAclClient
}
}
/// <summary>An in-memory user-task client for the beoordeling acceptance scenario: it holds one open
/// Beoordelen task per registration and records the besluit each is completed with.</summary>
public sealed class InMemoryUserTaskClient : IUserTaskClient
{
private readonly List<BeoordelingTask> _open = [];
public (string TaskId, BeoordelingsBesluit Besluit)? Completed { get; private set; }
public void Open(RegistrationId registrationId) => _open.Add(new BeoordelingTask($"task-{registrationId}", registrationId));
public Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<BeoordelingTask>>(_open);
public Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default) => Task.CompletedTask;
public Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default)
{
Completed = (taskId, besluit);
_open.RemoveAll(t => t.TaskId == taskId);
return Task.CompletedTask;
}
}
/// <summary>An in-memory registration store for the domain acceptance scenario.</summary>
public sealed class InMemoryRegistrationStore : IRegistrationStore
{

View File

@@ -3,9 +3,6 @@ import { defineConfig, devices } from '@playwright/test';
// The e2e runs inside the compose network (infra/run-e2e-check.sh); baseURL defaults to the
// self-service service. Keep timeouts generous — the first navigation triggers the DigiD flow.
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';
export default defineConfig({
testDir: '.',
@@ -25,9 +22,7 @@ export default defineConfig({
// the production HTTPS context. This flag is only honoured by the full Chromium build (new
// headless), not Playwright's default headless-shell, so pin `channel: 'chromium'`.
channel: 'chromium',
launchOptions: {
args: [`--unsafely-treat-insecure-origin-as-secure=${baseURL},${behandelURL}`],
},
launchOptions: { args: [`--unsafely-treat-insecure-origin-as-secure=${baseURL}`] },
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});

View File

@@ -1,11 +1,10 @@
import { expect, test } from '@playwright/test';
// Walking-skeleton happy path (S-08d + S-09 + S-09b + S-12): a zorgprofessional logs in via mock
// DigiD and submits through the self-service portal → BFF → domain; the entry appears in the openbaar
// register as INGEDIEND; a behandelaar then logs in to the behandel portal, finds the registration in
// the werkbak, and approves it (goedkeuren); the decision completes the Flowable Beoordelen task and
// flows via the ACL → NRC → event-subscriber → projection, and the openbaar register shows INGESCHREVEN.
test('DigiD submit → public INGEDIEND → behandelaar goedkeurt → public INGESCHREVEN', async ({ page }) => {
// Walking-skeleton happy path (S-08d + S-09 + S-09b): a zorgprofessional logs in via mock DigiD and
// submits through the self-service portal → BFF → domain; the entry appears in the openbaar register
// as INGEDIEND; a behandelaar approves it via the temporary admin endpoint; the approval flows via the
// ACL → NRC → event-subscriber → projection, and the openbaar register then shows it as INGESCHREVEN.
test('DigiD login → submit → public INGEDIENDapprove → public INGESCHREVEN', async ({ page, request }) => {
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
await page.goto('/');
@@ -44,42 +43,21 @@ test('DigiD submit → public INGEDIEND → behandelaar goedkeurt → public ING
await expect(page.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGEDIEND' }))
.toBeVisible();
// A behandelaar picks the registration up in the behandel-portal werkbak and approves it
// (goedkeuren) — the S-12 flow that replaces the temporary admin endpoint. Navigating here switches
// to the medewerker realm (a different Keycloak realm than the citizen's digid session).
await page.goto('http://behandel/');
await page.locator('#username').fill('merel-behandelaar');
await page.locator('#password').fill('test123');
await page.locator('#kc-login').click();
await expect(page.getByRole('heading', { name: /Werkbak/i })).toBeVisible();
// The registration parks at the Beoordelen user task only after the worker has opened its zaak, so
// it appears in the werkbak asynchronously — reload until this reference's row shows up. Target the
// decide button by reference (not a generic "Goedkeuren"): the shared verify stack holds other open
// tasks, so a positional match could act on someone else's registration.
const goedkeuren = page.getByRole('button', { name: `Goedkeuren ${reference}` });
// Approve via the temporary admin endpoint (reached directly on the compose network, as a
// behandelaar would until the behandel-portal exists — S-12). The zaak is opened off the request
// path by the worker, so wait for it before approving.
await expect
.poll(async () => {
await page.reload();
return goedkeuren.count();
const res = await request.get(`http://domain:8080/registrations/${reference}`);
return res.ok() ? (await res.json()).zaakUrl : null;
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
.toBeGreaterThan(0);
.toBeTruthy();
// Click and wait for the decide POST to finish (204) BEFORE leaving the page. `click()` only
// dispatches the request; navigating away immediately cancels it in flight (nginx logs a 499) and
// the decision never reaches the domain — so the registration would stay INGEDIEND.
const decided = page.waitForResponse(
(r) =>
r.url().includes(`/behandel/registrations/${reference}/decide`) &&
r.request().method() === 'POST',
);
await goedkeuren.click();
expect((await decided).status()).toBe(204);
const approve = await request.post(`http://domain:8080/registrations/${reference}/approve`);
expect(approve.status()).toBe(204);
// The approval flows back to the projection; back on the openbaar register *our* row (matched by
// its reference) now shows INGESCHREVEN.
await page.goto('http://openbaar/');
// The approval flows back to the projection; the openbaar register now shows *our* row (matched by
// its reference) as INGESCHREVEN.
await expect
.poll(async () => {
await page.reload();