Compare commits

..
Author SHA1 Message Date
not a16f811d50 Merge branch 'main' into feat/132-medewerker-mfa
CI / build (pull_request) Successful in 1m3s
CI / lint (pull_request) Successful in 1m20s
CI / unit (pull_request) Successful in 1m24s
CI / frontend (pull_request) Successful in 3m11s
CI / mutation (pull_request) Successful in 6m17s
CI / verify-stack (pull_request) Failing after 9m38s
2026-09-04 06:47:59 +00:00
notandClaude Opus 5 a87a32e269 docs(infra): document MFA on the medewerker realm + ADR-0031 (refs #132)
CI / build (pull_request) Successful in 1m10s
CI / lint (pull_request) Successful in 1m27s
CI / unit (pull_request) Successful in 1m24s
CI / frontend (pull_request) Successful in 3m27s
CI / mutation (pull_request) Successful in 6m29s
CI / verify-stack (pull_request) Successful in 9m41s
Runbook gains an MFA section and how to get a code; synthetic-data lists the fixture
TOTP secret and the extra grant parameter; demo-script gains the S-15c note and its
staff logins now mention the second factor. check_realms.py grows an 'otp' argument
that prints a current code for a manual demo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 09:11:03 +02:00
notandClaude Opus 5 3567bc1f42 feat(infra): enforce MFA on the medewerker realm (refs #132)
Every seeded medewerker carries a TOTP credential, so Keycloak's conditional-OTP
step in both the browser and direct-grant flows always challenges them; a password
alone no longer yields a token. CONFIGURE_TOTP becomes a default required action so
any medewerker added later must enrol before logging in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 09:08:50 +02:00
notandClaude Opus 5 30c5279e90 test(infra): medewerker login must be refused without a second factor (refs #132)
The keycloak smoke check now asserts that a password-only grant on the medewerker
realm is rejected and that a TOTP code completes it. The e2e medewerker logins move
to a shared helper that submits Keycloak's OTP challenge. Both fail against the
current realm export, which enforces no MFA.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 09:07:45 +02:00
8 changed files with 28 additions and 315 deletions
@@ -4,7 +4,7 @@ import { of, throwError } from 'rxjs';
import { BffApiV1Service, type WerkbakItem } from 'api-client';
import { AuthService } from 'auth';
import { axe } from 'vitest-axe';
import { WERKBAK_REFRESH_MS, WerkbakPage } from './werkbak-page';
import { WerkbakPage } from './werkbak-page';
const sample: WerkbakItem[] = [
{ registrationId: 'reg-1', bsn: '123456782', status: 'InBehandeling' },
@@ -81,94 +81,6 @@ describe('WerkbakPage', () => {
});
});
it('picks up a newly submitted registration without a reload', async () => {
// S-26 (#162): a registration reaches Beoordelen asynchronously, after the citizen supplies
// documents — so the werkbak must refresh itself rather than wait for the behandelaar to reload.
vi.useFakeTimers();
try {
const getBehandelWerkbak = vi
.fn()
.mockReturnValueOnce(of([sample[0]]))
.mockReturnValue(of(sample));
const { providers } = setup({ getBehandelWerkbak });
const { detectChanges } = await render(WerkbakPage, { providers });
expect(screen.getByText('reg-1')).toBeTruthy();
expect(screen.queryByText('reg-2')).toBeNull();
vi.advanceTimersByTime(WERKBAK_REFRESH_MS);
detectChanges();
expect(getBehandelWerkbak).toHaveBeenCalledTimes(2);
expect(screen.getByText('reg-2')).toBeTruthy();
// A background refresh must not flash the loading state over the rows the behandelaar is reading.
expect(screen.queryByText(/bezig met laden/i)).toBeNull();
} finally {
vi.useRealTimers();
}
});
it('keeps the rows on screen when a background refresh fails', async () => {
// A blip on a background poll must not replace the list with the load-failure alert; the next
// tick recovers. Only the first load speaks for whether the werkbak is readable at all.
vi.useFakeTimers();
try {
const getBehandelWerkbak = vi
.fn()
.mockReturnValueOnce(of(sample))
.mockReturnValue(throwError(() => new Error('503')));
const { providers } = setup({ getBehandelWerkbak });
const { detectChanges } = await render(WerkbakPage, { providers });
vi.advanceTimersByTime(WERKBAK_REFRESH_MS);
detectChanges();
expect(screen.getByText('reg-1')).toBeTruthy();
expect(screen.queryByText(/kon de werkbak niet laden/i)).toBeNull();
} finally {
vi.useRealTimers();
}
});
it('stops refreshing once the page is destroyed', async () => {
vi.useFakeTimers();
try {
const { getBehandelWerkbak, providers } = setup();
const { fixture } = await render(WerkbakPage, { providers });
fixture.destroy();
vi.advanceTimersByTime(WERKBAK_REFRESH_MS * 3);
expect(getBehandelWerkbak).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
it('clears a load failure once a refresh succeeds', async () => {
// Without this the werkbak stays stuck on the error until the behandelaar reloads — the very
// thing this slice removes. A recovered read must put the rows back.
vi.useFakeTimers();
try {
const getBehandelWerkbak = vi
.fn()
.mockReturnValueOnce(throwError(() => new Error('503')))
.mockReturnValue(of(sample));
const { providers } = setup({ getBehandelWerkbak });
const { detectChanges } = await render(WerkbakPage, { providers });
expect(screen.getByText(/kon de werkbak niet laden/i)).toBeTruthy();
vi.advanceTimersByTime(WERKBAK_REFRESH_MS);
detectChanges();
expect(screen.queryByText(/kon de werkbak niet laden/i)).toBeNull();
expect(screen.getByText('reg-1')).toBeTruthy();
} finally {
vi.useRealTimers();
}
});
it('shows an empty state when the werkbak has no items', async () => {
const { providers } = setup({ getBehandelWerkbak: vi.fn().mockReturnValue(of([])) });
await render(WerkbakPage, { providers });
+3 -34
View File
@@ -1,15 +1,7 @@
import { Component, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
import { BffApiV1Service, type WerkbakItem } from 'api-client';
import { UtrechtComponentsModule } from 'ui';
/**
* How often an open werkbak re-reads itself (S-26/#162, ADR-0032). Exported so the spec advances the
* clock by exactly one interval instead of hard-coding the number.
*/
export const WERKBAK_REFRESH_MS = 5_000;
/** The two decisions a behandelaar can make; the BFF validates these exact values (ADR-0013). */
type Besluit = 'goedkeuren' | 'afwijzen';
@@ -18,11 +10,6 @@ type Besluit = 'goedkeuren' | 'afwijzen';
* 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.
*
* The page also re-reads itself every {@link WERKBAK_REFRESH_MS} while it is open, so a registration
* that reaches beoordeling after the behandelaar opened the werkbak shows up on its own — no reload
* (S-26/#162). Polling rather than a pushed stream: nothing notifies the BFF either, so a stream
* would poll the domain in the BFF instead and add connection state for the same freshness (ADR-0032).
*/
@Component({
selector: 'app-werkbak-page',
@@ -40,37 +27,19 @@ export class WerkbakPage {
constructor() {
this.load();
// ponytail: a fixed interval, polled while the page lives — it keeps refreshing in a background
// tab. Gate on `document.visibilityState` if the request volume ever matters.
interval(WERKBAK_REFRESH_MS)
.pipe(takeUntilDestroyed())
.subscribe(() => this.load({ background: true }));
}
/**
* Read the werkbak. A `background` read is the interval refresh: it leaves the rows and the states
* the behandelaar is looking at alone until it has an answer — no loading flash on every tick, and
* a blip does not swap the list for the failure alert (the next tick recovers). Only a foreground
* read — on open, or after a decision — speaks for whether the werkbak is readable at all.
*/
load(options: { background?: boolean } = {}): void {
const background = options.background ?? false;
if (!background) {
this.loading.set(true);
this.failed.set(false);
}
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);
// A read that came back is the answer, so a refresh also clears an earlier failure — the
// werkbak recovers on its own instead of showing the error until someone reloads.
this.failed.set(false);
},
// Surface the failure (e.g. 403 for a non-behandelaar) instead of swallowing it.
error: () => {
if (background) return;
this.items.set([]);
this.loading.set(false);
this.loaded.set(true);
@@ -1,79 +0,0 @@
# ADR-0032: The werkbak refreshes itself by polling, not by a pushed stream
- **Status:** Accepted
- **Date:** 2026-09-04
- **Deciders:** Respellion engineering
- **Slice:** #162 (proposal #163). The issue titles it S-26; that id already belongs to
the self-service resume slice (#111), so #162 is the identifier that counts.
## Context
The werkbak (S-12) is a read of the open Flowable `Beoordelen` tasks: portal → BFF
`GET /behandel/werkbak` → domain `Werkbak` query → workflow engine, each task enriched
from its aggregate. A registration reaches `Beoordelen` **asynchronously**, only once the
citizen supplies its documents and the DMN routes it (S-10a) — so it appears in a werkbak
that is already open, and until now a behandelaar had to reload the page to see it.
Three forces shape the mechanism:
- **Nothing notifies anyone.** The trigger lives in Flowable. The domain does not publish
task events, and there is no bus between the domain and the BFF.
- **The BFF is stateless** and sits behind each portal's nginx.
- **This is the repo's first live-updating view**, so the choice sets a precedent.
## Decision
**The werkbak page re-reads the existing BFF endpoint on a fixed interval
(`WERKBAK_REFRESH_MS`, 5 s) while it is open. No new endpoint, dependency or server-side
state.**
The refresh is a *background* read: it leaves the rows and the loading/failure states
untouched until it has an answer, so a tick never flashes a spinner over rows a
behandelaar is reading and a single failed poll never swaps the list for the error alert.
A read that comes back also clears an earlier failure, so the view recovers on its own —
the same reload this slice set out to remove would otherwise be needed to escape a
transient error. Only a foreground read (on open, after a decision) speaks for whether the
werkbak is readable at all.
### Why not SSE or WebSockets
Neither buys freshness here, because **nothing notifies the BFF either**:
- **SSE** (`text/event-stream`) would mean a new streaming endpoint whose handler polls the
domain and forwards diffs — the same latency, plus connection lifecycle, nginx
buffering, and auth on a long-lived connection.
- **WebSocket/SignalR** adds a dependency (CLAUDE.md §13) and makes the BFF stateful and
sticky-session-bound. A genuine push path would *also* need the domain to publish task
events. Warranted by high-frequency, bidirectional or fan-out-heavy traffic; the werkbak
is none of those.
Polling meets the acceptance ("a registration can be seen in the werkbak once it is ready
for review") in a handful of lines inside one component.
- ponytail ceiling: a fixed 5 s interval, per open page, that keeps polling in a
background tab. Each tick costs one Flowable task query plus a store read per open task.
- Upgrade path: publish task events from the domain, then swap the component's `interval`
for a stream. The endpoint contract and the component's rendering stay as they are;
gate on `document.visibilityState` first if request volume is the concern.
## Consequences
**Positive**
- The outcome is delivered with no new endpoint, dependency, or server-side state, and no
service boundary moves.
- Self-healing: a transient read failure no longer strands the view until a manual reload.
- The e2e got *simpler* — the happy path waits for the werkbak row without reloading the
page, which is itself the live-refresh assertion.
**Negative / costs**
- Staleness is bounded by one interval (≤5 s) rather than instant.
- One `GET /behandel/werkbak` per open werkbak per interval, including in hidden tabs.
- The precedent is polling; a future view with genuinely high-frequency updates will have
to revisit this (see the upgrade path above).
## Coupling rules touched (CLAUDE.md §8)
None. The poll reuses the existing portal → BFF → domain read path: §8.3 (portals talk
only to the BFF) and §8.2 (only the Workflow Client talks to Flowable) are unchanged.
-34
View File
@@ -5,40 +5,6 @@ copy-pasteable walkthrough against a local `make up` stack.
---
## S-26/#162 — the werkbak refreshes itself (ADR-0032)
**Outcome:** a registration that reaches beoordeling while a behandelaar already has the werkbak open
**appears on its own** — no reload. The page re-reads `GET /behandel/werkbak` every 5 seconds; a
background refresh swaps the rows in without flashing the loading state, and a transient failure no
longer strands the view on its error message until someone reloads.
```bash
# 1. Two windows. Left: the behandel werkbak, already open and idle.
python3 infra/keycloak/check_realms.py otp # a code, valid right now
open http://localhost:8142 # merel-behandelaar / test123 + that code
#
# 2. Right: submit a registration and supply its documents (this is what routes it to Beoordelen).
open http://localhost:8140 # jan-burger / test123 → indienen → upload a PDF
#
# 3. Watch the left window. Within ~5 seconds the new reference appears in the werkbak — the page was
# never reloaded and never left the werkbak.
#
# 4. Automated, end to end: the happy path now waits for the werkbak row WITHOUT reloading, so the
# absence of the reload IS the assertion.
make verify-e2e # → registration.spec: "… → behandelaar goedkeurt → public INGESCHREVEN"
#
# 5. Component level (background refresh, failure recovery, teardown):
pnpm nx test behandel # → "picks up a newly submitted registration without a reload" (+3 guards)
```
**The path:** unchanged — portal → BFF `GET /behandel/werkbak` → domain `Werkbak` → Flowable. Only the
page's cadence is new: `interval(WERKBAK_REFRESH_MS)` scoped to the page with `takeUntilDestroyed()`.
**Not push:** nothing notifies the BFF either, so SSE/WebSockets would poll the domain inside the BFF
for the same freshness plus connection state — see ADR-0032 for the trade-off and the upgrade path.
---
## S-19a — approval writes the register record to Objecten (#149, ADR-0028)
**Outcome:** approving a registration no longer only moves the ZGW zaak to its eindstatus — it also
-6
View File
@@ -63,11 +63,5 @@ Or enrol a phone once: the secret in base32 is `IJEUOTKFIRCVORKSJNCVET2UKBJUKRKE
(`otpauth://totp/medewerker?secret=IJEUOTKFIRCVORKSJNCVET2UKBJUKRKE`). The e2e computes its
own code in `tests/e2e/medewerker-login.ts`.
**A code is single-use.** Keycloak's `otpPolicyCodeReusable` defaults to false, so it refuses a
code it has already accepted — a second login as the same medewerker inside the same 30-second
window fails with `invalid_grant` / *Invalid user credentials*, even though the code is current.
Nothing to fix in the realm: wait for the next window, or spend the following counter, which is
what `nextUnusedCounter` in `tests/e2e/medewerker-login.ts` does for back-to-back specs.
**Fixture only.** A shared, committed secret is a demo convenience, never a production
posture — see the ADR's consequences.
-14
View File
@@ -1,14 +0,0 @@
import { expect, test } from '@playwright/test';
import { OTP_PERIOD_MS, nextUnusedCounter } from './medewerker-login';
// Pure check of the TOTP counter guard in loginMedewerker — no browser, no stack. Keycloak refuses
// a code it has already accepted (its otpPolicyCodeReusable defaults to false), so two logins as
// the same medewerker inside one 30-second window must not spend the same counter twice (#132).
test('a login never spends a TOTP counter this medewerker already used', () => {
const now = 3 * OTP_PERIOD_MS + 1_000; // 1 second into counter 3
expect(nextUnusedCounter(now, -1)).toBe(3); // nothing spent yet → the current counter
expect(nextUnusedCounter(now, 3)).toBe(4); // the current counter is spent → the next one
expect(nextUnusedCounter(now, 4)).toBe(5); // two logins already in this window → the one after
expect(nextUnusedCounter(now + OTP_PERIOD_MS, 3)).toBe(4); // window moved on → current again
});
+4 -34
View File
@@ -1,7 +1,4 @@
import { createHmac } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { Page } from '@playwright/test';
// The medewerker realm enforces MFA (S-15c), so a staff login is two steps: password, then a TOTP
@@ -9,49 +6,22 @@ import type { Page } from '@playwright/test';
// secret bytes — so the e2e can compute a valid code instead of enrolling an authenticator.
const OTP_SECRET = 'BIGMEDEWERKEROTPSEED';
export const OTP_PERIOD_MS = 30_000;
// RFC 6238 TOTP: HMAC-SHA1 over the 30-second counter, dynamically truncated to 6 digits.
export function totp(secret = OTP_SECRET, at = Date.now()): string {
const counter = Buffer.alloc(8);
counter.writeBigUInt64BE(BigInt(Math.floor(at / OTP_PERIOD_MS)));
counter.writeBigUInt64BE(BigInt(Math.floor(at / 1000 / 30)));
const mac = createHmac('sha1', secret).update(counter).digest();
const offset = mac[mac.length - 1] & 0x0f;
return String((mac.readUInt32BE(offset) & 0x7fffffff) % 1_000_000).padStart(6, '0');
}
// Keycloak refuses a TOTP code it has already accepted (its otpPolicyCodeReusable defaults to
// false), so two logins as the same medewerker inside one 30-second window would both submit the
// same code and the second is rejected. Spend the first counter this medewerker has left.
export function nextUnusedCounter(now: number, spent: number): number {
return Math.max(Math.floor(now / OTP_PERIOD_MS), spent + 1);
}
// The spent counter lives on disk rather than in module state: Playwright starts a fresh worker
// process for a retry, which would otherwise forget it and resubmit the rejected code.
function spendCounter(username: string): number {
const file = join(tmpdir(), `otp-counter-${username}`);
let spent = -1;
try {
spent = Number(readFileSync(file, 'utf8')) || -1;
} catch {
// first login as this medewerker in this run
}
const counter = nextUnusedCounter(Date.now(), spent);
writeFileSync(file, String(counter));
return counter;
}
export async function loginMedewerker(page: Page, username: string): Promise<void> {
await page.locator('#username').fill(username);
await page.locator('#password').fill('test123');
await page.locator('#kc-login').click();
// Keycloak's conditional-OTP step. Wait out the rest of the window if the counter we may spend is
// still in the future; its lookAheadWindow would accept the code a moment early, but only by one
// counter — waiting keeps a third login in the same window valid too.
const counter = spendCounter(username);
await page.waitForTimeout(Math.max(0, counter * OTP_PERIOD_MS - Date.now()));
await page.locator('#otp').fill(totp(OTP_SECRET, counter * OTP_PERIOD_MS));
// Keycloak's conditional-OTP step. Its lookAheadWindow accepts the neighbouring counters, so a
// code computed just before a 30-second boundary still validates — no retry needed.
await page.locator('#otp').fill(totp());
await page.locator('#kc-login').click();
}
+20 -25
View File
@@ -59,32 +59,12 @@ test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt
await expect(staff.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGEDIEND' }))
.toBeVisible();
// A behandelaar opens the behandel-portal werkbak and approves the registration (goedkeuren) — the
// S-12 flow that replaces the temporary admin endpoint. The staff tab switches to the medewerker
// realm (a different Keycloak realm than the citizen's digid session).
//
// The werkbak is opened BEFORE the citizen supplies the documents that route the registration to
// Beoordelen, so its row cannot be there at page load: the only thing that can deliver it to this
// already-open page is the werkbak refreshing itself (S-26/#162, ADR-0032). This spec used to
// `staff.reload()` in a poll loop here; the absence of that reload is the live-refresh assertion.
await staff.goto('http://behandel/');
// That realm enforces MFA (S-15c), so the behandelaar logs in with password + TOTP.
await loginMedewerker(staff, 'merel-behandelaar');
await expect(staff.getByRole('heading', { name: /Werkbak/i })).toBeVisible();
// 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 = staff.getByRole('button', { name: `Goedkeuren ${reference}` });
await expect(goedkeuren, 'the registration is not awaiting beoordeling yet').toBeHidden();
// Provide the documents the registration is waiting for (S-10a), on the still-open self-service tab.
// The process parks at WachtOpDocumenten only after the zaak is opened; the INGEDIEND row above proves
// the zaak exists — so the OpenZaak worker has completed and the process is now at the wait — which is
// why we supply the documents here rather than right after submit, when the trigger would race the
// wait and no-op. (S-10b turns this into a real file upload; here it is the trigger that unblocks
// beoordeling.)
await page.bringToFront();
await page.setInputFiles('#diploma', {
name: 'diploma.pdf',
mimeType: 'application/pdf',
@@ -93,11 +73,26 @@ test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt
await page.getByRole('button', { name: /documenten aanleveren/i }).click();
await expect(page.getByText(/documenten zijn aangeleverd/i)).toBeVisible();
// Back to the werkbak — untouched since login, never reloaded. The row arrives on its own once the
// DMN routes the registration to Beoordelen. (Foregrounded so Chromium doesn't throttle the page's
// refresh timer as a hidden tab.)
await staff.bringToFront();
await expect(goedkeuren).toBeVisible({ timeout: 30_000 });
// 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. The staff tab switches to the
// medewerker realm (a different Keycloak realm than the citizen's digid session).
await staff.goto('http://behandel/');
// That realm enforces MFA (S-15c), so the behandelaar logs in with password + TOTP.
await loginMedewerker(staff, 'merel-behandelaar');
await expect(staff.getByRole('heading', { name: /Werkbak/i })).toBeVisible();
// The registration reaches the Beoordelen user task only after its documents are provided (above), 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 = staff.getByRole('button', { name: `Goedkeuren ${reference}` });
await expect
.poll(async () => {
await staff.reload();
return goedkeuren.count();
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
.toBeGreaterThan(0);
// 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