Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b206a005f | ||
|
|
d0fb2b3e8c |
@@ -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 { WerkbakPage } from './werkbak-page';
|
||||
import { WERKBAK_REFRESH_MS, WerkbakPage } from './werkbak-page';
|
||||
|
||||
const sample: WerkbakItem[] = [
|
||||
{ registrationId: 'reg-1', bsn: '123456782', status: 'InBehandeling' },
|
||||
@@ -81,6 +81,94 @@ 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 });
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
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';
|
||||
|
||||
@@ -10,6 +18,11 @@ 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',
|
||||
@@ -27,19 +40,37 @@ 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 }));
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.failed.set(false);
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# ADR-0031 — MFA on the medewerker realm, with a fixture TOTP secret
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-09-03
|
||||
- **Slice:** S-15c (Gitea #132)
|
||||
|
||||
## Context
|
||||
|
||||
Staff (behandelaar, teamlead, beheerder) act on citizens' registrations and on the ACL's
|
||||
default-fill: the highest-privilege logins in the platform. The medewerker realm protected
|
||||
them with a password alone, while the citizen realms (digid, eherkenning, eidas) mock
|
||||
brokers that carry their own assurance levels. A reference application that demonstrates a
|
||||
government architecture should show MFA on the staff realm.
|
||||
|
||||
Two things had to be decided: **how** to enforce OTP in a realm export, and **how the
|
||||
automated checks and a human demo obtain a code** — the e2e drives a real browser login and
|
||||
`make keycloak-smoke` drives a real password grant, so neither can scan a QR.
|
||||
|
||||
## Decision
|
||||
|
||||
**Enforce OTP by giving every seeded medewerker a TOTP credential**, rather than replacing
|
||||
Keycloak's browser flow with a copy whose OTP execution is `REQUIRED`.
|
||||
|
||||
Keycloak's stock `browser` and `direct grant` flows both contain a *conditional OTP*
|
||||
subflow that fires when the user has an OTP credential. Seeding the credential therefore
|
||||
turns the challenge on for every seeded user, in both flows, without duplicating ~40 lines
|
||||
of flow JSON into the export. `CONFIGURE_TOTP` is additionally set as a **default required
|
||||
action**, so a medewerker created later must enrol before their first login.
|
||||
|
||||
**The seeded secret is a fixed, committed fixture** (`BIGMEDEWERKEROTPSEED`) shared by all
|
||||
medewerkers. Codes are then computable: `infra/keycloak/check_realms.py` (Python, stdlib
|
||||
`hmac`) and `tests/e2e/medewerker-login.ts` (Node `crypto`) each implement RFC 6238 in
|
||||
about six lines — no OTP dependency on either side, and no enrolment step in the tests.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A password alone no longer yields a token on the medewerker realm; `check_realms.py`
|
||||
asserts that refusal, so the enforcement cannot silently regress.
|
||||
- Every medewerker login in the e2e goes through `loginMedewerker()`, which submits the OTP
|
||||
form. New staff specs must use it.
|
||||
- **The secret is public.** It is a demo fixture and worthless outside this synthetic
|
||||
stack, in the same class as the committed `test123` passwords and the mock DigiD broker.
|
||||
A real deployment enrols per-user authenticators (or federates to DigiD Machtigen /
|
||||
eHerkenning at the required assurance level) and seeds no credentials at all.
|
||||
- Enforcement is *effectively* realm-wide but *technically* per-user: the conditional
|
||||
subflow is what fires. A medewerker whose OTP credential were removed would fall back to
|
||||
the required action at next login (enrol, then challenge) rather than skipping MFA — an
|
||||
acceptable equivalence for this purpose, and the reason the required action is set.
|
||||
- Reversal is a one-file edit: drop the `otp` credentials and the `requiredActions` block.
|
||||
@@ -0,0 +1,79 @@
|
||||
# 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.
|
||||
+70
-4
@@ -5,6 +5,40 @@ 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
|
||||
@@ -140,7 +174,8 @@ zaaktype cache). Store is in-memory: an edit reverts to the configured env on re
|
||||
|
||||
```bash
|
||||
make up
|
||||
# 1. Log in as bram-beheerder / test123 → "Default-fill" tab → change a value → Opslaan.
|
||||
# 1. Log in as bram-beheerder / test123 + OTP (`python3 infra/keycloak/check_realms.py otp`)
|
||||
# → "Default-fill" tab → change a value → Opslaan.
|
||||
open http://localhost:8143/default-fill
|
||||
#
|
||||
# 2. Automated: the ACL uses the current default-fill per zaak (unit) and the endpoints are behind the
|
||||
@@ -161,7 +196,8 @@ directly (ADR-0025); managing the default-fill config (S-15b) and MFA (S-15c) co
|
||||
|
||||
```bash
|
||||
make up
|
||||
# 1. Log in as bram-beheerder / test123 → the catalogus lists the published zaaktypen.
|
||||
# 1. Log in as bram-beheerder / test123 + OTP (`python3 infra/keycloak/check_realms.py otp`)
|
||||
# → the catalogus lists the published zaaktypen.
|
||||
open http://localhost:8143
|
||||
#
|
||||
# 2. Automated (a CI verify-stack e2e): a beheerder logs in and sees BIG-REGISTRATIE.
|
||||
@@ -304,7 +340,8 @@ make verify-local # → "OK — a fresh local stack completed the flow with
|
||||
|
||||
# 3. Or by hand in the browser: log in at http://localhost:8140 (jan-burger / test123), submit +
|
||||
# upload a PDF, then approve it in the werkbak at http://localhost:8142 (merel-behandelaar /
|
||||
# test123); it shows as INGESCHREVEN in the openbaar register at http://localhost:8141.
|
||||
# test123 + OTP, see S-15c); it shows as INGESCHREVEN in the openbaar register at
|
||||
# http://localhost:8141.
|
||||
```
|
||||
|
||||
> The zaaktype is discovered by the ACL itself since S-27 (below); `local-seed`'s `acl.env` now
|
||||
@@ -589,7 +626,7 @@ or **afwijzen** — which also completes the Beoordelen task so the process adva
|
||||
|
||||
```text
|
||||
# 1. Open the behandel portal and log in as a behandelaar (medewerker realm):
|
||||
# http://localhost:8142/ → merel-behandelaar / test123
|
||||
# http://localhost:8142/ → merel-behandelaar / test123 + OTP
|
||||
#
|
||||
# 2. The werkbak lists the registrations awaiting beoordeling (referentie / bsn / status).
|
||||
# Find the reference from the submit confirmation and click "Goedkeuren" on that row.
|
||||
@@ -812,3 +849,32 @@ make verify-domain # → "the timed-out registration's zaak was cancelled to
|
||||
`POST /annuleringen` → ZGW `resultaten` + `statussen` (Geannuleerd); the aggregate then moves to
|
||||
`Verlopen`. The ACL cancels the zaak **before** the aggregate is expired, so a failed ZGW call leaves the
|
||||
job for redelivery rather than diverging the two (ADR-0019).
|
||||
|
||||
---
|
||||
|
||||
## S-15c — MFA on the medewerker realm (#132, ADR-0031)
|
||||
|
||||
**Outcome:** staff logins (behandel + beheer portals) need a **second factor**. The medewerker realm
|
||||
seeds every medewerker with a TOTP credential, so Keycloak's conditional-OTP step challenges them in
|
||||
both the browser flow and the direct grant; a password alone no longer yields a token. `CONFIGURE_TOTP`
|
||||
is a default required action, so a medewerker added later must enrol first. Citizen realms (digid,
|
||||
eherkenning, eidas) are unchanged — they mock brokers that carry their own assurance.
|
||||
|
||||
```bash
|
||||
# 1. Manual: log in to the behandel portal. After username + password Keycloak asks for a code.
|
||||
python3 infra/keycloak/check_realms.py otp # a valid code, right now
|
||||
open http://localhost:8142 # merel-behandelaar / test123 + that code
|
||||
#
|
||||
# 2. Automated: the realm smoke check asserts the password alone is REFUSED, then that
|
||||
# password + TOTP succeeds and still carries the behandelaar role:
|
||||
make keycloak-smoke # → "medewerker merel-behandelaar password-only login refused [OK]"
|
||||
#
|
||||
# 3. End-to-end: every staff login in the e2e goes through the OTP prompt (loginMedewerker):
|
||||
make verify-e2e # → registration.spec (behandelaar approves), catalogus.spec, default-fill.spec
|
||||
```
|
||||
|
||||
**The path:** the seeded `otp` credential in `infra/keycloak/realms/medewerker-realm.json` activates
|
||||
Keycloak's stock conditional-OTP subflow — no custom browser flow. The fixture secret is shared and
|
||||
committed on purpose so the checks can compute codes; a real deployment enrols per-user authenticators
|
||||
(ADR-0031).
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ login per realm and asserts the identifying claim:
|
||||
| eidas | pierre-dupont | `eidas_id` |
|
||||
| medewerker | merel-behandelaar | role `behandelaar` |
|
||||
|
||||
The medewerker row also asserts that the password **alone** is refused — that realm
|
||||
enforces MFA (below).
|
||||
|
||||
All test users / credentials are in [../synthetic-data.md](../synthetic-data.md).
|
||||
|
||||
## Notes
|
||||
@@ -35,3 +38,36 @@ All test users / credentials are in [../synthetic-data.md](../synthetic-data.md)
|
||||
- **Image** pinned to `quay.io/keycloak/keycloak:26.1`.
|
||||
- Claims are injected by OIDC protocol mappers on `big-portal` (user attribute → token
|
||||
claim); `medewerker` roles come through `realm_access.roles`.
|
||||
|
||||
## MFA on the medewerker realm (S-15c)
|
||||
|
||||
Staff logins (behandel + beheer portals) need a second factor; citizen/company realms
|
||||
(digid, eherkenning, eidas) do not. Two halves in `medewerker-realm.json`:
|
||||
|
||||
- Every seeded medewerker carries a **TOTP credential** with the fixture secret
|
||||
`BIGMEDEWERKEROTPSEED`, so Keycloak's built-in *conditional OTP* step fires on every
|
||||
login — browser flow (an `#otp` prompt after the password) and direct grant (a `totp`
|
||||
form field) alike.
|
||||
- `CONFIGURE_TOTP` is a **default required action**, so any medewerker added later must
|
||||
enrol an authenticator before the first login.
|
||||
|
||||
See [../architecture/adr-0031-mfa-on-the-medewerker-realm.md](../architecture/adr-0031-mfa-on-the-medewerker-realm.md).
|
||||
|
||||
### Getting a code
|
||||
|
||||
```bash
|
||||
python3 infra/keycloak/check_realms.py otp # prints a valid 6-digit code right now
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -19,6 +19,11 @@ All test users share the password **`test123`**.
|
||||
| `eidas` | eIDAS (EU) | `pierre-dupont` | `eidas_id` = `FR/NL/AB-1234-5678` |
|
||||
| `medewerker` | Internal staff | `merel-behandelaar` | role `behandelaar` |
|
||||
| `medewerker` | Internal staff | `tom-teamlead` | roles `behandelaar`, `teamlead` |
|
||||
| `medewerker` | Internal staff | `bram-beheerder` | role `beheerder` |
|
||||
|
||||
`medewerker` users additionally need a **second factor**: that realm enforces MFA (S-15c,
|
||||
ADR-0031). All three share the fixture TOTP secret `BIGMEDEWERKEROTPSEED`; print a current
|
||||
code with `python3 infra/keycloak/check_realms.py otp`.
|
||||
|
||||
The identifying claims are injected via OIDC protocol mappers on `big-portal`
|
||||
(user-attribute → token claim); `medewerker` roles appear in `realm_access.roles`.
|
||||
@@ -32,5 +37,8 @@ curl -s -X POST \
|
||||
-d username=jan-burger -d password=test123 -d scope=openid | jq -r .access_token
|
||||
```
|
||||
|
||||
For a `medewerker` user, add `-d totp=$(python3 infra/keycloak/check_realms.py otp)` —
|
||||
without it the grant is refused with `invalid_grant`.
|
||||
|
||||
Decode the JWT payload to see the `bsn` claim. `make keycloak-smoke` checks every realm
|
||||
automatically.
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Smoke-check the Keycloak realms: each realm's OIDC login works (password grant)
|
||||
and returns its expected identifying claim. Stdlib only. Exits non-zero on failure.
|
||||
and returns its expected identifying claim. The medewerker realm additionally enforces
|
||||
MFA (S-15c), so its login must be refused without a TOTP code. Stdlib only.
|
||||
Exits non-zero on failure.
|
||||
"""
|
||||
import base64, json, sys, urllib.error, urllib.parse, urllib.request
|
||||
import base64, hashlib, hmac, json, struct, sys, time, urllib.error, urllib.parse, urllib.request
|
||||
|
||||
BASE = "http://localhost:8180"
|
||||
CLIENT = "big-portal"
|
||||
PWD = "test123"
|
||||
|
||||
# realm, user, claim ("__roles__" => check realm_access.roles), expected-contains
|
||||
# Fixture TOTP secret seeded into every medewerker in infra/keycloak/realms/medewerker-realm.json.
|
||||
# Keycloak HMACs the raw secret bytes, so no base32 decoding is involved.
|
||||
OTP_SECRET = b"BIGMEDEWERKEROTPSEED"
|
||||
|
||||
# realm, user, claim ("__roles__" => check realm_access.roles), expected-contains, mfa-enforced
|
||||
CHECKS = [
|
||||
("digid", "jan-burger", "bsn", "123456782"),
|
||||
("eherkenning", "acme-ondernemer", "kvk", "12345678"),
|
||||
("eidas", "pierre-dupont", "eidas_id", "FR/NL"),
|
||||
("medewerker", "merel-behandelaar", "__roles__", "behandelaar"),
|
||||
("digid", "jan-burger", "bsn", "123456782", False),
|
||||
("eherkenning", "acme-ondernemer", "kvk", "12345678", False),
|
||||
("eidas", "pierre-dupont", "eidas_id", "FR/NL", False),
|
||||
("medewerker", "merel-behandelaar", "__roles__", "behandelaar", True),
|
||||
]
|
||||
|
||||
|
||||
@@ -23,10 +29,17 @@ def decode(jwt):
|
||||
return json.loads(base64.urlsafe_b64decode(p))
|
||||
|
||||
|
||||
def grant(realm, user):
|
||||
def totp(secret=OTP_SECRET, period=30, digits=6):
|
||||
"""RFC 6238 code: HMAC-SHA1 over the 30-second counter, dynamically truncated."""
|
||||
mac = hmac.new(secret, struct.pack(">Q", int(time.time()) // period), hashlib.sha1).digest()
|
||||
o = mac[-1] & 0x0F
|
||||
return str((struct.unpack(">I", mac[o:o + 4])[0] & 0x7FFFFFFF) % 10 ** digits).zfill(digits)
|
||||
|
||||
|
||||
def grant(realm, user, **extra):
|
||||
data = urllib.parse.urlencode({
|
||||
"grant_type": "password", "client_id": CLIENT,
|
||||
"username": user, "password": PWD, "scope": "openid",
|
||||
"username": user, "password": PWD, "scope": "openid", **extra,
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{BASE}/realms/{realm}/protocol/openid-connect/token", data=data,
|
||||
@@ -35,11 +48,27 @@ def grant(realm, user):
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
def second_factor_refused(realm, user):
|
||||
"""The password alone must not yield a token on an MFA-enforced realm."""
|
||||
try:
|
||||
grant(realm, user)
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code in (400, 401)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
ok = True
|
||||
for realm, user, claim, expect in CHECKS:
|
||||
for realm, user, claim, expect, mfa in CHECKS:
|
||||
extra = {}
|
||||
if mfa:
|
||||
refused = second_factor_refused(realm, user)
|
||||
ok = ok and refused
|
||||
print(f"{realm:12} {user:18} password-only login refused "
|
||||
f"[{'OK' if refused else 'MFA NOT ENFORCED'}]")
|
||||
extra = {"totp": totp()}
|
||||
try:
|
||||
at = decode(grant(realm, user)["access_token"])
|
||||
at = decode(grant(realm, user, **extra)["access_token"])
|
||||
if claim == "__roles__":
|
||||
val = at.get("realm_access", {}).get("roles", [])
|
||||
good = expect in val
|
||||
@@ -57,4 +86,9 @@ def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
# `check_realms.py otp` prints a current code for the fixture secret — what a human demoing
|
||||
# the medewerker portals types at Keycloak's OTP prompt (docs/runbooks/keycloak.md).
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "otp":
|
||||
print(totp())
|
||||
else:
|
||||
main()
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
"realm": "medewerker",
|
||||
"enabled": true,
|
||||
"displayName": "Medewerkers",
|
||||
"requiredActions": [
|
||||
{
|
||||
"alias": "CONFIGURE_TOTP",
|
||||
"name": "Configure OTP",
|
||||
"providerId": "CONFIGURE_TOTP",
|
||||
"enabled": true,
|
||||
"defaultAction": true,
|
||||
"priority": 10
|
||||
}
|
||||
],
|
||||
"roles": {
|
||||
"realm": [
|
||||
{ "name": "behandelaar", "description": "Behandelt registratieaanvragen" },
|
||||
@@ -43,7 +53,15 @@
|
||||
"lastName": "Behandelaar",
|
||||
"email": "merel@big.example.nl",
|
||||
"emailVerified": true,
|
||||
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
||||
"credentials": [
|
||||
{ "type": "password", "value": "test123", "temporary": false },
|
||||
{
|
||||
"type": "otp",
|
||||
"userLabel": "seeded TOTP (fixture)",
|
||||
"secretData": "{\"value\":\"BIGMEDEWERKEROTPSEED\"}",
|
||||
"credentialData": "{\"subType\":\"totp\",\"digits\":6,\"counter\":0,\"period\":30,\"algorithm\":\"HmacSHA1\"}"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["behandelaar"]
|
||||
},
|
||||
{
|
||||
@@ -53,7 +71,15 @@
|
||||
"lastName": "Teamlead",
|
||||
"email": "tom@big.example.nl",
|
||||
"emailVerified": true,
|
||||
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
||||
"credentials": [
|
||||
{ "type": "password", "value": "test123", "temporary": false },
|
||||
{
|
||||
"type": "otp",
|
||||
"userLabel": "seeded TOTP (fixture)",
|
||||
"secretData": "{\"value\":\"BIGMEDEWERKEROTPSEED\"}",
|
||||
"credentialData": "{\"subType\":\"totp\",\"digits\":6,\"counter\":0,\"period\":30,\"algorithm\":\"HmacSHA1\"}"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["behandelaar", "teamlead"]
|
||||
},
|
||||
{
|
||||
@@ -63,7 +89,15 @@
|
||||
"lastName": "Beheerder",
|
||||
"email": "bram@big.example.nl",
|
||||
"emailVerified": true,
|
||||
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
||||
"credentials": [
|
||||
{ "type": "password", "value": "test123", "temporary": false },
|
||||
{
|
||||
"type": "otp",
|
||||
"userLabel": "seeded TOTP (fixture)",
|
||||
"secretData": "{\"value\":\"BIGMEDEWERKEROTPSEED\"}",
|
||||
"credentialData": "{\"subType\":\"totp\",\"digits\":6,\"counter\":0,\"period\":30,\"algorithm\":\"HmacSHA1\"}"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["beheerder"]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { loginMedewerker } from './medewerker-login';
|
||||
|
||||
// S-15a walking skeleton: a beheerder logs in to the beheer portal (medewerker realm) and sees the
|
||||
// read-only ZTC catalogus. The verify stack seeds and publishes the BIG-REGISTRATIE zaaktype (the
|
||||
@@ -7,10 +8,9 @@ import { expect, test } from '@playwright/test';
|
||||
test('a beheerder sees the published zaaktypen in the catalogus', async ({ page }) => {
|
||||
await page.goto('http://beheer/');
|
||||
|
||||
// The beheer portal redirects to the Keycloak medewerker realm login (same realm as behandel).
|
||||
await page.locator('#username').fill('bram-beheerder');
|
||||
await page.locator('#password').fill('test123');
|
||||
await page.locator('#kc-login').click();
|
||||
// The beheer portal redirects to the Keycloak medewerker realm login (same realm as behandel),
|
||||
// which enforces MFA: password, then a TOTP code.
|
||||
await loginMedewerker(page, 'bram-beheerder');
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Catalogus/i })).toBeVisible();
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { loginMedewerker } from './medewerker-login';
|
||||
|
||||
// S-15b: a beheerder edits the ACL default-fill in the beheer portal and gets a saved confirmation.
|
||||
// Runs against the shared verify stack; it edits + saves (the ACL store is in-memory, ADR-0026) and
|
||||
@@ -6,10 +7,8 @@ import { expect, test } from '@playwright/test';
|
||||
test('a beheerder edits and saves the default-fill', async ({ page }) => {
|
||||
await page.goto('http://beheer/');
|
||||
|
||||
// Keycloak medewerker-realm login (same realm as behandel).
|
||||
await page.locator('#username').fill('bram-beheerder');
|
||||
await page.locator('#password').fill('test123');
|
||||
await page.locator('#kc-login').click();
|
||||
// Keycloak medewerker-realm login (same realm as behandel) — password + enforced TOTP.
|
||||
await loginMedewerker(page, 'bram-beheerder');
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Catalogus/i })).toBeVisible();
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
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
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
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
|
||||
// code. The realm export seeds every medewerker with this fixture secret — Keycloak HMACs the raw
|
||||
// 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)));
|
||||
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));
|
||||
await page.locator('#kc-login').click();
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, request, test } from '@playwright/test';
|
||||
import { loginMedewerker } from './medewerker-login';
|
||||
|
||||
// Walking-skeleton happy path (S-08d + S-09 + S-09b + S-12 + S-10a + S-19b-2): a zorgprofessional
|
||||
// logs in via mock DigiD and submits through the self-service portal → BFF → domain; the entry
|
||||
@@ -58,12 +59,32 @@ 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',
|
||||
@@ -72,27 +93,11 @@ 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();
|
||||
|
||||
// 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/');
|
||||
await staff.locator('#username').fill('merel-behandelaar');
|
||||
await staff.locator('#password').fill('test123');
|
||||
await staff.locator('#kc-login').click();
|
||||
|
||||
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);
|
||||
// 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 });
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user