Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b30fa664d8 | ||
|
|
0dd26a711a | ||
|
|
7e0897a41e | ||
|
|
744f91a2b2 | ||
|
|
b496ac9477 | ||
|
|
88a601123b | ||
|
|
62fb986701 | ||
|
|
ceb65991de | ||
|
|
8af09b2c92 | ||
|
|
142ed454aa | ||
|
|
566ef7dd64 | ||
|
|
06c0444859 |
@@ -71,11 +71,8 @@ build:
|
|||||||
|
|
||||||
## unit: run unit tests (excludes the container-backed Integration lane)
|
## unit: run unit tests (excludes the container-backed Integration lane)
|
||||||
# TRX per test project (→ TestResults/) feeds the CI per-service summary (#136); harmless locally.
|
# TRX per test project (→ TestResults/) feeds the CI per-service summary (#136); harmless locally.
|
||||||
# The CI reporting scripts are stdlib Python with their own assert-based self-checks (#161) — they
|
|
||||||
# ride this lane so a broken job summary is caught by CI rather than by the next red pipeline.
|
|
||||||
unit:
|
unit:
|
||||||
dotnet test $(SLN) -c Release --filter "Category!=Integration" --logger trx --results-directory TestResults
|
dotnet test $(SLN) -c Release --filter "Category!=Integration" --logger trx --results-directory TestResults
|
||||||
python3 infra/test_playwright_summary.py
|
|
||||||
|
|
||||||
## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below baseline)
|
## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below baseline)
|
||||||
# Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore`
|
# Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore`
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { of, throwError } from 'rxjs';
|
|||||||
import { BffApiV1Service, type WerkbakItem } from 'api-client';
|
import { BffApiV1Service, type WerkbakItem } from 'api-client';
|
||||||
import { AuthService } from 'auth';
|
import { AuthService } from 'auth';
|
||||||
import { axe } from 'vitest-axe';
|
import { axe } from 'vitest-axe';
|
||||||
import { WERKBAK_REFRESH_MS, WerkbakPage } from './werkbak-page';
|
import { WerkbakPage } from './werkbak-page';
|
||||||
|
|
||||||
const sample: WerkbakItem[] = [
|
const sample: WerkbakItem[] = [
|
||||||
{ registrationId: 'reg-1', bsn: '123456782', status: 'InBehandeling' },
|
{ 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 () => {
|
it('shows an empty state when the werkbak has no items', async () => {
|
||||||
const { providers } = setup({ getBehandelWerkbak: vi.fn().mockReturnValue(of([])) });
|
const { providers } = setup({ getBehandelWerkbak: vi.fn().mockReturnValue(of([])) });
|
||||||
await render(WerkbakPage, { providers });
|
await render(WerkbakPage, { providers });
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
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 { BffApiV1Service, type WerkbakItem } from 'api-client';
|
||||||
import { UtrechtComponentsModule } from 'ui';
|
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). */
|
/** The two decisions a behandelaar can make; the BFF validates these exact values (ADR-0013). */
|
||||||
type Besluit = 'goedkeuren' | 'afwijzen';
|
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
|
* 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
|
* 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.
|
* (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({
|
@Component({
|
||||||
selector: 'app-werkbak-page',
|
selector: 'app-werkbak-page',
|
||||||
@@ -40,37 +27,19 @@ export class WerkbakPage {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.load();
|
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 {
|
||||||
* 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.loading.set(true);
|
||||||
this.failed.set(false);
|
this.failed.set(false);
|
||||||
}
|
|
||||||
this.bff.getBehandelWerkbak().subscribe({
|
this.bff.getBehandelWerkbak().subscribe({
|
||||||
next: (rows: WerkbakItem[]) => {
|
next: (rows: WerkbakItem[]) => {
|
||||||
this.items.set(rows);
|
this.items.set(rows);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
this.loaded.set(true);
|
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.
|
// Surface the failure (e.g. 403 for a non-behandelaar) instead of swallowing it.
|
||||||
error: () => {
|
error: () => {
|
||||||
if (background) return;
|
|
||||||
this.items.set([]);
|
this.items.set([]);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
this.loaded.set(true);
|
this.loaded.set(true);
|
||||||
|
|||||||
@@ -67,14 +67,6 @@ itself, so no in-image healthcheck tool is required.
|
|||||||
- Three more images built each CI run (kept small; not on the health-gate list).
|
- Three more images built each CI run (kept small; not on the health-gate list).
|
||||||
- Storage is ephemeral container fs — a demo backplane, not a retention target.
|
- Storage is ephemeral container fs — a demo backplane, not a retention target.
|
||||||
Object storage for Tempo / remote-write for Prometheus is a later concern.
|
Object storage for Tempo / remote-write for Prometheus is a later concern.
|
||||||
- Tempo runs **single-binary**, so its distributor and ingester are one process and
|
|
||||||
some of its distributed-mode machinery is not just redundant but harmful. Its
|
|
||||||
ingester-pool health check is disabled (`ingester_client.pool_config`) because with
|
|
||||||
a single in-process ingester the check can never route around a failure — a 1s
|
|
||||||
loopback-gRPC deadline missed under CI load only evicted the one ingester and made
|
|
||||||
Tempo drop spans, which is how `verify-tracing` flaked (#156). Expect the same
|
|
||||||
shape from other distributed-mode knobs if we tune them; the fix is to switch to
|
|
||||||
real multi-ingester Tempo, not to re-enable them here.
|
|
||||||
|
|
||||||
## Coupling rules touched (CLAUDE.md §8)
|
## Coupling rules touched (CLAUDE.md §8)
|
||||||
|
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
# FDS-architectuur — Open Register
|
|
||||||
|
|
||||||
Deze map bevat de architectuurbesluiten en de engineer-documentatie voor de FDS-kant van deze
|
|
||||||
referentie-applicatie: deelnemen aan het Federatief Datastelsel als **afnemer**.
|
|
||||||
|
|
||||||
De strategische inzet, de slices en de portfoliostatus staan in het Innovation Lab-repo,
|
|
||||||
`Respellion/innovation-lab`, onder `projects/open-register-fd/`. Daar staan ook de
|
|
||||||
architectuurblauwdruk, de FDS gap-analyse en de privacy-views.
|
|
||||||
|
|
||||||
## Documenten
|
|
||||||
|
|
||||||
| Document | Waarvoor |
|
|
||||||
|---|---|
|
|
||||||
| [`c4-component-view.md`](c4-component-view.md) | Componentview op niveau 3: ports en adapters, en welke views nog waarde toevoegen |
|
|
||||||
| [`slice-1-proposal.md`](slice-1-proposal.md) | Het bouwbare eerste increment; plak dit in een `poc-voorstel`-issue |
|
|
||||||
| `adr/` | De geaccepteerde architectuurbesluiten, ADR-0001 tot en met ADR-0006. Zie de tabel hieronder. |
|
|
||||||
|
|
||||||
## Architecture Decision Records
|
|
||||||
|
|
||||||
Een ADR legt een besluit vast dat **vaststaat**, met de context en de gevolgen, zodat het niet stil
|
|
||||||
opnieuw wordt uitgevochten. Statuswaarden: `proposed` → `accepted` → (`vervangen door ADR-NNNN` |
|
|
||||||
`deprecated`).
|
|
||||||
|
|
||||||
Een geaccepteerde ADR wijzigen betekent een nieuwe ADR schrijven die de oude vervangt. Wij
|
|
||||||
herschrijven de historie nooit.
|
|
||||||
|
|
||||||
ADRs liggen naast governance. Acceptatie volgt de asynchrone bezwaarronde uit
|
|
||||||
`Respellion/innovation-lab`, `operating-model/operating-model.md`, sectie *Besluitvorming*.
|
|
||||||
|
|
||||||
| ADR | Besluit | Status |
|
|
||||||
|---|---|---|
|
|
||||||
| [0001](adr/0001-acl-at-every-register-boundary.md) | Anti-Corruption Layer op elke registergrens | accepted |
|
|
||||||
| [0002](adr/0002-fsc-for-connectivity.md) | FSC voor connectiviteit tussen organisaties, geen ruwe REST | accepted |
|
|
||||||
| [0003](adr/0003-pbac-via-opa.md) | Policy-based access control via OPA, FTV-klaar | accepted |
|
|
||||||
| [0004](adr/0004-bounded-cache.md) | Begrensde cache; registers blijven systeem van registratie | accepted |
|
|
||||||
| [0005](adr/0005-ldv-verwerkingenlog.md) | Verwerkingenlog via event-emissie, in lijn met LDV | accepted |
|
|
||||||
| [0006](adr/0006-module-boundary-and-reuse.md) | Modulegrens en hergebruikstrategie: in-process → .NET-module → OpenMetadata-feed → gateway op verzoek | accepted |
|
|
||||||
|
|
||||||
## Nummering
|
|
||||||
|
|
||||||
Deze reeks staat los van de ADR-reeks over de referentie-applicatie zelf, die in
|
|
||||||
[`../`](../adr-0001-loose-coupling.md) loopt van `adr-0001-loose-coupling` tot en met
|
|
||||||
`adr-0010-bff-oidc`. Vandaar de eigen map `fds/`: beide reeksen beginnen bij 0001, en de nummers
|
|
||||||
zouden anders over de volle breedte botsen.
|
|
||||||
|
|
||||||
In de MkDocs-navigatie staan deze zes daarom als **FDS ADR-000N**, zodat de zijbalk ze niet met de
|
|
||||||
reeks van de applicatie verwart.
|
|
||||||
|
|
||||||
Nieuwe FDS-ADR: kopieer [`adr/template.md`](adr/template.md), neem het volgende nummer, en open een
|
|
||||||
pull request.
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# ADR-0001: Anti-Corruption Layer op elke registergrens
|
|
||||||
|
|
||||||
- **Status:** accepted
|
|
||||||
- **Datum:** 2026-06-13
|
|
||||||
- **Deciders:** Lab Circle (Build, Lead Link)
|
|
||||||
- **Vervangt / vervangen door:** —
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
De applicatie bevraagt meerdere registers: BRP, NHR/KVK, en ZGW via OpenZaak. Hun vocabulaires en
|
|
||||||
schema's verschillen van elkaar en van ons domein. Zij veranderen ook zelf mee met de FDS-standaarden.
|
|
||||||
|
|
||||||
Lekt registervocabulaire het domeinmodel in, dan werkt elke wijziging aan de registerzijde door in de
|
|
||||||
bedrijfslogica. Het domein wordt dan een lappendeken van vreemde begrippen in plaats van ubiquitous
|
|
||||||
language.
|
|
||||||
|
|
||||||
## Besluit
|
|
||||||
|
|
||||||
Elk register is bereikbaar via een Anti-Corruption Layer: **één adapter per register**, die een
|
|
||||||
**port** vervult die het domein definieert.
|
|
||||||
|
|
||||||
Adapters doen alleen vertalen en velden versmallen. Zij bevatten geen bedrijfslogica. Het domein
|
|
||||||
spreekt `Persoon` en `Organisatie`, en nooit veldnamen uit BRP of NHR.
|
|
||||||
|
|
||||||
## Gevolgen
|
|
||||||
|
|
||||||
**Positief:** verloop in registers en FDS-standaarden blijft bij de adapter. Het domein blijft stabiel
|
|
||||||
en testbaar. Adapters zijn onafhankelijk vervangbaar, en dat is precies wat de FSC-wissel uit
|
|
||||||
ADR-0002 goedkoop maakt. Het patroon generaliseert naar een herbruikbare ACL-template per register,
|
|
||||||
een Foundations-kandidaat.
|
|
||||||
|
|
||||||
**Negatief en kosten:** één vertaalmap per register om te schrijven en te onderhouden, plus een extra
|
|
||||||
indirectie die engineers moeten respecteren in plaats van omzeilen.
|
|
||||||
|
|
||||||
**Vervolgwerk:** extraheer de ACL-template zodra de tweede adapter bestaat (slice 3).
|
|
||||||
|
|
||||||
## Overwogen alternatieven
|
|
||||||
|
|
||||||
- **Registers direct aanroepen uit de applicatieservices** — afgewezen: dit koppelt bedrijfscode aan
|
|
||||||
registerschema's en aan versies van FDS-standaarden.
|
|
||||||
- **Eén generieke registeradapter** — afgewezen: registers verschillen genoeg dat een generieke
|
|
||||||
abstractie zou gaan lekken of opzwellen. Adapters per register zijn duidelijker.
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# ADR-0002: FSC voor connectiviteit tussen organisaties, geen ruwe REST
|
|
||||||
|
|
||||||
- **Status:** accepted
|
|
||||||
- **Datum:** 2026-06-13
|
|
||||||
- **Deciders:** Lab Circle, Upstream Liaison
|
|
||||||
- **Vervangt / vervangen door:** —
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Registerbevragingen kruisen een organisatiegrens naar systemen van bronhouders met
|
|
||||||
persoonsgegevens. Het FDS noemt Federatieve Service Connectiviteit (FSC, de opvolger van NLX) als de
|
|
||||||
richting voor connectiviteit: wederzijdse authenticatie op organisatieniveau, autorisatie
|
|
||||||
gecontroleerd tegen een contract en gehandhaafd bij de bron, en symmetrische transactielogging.
|
|
||||||
|
|
||||||
Een ruwe REST-client met mTLS geeft ons geen van de contractadministratie, delegatie of onafhankelijke
|
|
||||||
tweezijdige verantwoording die een FG of auditor nodig heeft.
|
|
||||||
|
|
||||||
## Besluit
|
|
||||||
|
|
||||||
Het FSC Client-component stuurt alle registerbevragingen via een **FSC outway**, de
|
|
||||||
EUPL-referentie-implementatie. De ACL-adapter hangt af van de FSC Client, en niet van een HTTP-client.
|
|
||||||
|
|
||||||
FSC-zaken — contracten, identiteiten, delegatie — leven in dit component, achter de Register Port.
|
|
||||||
|
|
||||||
## Gevolgen
|
|
||||||
|
|
||||||
**Positief:** de autorisatie wordt bij de bron gehandhaafd, en niet op gezag van de aanroeper
|
|
||||||
vertrouwd. Onweerlegbaar loggen aan beide uiteinden maakt onafhankelijke afstemming tegen ons LDV-log
|
|
||||||
mogelijk. Delegatie wordt expliciet meegedragen. Wij lopen in lijn met de FDS-richting, vóór er een
|
|
||||||
verplichting is.
|
|
||||||
|
|
||||||
**Negatief en kosten:** FSC is operationeel zwaarder dan een REST-aanroep — beheer van certificaten en
|
|
||||||
identiteiten, plus een outway die op De Werf moet draaien. De vergelijking FSC tegenover DSP loopt
|
|
||||||
binnen het FDS nog, dus sommige details kunnen schuiven.
|
|
||||||
|
|
||||||
**Vervolgwerk:** valideer het contract- en logginggedrag van de huidige fsc-nlx-implementatie
|
|
||||||
(slice 2). Herzie dit als het FDS voor DSP kiest; ADR-0001 houdt die wissel beperkt tot één component.
|
|
||||||
|
|
||||||
## Overwogen alternatieven
|
|
||||||
|
|
||||||
- **Ruwe REST met mTLS** — afgewezen: geen contractlaag, geen tweezijdig log, en het wijkt af van het
|
|
||||||
FDS.
|
|
||||||
- **Wachten tot het FDS FSC tegenover DSP heeft beslist** — afgewezen: de naad uit ADR-0001 laat ons nu
|
|
||||||
adopteren en later aanpassen. Wachten geeft het voordeel van vroege expertise weg.
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# ADR-0003: Policy-based access control via OPA, FTV-klaar
|
|
||||||
|
|
||||||
- **Status:** accepted
|
|
||||||
- **Datum:** 2026-06-13
|
|
||||||
- **Deciders:** Lab Circle, FG (geconsulteerd)
|
|
||||||
- **Vervangt / vervangen door:** —
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Elke bevraging van persoonsgegevens uit BRP of NHR is een verwerking die een grondslag en een
|
|
||||||
begrensde doelbinding nodig heeft. Toegangsregels moeten handhaafbaar en auditeerbaar zijn, en
|
|
||||||
wijzigbaar zonder de bedrijfscode opnieuw uit te rollen.
|
|
||||||
|
|
||||||
De Federatieve Toegangsverlening (FTV) van het FDS beweegt naar policy-based access control, maar is
|
|
||||||
nog geen afgeronde standaard.
|
|
||||||
|
|
||||||
## Besluit
|
|
||||||
|
|
||||||
Introduceer een Policy Decision Point met Open Policy Agent (OPA). De applicatieservices roepen de
|
|
||||||
PDP aan — via een Authorisation Port en een PDP Client — **vóór elke registerbevraging**, en geven
|
|
||||||
rol, doel en grondslag mee.
|
|
||||||
|
|
||||||
Policies schrijven wij als code, **geversioneerd in Gitea**, en zij gaan via review naar productie. De
|
|
||||||
PDP staat zo gepositioneerd dat wij bij de komst van FTV alleen het policy-dialect opnieuw uitdrukken,
|
|
||||||
zonder de architectuurgrens te verplaatsen.
|
|
||||||
|
|
||||||
## Gevolgen
|
|
||||||
|
|
||||||
**Positief:** doelbinding en grondslag worden gehandhaafd, en niet alleen gedocumenteerd. De FG kan de
|
|
||||||
werkelijke regels in versiebeheer lezen, waardoor het verwerkingenregister en de gehandhaafde policy
|
|
||||||
naar elkaar toe groeien. Toegangswijzigingen zijn reviewbaar en gedateerd.
|
|
||||||
|
|
||||||
**Negatief en kosten:** BRP-autorisatiebesluiten correct modelleren is juridisch werk, geen
|
|
||||||
engineering. De PDP maakt de handhaving betrouwbaar, niet de policy juist. Daarnaast komt er een
|
|
||||||
component bij om te exploiteren.
|
|
||||||
|
|
||||||
**Vervolgwerk:** een promotiepijplijn voor policies in Gitea Actions. Policies opnieuw uitdrukken zodra
|
|
||||||
FTV stabiliseert. Een FG-review van de policy-set vóórdat er echte persoonsgegevens in komen.
|
|
||||||
|
|
||||||
## Overwogen alternatieven
|
|
||||||
|
|
||||||
- **Rolcontroles in de applicatiecode** — afgewezen: niet auditeerbaar, niet wijzigbaar zonder deploy,
|
|
||||||
en het verspreidt toegangslogica over de codebase.
|
|
||||||
- **Wachten op FTV** — afgewezen: de PBAC-vorm is al duidelijk. Nu OPA, later het FTV-dialect.
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# ADR-0004: Begrensde cache; registers blijven systeem van registratie
|
|
||||||
|
|
||||||
- **Status:** accepted
|
|
||||||
- **Datum:** 2026-06-13
|
|
||||||
- **Deciders:** Lab Circle, FG (geconsulteerd)
|
|
||||||
- **Vervangt / vervangen door:** —
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
*Data bij de bron* verbiedt het behandelen van registerdata als lokale bron van waarheid. Maar BRP of
|
|
||||||
NHR bij elke interactie bevragen is onpraktisch en vergroot de blootstelling.
|
|
||||||
|
|
||||||
Persoonsgegevens zijn de data die wij het minst willen opbouwen. Een onbegrensde cache wordt stil een
|
|
||||||
schaduwregister, met een onbeheerde bewaarverplichting als gevolg.
|
|
||||||
|
|
||||||
## Besluit
|
|
||||||
|
|
||||||
Een **begrensde cache** staat achter een Cache Port, beheerd door een Cache Manager. Vier grenzen
|
|
||||||
gelden.
|
|
||||||
|
|
||||||
| Grens | Wat die betekent |
|
|
||||||
|---|---|
|
|
||||||
| **Tijd** | Een TTL die aan het doel hangt |
|
|
||||||
| **Omvang** | Alleen de werkset van een actieve zaak |
|
|
||||||
| **Gezag** | Antwoordt nooit wat de bron niet zou antwoorden; geen systeem van registratie |
|
|
||||||
| **Adresseerbaarheid** | Gesleuteld op subject, zodat verwijderen op verzoek kan |
|
|
||||||
|
|
||||||
Purge-triggers: het verstrijken van de TTL, het sluiten van de zaak, en een verwijderingsverzoek.
|
|
||||||
|
|
||||||
## Gevolgen
|
|
||||||
|
|
||||||
**Positief:** de prestaties van een lokale kopie, zonder een onbevoegd register te worden. Bewaartermijn
|
|
||||||
en het recht op verwijdering zijn echte operaties, geen hoop. Dit is consistent met zowel
|
|
||||||
AVG-dataminimalisatie als FDS-data-bij-de-bron.
|
|
||||||
|
|
||||||
**Negatief en kosten:** de mapping van doel naar TTL is een beleidsbesluit, samen met de FG en de
|
|
||||||
autorisatievoorwaarden, en geen engineeringconstante. Die is dus makkelijk fout te krijgen. Daarnaast
|
|
||||||
komt de complexiteit van cache-invalidatie erbij.
|
|
||||||
|
|
||||||
**Vervolgwerk:** definieer het beleid voor doel naar TTL met de FG. Maak een toestandsdiagram voor de
|
|
||||||
levensloop van een cache-entry. Documenteer de aanvaardbare veroudering per register.
|
|
||||||
|
|
||||||
## Overwogen alternatieven
|
|
||||||
|
|
||||||
- **Geen cache; altijd de bron bevragen** — afgewezen: onpraktische latency en belasting, en meer
|
|
||||||
blootstelling per aanroep.
|
|
||||||
- **Een onbegrensde of algemene cache** — afgewezen: die wordt een schaduwregister, precies de
|
|
||||||
faalvorm waar de AVG en het FDS beide tegen duwen.
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# ADR-0005: Verwerkingenlog via event-emissie, in lijn met LDV
|
|
||||||
|
|
||||||
- **Status:** accepted
|
|
||||||
- **Datum:** 2026-06-13
|
|
||||||
- **Deciders:** Lab Circle, FG (geconsulteerd)
|
|
||||||
- **Vervangt / vervangen door:** —
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
AVG art. 30 vereist een register van verwerkingsactiviteiten. De FDS-bouwsteen Logboek
|
|
||||||
Dataverwerkingen (LDV) wijst naar een gestandaardiseerd verwerkingslog dat de burger kan bevragen.
|
|
||||||
|
|
||||||
Database-CDC met Debezium legt *datawijzigingen* vast, en niet *verwerkingsgebeurtenissen met
|
|
||||||
doelbinding*. Het is dus geen verwerkingenlog.
|
|
||||||
|
|
||||||
## Besluit
|
|
||||||
|
|
||||||
Elke registeradapter stuurt een **verwerkingsactiviteit-event** naar een eigen Redpanda-topic, via een
|
|
||||||
Verwerking Port en een LDV Emitter. Het event bevat: subjectcategorie, register, velden, doel en
|
|
||||||
doelbinding, grondslag, bevragende rol, en tijdstempel. **Nooit de opgehaalde waarden.**
|
|
||||||
|
|
||||||
Een projectie maakt het log bevraagbaar. De emissie is asynchroon, maar niet over te slaan: de adapter
|
|
||||||
die de Register Port vervult, is dezelfde code die het event uitstuurt.
|
|
||||||
|
|
||||||
## Gevolgen
|
|
||||||
|
|
||||||
**Positief:** het spoor voor art. 30 en LDV ontstaat als neveneffect van de bevraging, dus het kan niet
|
|
||||||
uit de pas lopen met de werkelijkheid. Het is af te stemmen tegen de tweezijdige logs van FSC
|
|
||||||
(ADR-0002). Het is onderscheidend in een tender.
|
|
||||||
|
|
||||||
**Negatief en kosten:** een topic en een projectie om te exploiteren. Het ontsluiten van het log naar
|
|
||||||
de burger valt buiten de huidige scope; wij produceren het log. Het eventschema vraagt governance.
|
|
||||||
|
|
||||||
**Vervolgwerk:** definieer het schema van het verwerkingsevent. Bouw de bevraagbare projectie. Sluit
|
|
||||||
aan op de LDV-standaard zodra die volwassen wordt; dit is een upstream-kandidaat.
|
|
||||||
|
|
||||||
## Overwogen alternatieven
|
|
||||||
|
|
||||||
- **Debezium-CDC hergebruiken als log** — afgewezen: dat legt datawijzigingen vast, en geen verwerking
|
|
||||||
met doelbinding. Verkeerde semantiek.
|
|
||||||
- **Synchroon loggen in het aanroeppad** — afgewezen: dat koppelt de latency van de bevraging aan het
|
|
||||||
log. Asynchroon maar niet over te slaan geeft zowel snelheid als garantie.
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
# ADR-0006: Modulegrens en hergebruikstrategie voor de governed-access spine
|
|
||||||
|
|
||||||
- **Status:** accepted
|
|
||||||
- **Datum:** 2026-06-13
|
|
||||||
- **Deciders:** Lab Circle (Lead Link, Build, Upstream Liaison)
|
|
||||||
- **Vervangt / vervangen door:** —
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
De compliance-spine uit slice 1 bestaat uit de PDP-controle (ADR-0003), gegoverneerd uitgaand verkeer
|
|
||||||
via FSC (ADR-0002), emissie van het verwerkingenlog (ADR-0005), en de begrensde cache (ADR-0004),
|
|
||||||
allemaal achter ports (ADR-0001). Die spine is mogelijk breder herbruikbaar dan alleen in de
|
|
||||||
referentie-applicatie.
|
|
||||||
|
|
||||||
Er spelen twee hergebruikvragen: welke verpakkingsvorm kiezen wij, en hoe verhoudt de spine zich tot
|
|
||||||
andere omgevingen zoals het OpenMetadata-datagovernanceproject?
|
|
||||||
|
|
||||||
Twee verduidelijkingen bepalen het besluit.
|
|
||||||
|
|
||||||
1. **OpenMetadata is geen afnemer.** In het datagovernanceproject is het de catalogus- en
|
|
||||||
lineage-laag over (synthetische) data. Het bevraagt geen BRP of NHR. FSC of de begrensde cache
|
|
||||||
daarin inbouwen zou zinloos zijn. De juiste aansluiting is **integratie van de output van de
|
|
||||||
spine**, en niet het inbouwen van de spine.
|
|
||||||
2. **FSC en de begrensde cache zijn zaken die alleen een afnemer aangaan.** "Maak het herbruikbaar"
|
|
||||||
mag deze niet uitsmeren over componenten die geen registerdata bevragen.
|
|
||||||
|
|
||||||
Nu al een taalonafhankelijke gateway bouwen — vóórdat er een tweede, niet-.NET afnemer bestaat — zou
|
|
||||||
de valkuil van speculatieve architectuur herhalen, die wij voor de capability-laag al hebben
|
|
||||||
afgewezen.
|
|
||||||
|
|
||||||
## Besluit
|
|
||||||
|
|
||||||
Wij nemen een **vraaggestuurde reeks van vier stappen** aan. Elke stap hangt af van echte behoefte, en
|
|
||||||
niet van verwachte behoefte.
|
|
||||||
|
|
||||||
| Stap | Wat | Wanneer |
|
|
||||||
|---|---|---|
|
|
||||||
| 1 | **In-process bewijzen.** Bouw de spine als gewone componenten achter ports, binnen de .NET register-applicatie. Nog geen extractie. Doel: de compliance-invarianten één keer echt valideren. | Slice 1 |
|
|
||||||
| 2 | **Extraheren als .NET-module.** Zodra een tweede .NET-afnemer in zicht is, haal de spine eruit als een geversioneerde .NET-library of SDK. Dit is de ACL-template-extractie die het charter al plant. Herbruikbaar voor .NET-afnemers, en dat is genoeg voor register-reference en zijn broertjes. | Slice 3 |
|
|
||||||
| 3 | **De feed LDV naar OpenMetadata aansluiten.** Route verwerkingsevents uit de LDV-emitter naar OpenMetadata als access- en usage-metadata bij het geclassificeerde asset: wie las welk persoonsgegevensveld, met welk doel, hoe vaak. Optioneel laten classificatietags uit OpenMetadata terugstromen om veldminimalisatie in de ACL aan te sturen. Dit is de concrete brug tussen beide anchor-projecten: integratie, geen inbouw. | Na stap 2 |
|
|
||||||
| 4 | **Alleen op verzoek een taalonafhankelijke gateway bouwen.** Heeft een echte niet-.NET afnemer gegoverneerde registertoegang nodig, verpak de spine dan als zelfstandige sidecar of proxy met een dunne lokale API, met PDP, FSC-egress en LDV erachter. Niet eerder. | Op verzoek |
|
|
||||||
|
|
||||||
## Gevolgen
|
|
||||||
|
|
||||||
**Positief:** eigen software blijft minimaal. Hergebruik volgt op validatie in plaats van eraan vooraf
|
|
||||||
te gaan. Beide anchor-projecten krijgen een concreet, benoemd integratiepunt (stap 3). Zaken die
|
|
||||||
alleen een afnemer aangaan, blijven ingesloten.
|
|
||||||
|
|
||||||
**Negatief en kosten:** de .NET-module uit stap 2 dient geen niet-.NET afnemers. Dat aanvaarden wij,
|
|
||||||
omdat stap 4 dat geval dekt zodra het echt is. Stap 3 vraagt een afgesproken schema voor het
|
|
||||||
verwerkingsevent, stabiel genoeg voor OpenMetadata om te consumeren.
|
|
||||||
|
|
||||||
**Vervolgwerk:**
|
|
||||||
|
|
||||||
1. Neem stap 3 als expliciet integratiepunt op in beide projectpagina's in het Innovation Lab-repo:
|
|
||||||
`projects/open-register-fd/README.md` en `projects/openmetadata/README.md`.
|
|
||||||
2. Herzie de trigger van stap 4 bij elke portfolio-review. Bouw niet vooruit.
|
|
||||||
3. Regel governance op het schema van het verwerkingsevent; dat is een gedeelde afhankelijkheid van
|
|
||||||
stap 1 en stap 3.
|
|
||||||
|
|
||||||
## Overwogen alternatieven
|
|
||||||
|
|
||||||
- **De taalonafhankelijke gateway vooraf bouwen** — afgewezen: speculatieve architectuur voordat er een
|
|
||||||
tweede afnemer bestaat. De latency en de operationele kosten zijn niet te rechtvaardigen.
|
|
||||||
- **De spine in OpenMetadata inbouwen** — afgewezen: OpenMetadata is geen afnemer. Dit is een
|
|
||||||
categoriefout.
|
|
||||||
- **De spine permanent in-process houden, zonder extractie** — afgewezen: dat geeft het hergebruik
|
|
||||||
tussen projecten en applicaties weg, en dat is een kerndoel van de Open Register-inzet.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# ADR-NNNN: <titel>
|
|
||||||
|
|
||||||
- **Status:** proposed
|
|
||||||
- **Datum:** JJJJ-MM-DD
|
|
||||||
- **Deciders:** <rollen>
|
|
||||||
- **Vervangt / vervangen door:** —
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
<De krachten die spelen: het probleem, de beperkingen, de FDS- en AVG-drijfveren. Waarom er nu een
|
|
||||||
besluit nodig is.>
|
|
||||||
|
|
||||||
## Besluit
|
|
||||||
|
|
||||||
<De keuze, eenvoudig gesteld.>
|
|
||||||
|
|
||||||
## Gevolgen
|
|
||||||
|
|
||||||
**Positief:** <wat dit oplevert>
|
|
||||||
|
|
||||||
**Negatief en kosten:** <wat het kost, en wat wij aanvaarden>
|
|
||||||
|
|
||||||
**Vervolgwerk:** <welk werk dit oproept>
|
|
||||||
|
|
||||||
## Overwogen alternatieven
|
|
||||||
|
|
||||||
<De afgewezen opties, en waarom.>
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
# C4-componentview — register-applicatie en capability-laag
|
|
||||||
|
|
||||||
> Niveau 3, de componentview. Deze view zoomt in op de container van de .NET register-applicatie uit
|
|
||||||
> het L2-containerdiagram. Zij verbindt het geheel op componentniveau — domein, ports, adapters en de
|
|
||||||
> FDS-capability-componenten — en toont waar elk onderdeel externe tooling raakt.
|
|
||||||
>
|
|
||||||
> De hexagonale structuur is expliciet: het domein hangt alleen af van **ports** (interfaces). Elke
|
|
||||||
> concrete capability is een **adapter** die aan een port is gebonden.
|
|
||||||
>
|
|
||||||
> De containerview (L2), de blauwdruk en de privacy-datastroomviews staan in het Innovation Lab-repo,
|
|
||||||
> `Respellion/innovation-lab`, onder `projects/open-register-fd/`.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
C4Component
|
|
||||||
title Componentview — register-applicatie (.NET) en de FDS-capability-laag
|
|
||||||
|
|
||||||
Person(user, "Behandelaar", "Behandelt zaken")
|
|
||||||
Container(spa, "Frontend", "Angular + NL Design System", "Zaakinterface")
|
|
||||||
|
|
||||||
Container_Boundary(app, "Register-applicatie (.NET, hexagonaal)") {
|
|
||||||
Component(api, "API / application services", ".NET", "Orkestreert use cases; verklaart doelbinding per vraag")
|
|
||||||
Component(domain, "Domeinmodel", ".NET / DDD", "Ubiquitous language; geen registervocabulaire")
|
|
||||||
|
|
||||||
Component(portReg, "Register Port", "interface", "De vraag van het domein: Personen / Organisaties")
|
|
||||||
Component(portPol, "Authorisation Port", "interface", "mag-deze-verwerking-doorgaan?")
|
|
||||||
Component(portLog, "Verwerking Port", "interface", "leg de verwerkingsgebeurtenis vast")
|
|
||||||
Component(portTm, "Terugmelding Port", "interface", "meld een vermoedelijke fout")
|
|
||||||
Component(portCache, "Cache Port", "interface", "doelgebonden lezen, schrijven en verwijderen")
|
|
||||||
|
|
||||||
Component(aclBrp, "BRP-adapter", ".NET", "Vertaalt domein<->BRP; minimale velden")
|
|
||||||
Component(aclKvk, "NHR/KVK-adapter", ".NET", "Vertaalt domein<->NHR; UBO-bewust")
|
|
||||||
Component(pdpClient, "PDP Client", ".NET -> OPA", "Roept de policy engine; geeft doel en grondslag mee")
|
|
||||||
Component(ldvEmit, "LDV Emitter", ".NET", "Bouwt het verwerkingsevent; publiceert naar Redpanda")
|
|
||||||
Component(fscClient, "FSC Client", ".NET", "Stuurt contractuele aanroepen via de outway")
|
|
||||||
Component(cacheMgr, "Cache Manager", ".NET", "TTL en verwijderen op subjectsleutel")
|
|
||||||
Component(tmHandler, "Terugmelding Handler", ".NET -> Flowable", "Start het terugmeldproces")
|
|
||||||
Component(procClient, "Process Client", ".NET -> Flowable", "Uitvoering van BPMN en DMN")
|
|
||||||
}
|
|
||||||
|
|
||||||
System_Ext(opa, "OPA (PDP)", "Policies geversioneerd in Gitea")
|
|
||||||
System_Ext(fsc, "FSC Outway", "EUPL-referentie-implementatie")
|
|
||||||
System_Ext(flowable, "Flowable", "BPMN + DMN")
|
|
||||||
ContainerDb_Ext(cache, "Begrensde cache", "PostgreSQL")
|
|
||||||
System_Ext(redpanda, "Redpanda", "LDV-topic + CDC")
|
|
||||||
System_Ext(brp, "BRP", "via FSC inway")
|
|
||||||
System_Ext(kvk, "NHR / KVK", "via FSC inway")
|
|
||||||
System_Ext(kanidm, "Kanidm", "OIDC")
|
|
||||||
|
|
||||||
Rel(user, spa, "Gebruikt")
|
|
||||||
Rel(spa, api, "REST/JSON")
|
|
||||||
Rel(kanidm, api, "OIDC", "authenticatie")
|
|
||||||
Rel(api, domain, "Roept aan")
|
|
||||||
Rel(api, portPol, "Controleert vóór de bevraging")
|
|
||||||
Rel(api, portReg, "Vraagt data")
|
|
||||||
Rel(api, portTm, "Dient melding in")
|
|
||||||
Rel(api, procClient, "Voert proces uit")
|
|
||||||
|
|
||||||
Rel(portPol, pdpClient, "gebonden aan")
|
|
||||||
Rel(pdpClient, opa, "besluitverzoek")
|
|
||||||
|
|
||||||
Rel(portReg, aclBrp, "gebonden aan")
|
|
||||||
Rel(portReg, aclKvk, "gebonden aan")
|
|
||||||
Rel(aclBrp, fscClient, "via")
|
|
||||||
Rel(aclKvk, fscClient, "via")
|
|
||||||
Rel(aclBrp, portLog, "stuurt event")
|
|
||||||
Rel(aclKvk, portLog, "stuurt event")
|
|
||||||
Rel(aclBrp, portCache, "leest en schrijft")
|
|
||||||
Rel(aclKvk, portCache, "leest en schrijft")
|
|
||||||
Rel(fscClient, fsc, "contractuele aanroep")
|
|
||||||
Rel(fsc, brp, "mTLS + contract")
|
|
||||||
Rel(fsc, kvk, "mTLS + contract")
|
|
||||||
|
|
||||||
Rel(portLog, ldvEmit, "gebonden aan")
|
|
||||||
Rel(ldvEmit, redpanda, "publiceert")
|
|
||||||
Rel(portCache, cacheMgr, "gebonden aan")
|
|
||||||
Rel(cacheMgr, cache, "slaat op")
|
|
||||||
Rel(portTm, tmHandler, "gebonden aan")
|
|
||||||
Rel(tmHandler, flowable, "start proces")
|
|
||||||
Rel(procClient, flowable, "voert uit")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Hoe je dit leest
|
|
||||||
|
|
||||||
1. **De ports zijn de naad.** Het domein en de application services hangen af van de vijf interfaces,
|
|
||||||
en nooit van adapters. FSC wisselen voor DSP, of OPA voor de latere FTV-client, verandert een
|
|
||||||
adapter — geen port, en niet het domein. Dit is de clock-speed boundary, concreet gemaakt.
|
|
||||||
2. **De compliance-componenten zijn adapters, geen domeinlogica.** De PDP-client, de LDV-emitter, de
|
|
||||||
FSC-client en de cache manager staan allemaal aan de adapterzijde. Een bevraging kan er fysiek niet
|
|
||||||
langs, omdat de adapter die de Register Port vervult dezelfde code is die het LDV-event uitstuurt
|
|
||||||
en via FSC routeert.
|
|
||||||
3. **Slechts twee componenten raken de registers**: de BRP-adapter en de NHR/KVK-adapter. Beide
|
|
||||||
bereiken ze uitsluitend via de FSC-client. Er is geen vierde pad.
|
|
||||||
|
|
||||||
## Componenten tegenover verplichtingen
|
|
||||||
|
|
||||||
| Component | Omvang eigen bouw | Verplichting die het afdekt |
|
|
||||||
|---|---|---|
|
|
||||||
| Domeinmodel | het product | correctheid van de bedrijfsregels |
|
|
||||||
| BRP- en NHR-adapters | dun | dataminimalisatie: vertalen en velden versmallen |
|
|
||||||
| PDP Client | klein | handhaven van grondslag en doelbinding |
|
|
||||||
| LDV Emitter | klein | verwerkingenlog (AVG art. 30 en LDV) |
|
|
||||||
| FSC Client | klein | geautoriseerde, gelogde connectiviteit |
|
|
||||||
| Cache Manager | klein | grenzen aan bewaring, en verwijdering |
|
|
||||||
| Terugmelding Handler | klein | de terugmeldplicht van de afnemer |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Aanvullende views die voor engineers waarde hebben
|
|
||||||
|
|
||||||
De diagrammen tot hier verklaren *structuur* en *compliance-intentie*. Engineers die dit bouwen,
|
|
||||||
hebben er nog een aantal nodig. Wij tekenen geen view voordat er iets echt is om te beschrijven, dus
|
|
||||||
elke regel noemt de trigger.
|
|
||||||
|
|
||||||
| # | View | Wat het toevoegt | Trigger |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | **Deploymentview** (C4 deployment, topologie) | Waar elke container op De Werf draait: k3s-namespaces, welke services sidecar zijn en welke een eigen pod (is OPA een sidecar of centraal? waar eindigt de FSC outway?), netwerkpolicies tussen de vlakken van de vertrouwensgrens, en beheer van secrets en mTLS-certificaten voor FSC. Hier worden de privacy*grenzen* echte firewall- en netwerkregels. | Vóór de eerste deploy met meerdere services. **Hoogste waarde als volgende.** |
|
|
||||||
| 2 | **Sequences voor de niet-gelukkige paden** | Wij hebben het gelukkige pad. Engineers hebben de lastige nodig: PDP-*deny* midden in een transactie, een verlopen of ingetrokken FSC-contract, een register-timeout terwijl er een verouderde cache-entry ligt, en een gedeeltelijk NHR-antwoord waarbij een UBO-veld is achtergehouden. Dit bepaalt de foutafhandeling, en hier verstoppen de compliance-randgevallen zich. | Direct na slice 1. |
|
|
||||||
| 3 | **Domeinmodel en ERD** | De bounded contexts en aggregates in het domein, plus het cacheschema: welke persoonsgegevens blijven staan, op welke sleutel, en met welke purge-kolom. Dit is tegelijk het artefact dat de FG beoordeelt voor bewaartermijnen. | Zodra het domein in slice 1 stabiliseert. |
|
|
||||||
| 4 | **Dataclassificatie- en catalogusview** | Elk veld dat een grens kruist, getagd — persoonsgegeven? bijzondere categorie? UBO-beperkt? — en gemapt op zijn classificatie in OpenMetadata. Dit stuurt de GDPR-scrubbingregels en de lineage-tags. | Beter *uit* OpenMetadata gegenereerd zodra die gevuld is, dan met de hand getekend. |
|
|
||||||
| 5 | **Toestandsdiagram: levensloop van een cache-entry** | `fetched` → `valid` (binnen TTL) → `stale` → `purged` (TTL verstreken \| zaak gesloten \| verwijderingsverzoek). Klein, maar het pint de bewaarsemantiek vast die "begrensde cache" nu alleen in prose beschrijft. | Samen met ADR-0004-vervolgwerk. |
|
|
||||||
| 6 | **BPMN-view: de terugmelding-workflow** | Het Flowable-proces zelf: ingediend → verstuurd naar bronhouder → bevestigd → opgelost of afgewezen. Dit is uitvoerbaar BPMN, dus het diagram en de implementatie zijn hetzelfde artefact. | Wanneer de terugmelding-slice start. |
|
|
||||||
| 7 | **Threat model en vertrouwensgrensview** (STRIDE-stijl) | Dreigingen over de vertrouwensgrens leggen: tokendiefstal, cache poisoning, replay tegen FSC, policy bypass, en manipulatie van logs. Past natuurlijk bij de FSC-zoom, en is het anker van het securitygesprek. | Vóór het verwerken van echte persoonsgegevens. |
|
|
||||||
| 8 | **CI/CD- en policy-promotieview** | Hoe OPA-policies en BPMN/DMN-modellen van een pull request naar draaiende configuratie gaan. "Toegangsbeheer is configuratie in Gitea" geldt alleen als er een pijplijn is die review en promotie handhaaft. | Samen met het vervolgwerk uit ADR-0003. |
|
|
||||||
|
|
||||||
**Voorstel voor de volgende twee.** De **deploymentview**, omdat die de privacygrenzen omzet in
|
|
||||||
handhaafbare netwerkpolicy. En de **sequences voor de niet-gelukkige paden**, omdat compliance daar
|
|
||||||
werkelijk breekt.
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
# POC-voorstel — slice 1: walking skeleton (één register, gegoverneerde bevraging)
|
|
||||||
|
|
||||||
> Klaar om in een `poc-voorstel`-issue te plakken, met de labels `build` en `poc`. Dit is het bouwbare
|
|
||||||
> eerste increment dat de architectuurdocumenten beschrijven. Het bewijst met opzet de
|
|
||||||
> *compliance-spine* end-to-end op de dunst mogelijke functionaliteit.
|
|
||||||
|
|
||||||
## Probleem en strategische vraag
|
|
||||||
|
|
||||||
Kunnen wij een registerbevraging demonstreren die *structureel* gegoverneerd is — onmogelijk uit te
|
|
||||||
voeren zonder gehandhaafde grondslag en een automatische regel in het verwerkingenlog — op onze
|
|
||||||
soevereine stack?
|
|
||||||
|
|
||||||
Dit is de geloofwaardigheidstoets achter de hele Open Register-inzet (slice 1 van het charter) en
|
|
||||||
achter de FDS gap-analyse.
|
|
||||||
|
|
||||||
## Hypothese
|
|
||||||
|
|
||||||
Wij verwachten dat het doorverbinden van één registerbevraging door de volledige capability-spine —
|
|
||||||
Register Port → ACL-adapter → PDP-controle → FSC-aanroep → LDV-emissie → begrensde cache — de claim
|
|
||||||
"compliance is structureel" bewijst.
|
|
||||||
|
|
||||||
Wij weten dat wij het goed hebben als een geautomatiseerde test aantoont dat een bevraging **niet** kan
|
|
||||||
voltooien als de PDP weigert, en **altijd** een LDV-event oplevert als de PDP toestaat.
|
|
||||||
|
|
||||||
## Scope ter grootte van één blok
|
|
||||||
|
|
||||||
**Wel in scope**
|
|
||||||
|
|
||||||
| Onderdeel | Wat |
|
|
||||||
|---|---|
|
|
||||||
| Register | **NHR/KVK**, basisgegevens over onderneming en bestuurder. Gekozen boven BRP; zie de slotnotitie. |
|
|
||||||
| Use case | Geef bij een KVK-nummer de geregistreerde organisatie terug aan het domein, voor één verklaard doel. |
|
|
||||||
| Ports | De vijf ports als interface. Concrete adapters: NHR-ACL, PDP-client (OPA), FSC-client met sandbox- of test-outway, LDV-emitter (Redpanda-topic), en cache manager (PostgreSQL met TTL). |
|
|
||||||
| Policy | OPA draait met één handgeschreven voorbeeldpolicy in Gitea: één allow-regel en één deny-geval. |
|
|
||||||
| Log | Verwerkingsevent-schema v0 plus een minimale bevraagbare projectie; een tabelweergave is genoeg. |
|
|
||||||
| Tests | Tests die de twee compliance-invarianten vastleggen: deny blokkeert, allow logt. |
|
|
||||||
|
|
||||||
**Niet in scope** — even belangrijk om op te schrijven.
|
|
||||||
|
|
||||||
1. Afgewerkte interface of NL Design System-schermen, verder dan een dev-harness.
|
|
||||||
2. BRP en paden met veel persoonsgegevens. Die gaan naar slice 2, met een door de FG beoordeelde
|
|
||||||
policy.
|
|
||||||
3. UBO-data. Het regime van beperkte toegankelijkheid valt buiten deze slice.
|
|
||||||
4. De terugmelding-workflow (latere slice), DCAT-export, en Superset-dashboards.
|
|
||||||
5. Echte register-endpoints. Alleen sandbox en stubs.
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
|
|
||||||
- [ ] Een bevraging op KVK-nummer geeft een domein-`Organisatie` terug via de NHR-ACL-adapter, zonder
|
|
||||||
registervocabulaire in het domein (ADR-0001).
|
|
||||||
- [ ] De aanroep loopt via de FSC-client naar een sandbox-outway, en niet via een ruwe HTTP-client
|
|
||||||
(ADR-0002).
|
|
||||||
- [ ] Er vindt geen bevraging plaats tenzij de PDP allow teruggeeft voor de combinatie rol, doel en
|
|
||||||
grondslag (ADR-0003).
|
|
||||||
- [ ] Elke toegestane bevraging stuurt precies één verwerkingsevent naar Redpanda, bevraagbaar in de
|
|
||||||
projectie, zonder opgehaalde waarden (ADR-0005).
|
|
||||||
- [ ] Cache-entries dragen een TTL en een subjectsleutel; een purge-aanroep verwijdert ze (ADR-0004).
|
|
||||||
- [ ] **De tests op de compliance-invarianten slagen in CI:** (a) PDP-deny betekent geen FSC-aanroep;
|
|
||||||
(b) PDP-allow betekent precies één LDV-event; (c) te ruim gevraagde velden bereiken het domein
|
|
||||||
nooit.
|
|
||||||
- [ ] Het geheel draait lokaal uit een gedocumenteerd `compose`- of k3s-manifest met stubs, zonder
|
|
||||||
echte registertoegang.
|
|
||||||
- [ ] ADR-0001 tot en met ADR-0005 zijn vanuit de code gelinkt. Eén nieuwe ADR als er in slice 1 een
|
|
||||||
besluit ontstaat.
|
|
||||||
|
|
||||||
## Acceptatiedemo (bewijs voor de week-3-toets)
|
|
||||||
|
|
||||||
Live: een geslaagde bevraging plus de bijbehorende LDV-regel. Zet daarna de policy op deny en toon
|
|
||||||
dezelfde bevraging geweigerd, zonder registeraanroep en zonder data.
|
|
||||||
|
|
||||||
Dat contrast *is* de demo.
|
|
||||||
|
|
||||||
## Ontvangende Delivery Circle (voorlopig)
|
|
||||||
|
|
||||||
De register-reference Delivery Circle. De Handoff-ontvanger krijgt bij de kickoff een naam.
|
|
||||||
|
|
||||||
Waarschijnlijke adoptie: de capability-spine wordt het herbruikbare substraat voor de
|
|
||||||
register-reference-applicatie.
|
|
||||||
|
|
||||||
## Upstream-kandidaten
|
|
||||||
|
|
||||||
| Project | Wat wij kunnen bijdragen |
|
|
||||||
|---|---|
|
|
||||||
| fsc-nlx | Ergonomie van de sandbox en testomgeving, plus documentatie |
|
|
||||||
| OPA | Policy-patronen voor het modelleren van Nederlandse grondslagen |
|
|
||||||
| OpenMetadata | Later een DCAT-AP-NL exporter; dit verbindt het OpenMetadata-project |
|
|
||||||
|
|
||||||
## AVG- en soevereiniteitsoverwegingen
|
|
||||||
|
|
||||||
Alleen NHR-basisgegevens, over onderneming en bestuurder, en in slice 1 **gestubd**. Er worden geen
|
|
||||||
echte persoonsgegevens verwerkt.
|
|
||||||
|
|
||||||
Een FG-review is een voorwaarde voor slice 2, met echte data en BRP. Alle componenten draaien
|
|
||||||
zelfgehost op De Werf; OPA-policies en BPMN staan in Gitea.
|
|
||||||
|
|
||||||
## Slotnotitie: waarom NHR vóór BRP voor het skeleton
|
|
||||||
|
|
||||||
Beide registers bevatten persoonsgegevens, dus geen van beide is "gratis". NHR-basisgegevens over
|
|
||||||
onderneming en bestuurder zijn echter minder gevoelig dan BRP-gegevens over inwoners, en er is een
|
|
||||||
duidelijker verhaal rond een publieke sandbox.
|
|
||||||
|
|
||||||
Zo bewijst slice 1 het *mechanisme*, voordat slice 2 BRP oppakt onder een door de FG beoordeelde
|
|
||||||
policy. UBO-data blijft buiten scope tot het toegangsregime is gemodelleerd.
|
|
||||||
+4
-70
@@ -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)
|
## 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
|
**Outcome:** approving a registration no longer only moves the ZGW zaak to its eindstatus — it also
|
||||||
@@ -174,8 +140,7 @@ zaaktype cache). Store is in-memory: an edit reverts to the configured env on re
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
make up
|
make up
|
||||||
# 1. Log in as bram-beheerder / test123 + OTP (`python3 infra/keycloak/check_realms.py otp`)
|
# 1. Log in as bram-beheerder / test123 → "Default-fill" tab → change a value → Opslaan.
|
||||||
# → "Default-fill" tab → change a value → Opslaan.
|
|
||||||
open http://localhost:8143/default-fill
|
open http://localhost:8143/default-fill
|
||||||
#
|
#
|
||||||
# 2. Automated: the ACL uses the current default-fill per zaak (unit) and the endpoints are behind the
|
# 2. Automated: the ACL uses the current default-fill per zaak (unit) and the endpoints are behind the
|
||||||
@@ -196,8 +161,7 @@ directly (ADR-0025); managing the default-fill config (S-15b) and MFA (S-15c) co
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
make up
|
make up
|
||||||
# 1. Log in as bram-beheerder / test123 + OTP (`python3 infra/keycloak/check_realms.py otp`)
|
# 1. Log in as bram-beheerder / test123 → the catalogus lists the published zaaktypen.
|
||||||
# → the catalogus lists the published zaaktypen.
|
|
||||||
open http://localhost:8143
|
open http://localhost:8143
|
||||||
#
|
#
|
||||||
# 2. Automated (a CI verify-stack e2e): a beheerder logs in and sees BIG-REGISTRATIE.
|
# 2. Automated (a CI verify-stack e2e): a beheerder logs in and sees BIG-REGISTRATIE.
|
||||||
@@ -340,8 +304,7 @@ 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 +
|
# 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 /
|
# upload a PDF, then approve it in the werkbak at http://localhost:8142 (merel-behandelaar /
|
||||||
# test123 + OTP, see S-15c); it shows as INGESCHREVEN in the openbaar register at
|
# test123); it shows as INGESCHREVEN in the openbaar register at http://localhost:8141.
|
||||||
# http://localhost:8141.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> The zaaktype is discovered by the ACL itself since S-27 (below); `local-seed`'s `acl.env` now
|
> The zaaktype is discovered by the ACL itself since S-27 (below); `local-seed`'s `acl.env` now
|
||||||
@@ -626,7 +589,7 @@ or **afwijzen** — which also completes the Beoordelen task so the process adva
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
# 1. Open the behandel portal and log in as a behandelaar (medewerker realm):
|
# 1. Open the behandel portal and log in as a behandelaar (medewerker realm):
|
||||||
# http://localhost:8142/ → merel-behandelaar / test123 + OTP
|
# http://localhost:8142/ → merel-behandelaar / test123
|
||||||
#
|
#
|
||||||
# 2. The werkbak lists the registrations awaiting beoordeling (referentie / bsn / status).
|
# 2. The werkbak lists the registrations awaiting beoordeling (referentie / bsn / status).
|
||||||
# Find the reference from the submit confirmation and click "Goedkeuren" on that row.
|
# Find the reference from the submit confirmation and click "Goedkeuren" on that row.
|
||||||
@@ -849,32 +812,3 @@ make verify-domain # → "the timed-out registration's zaak was cancelled to
|
|||||||
`POST /annuleringen` → ZGW `resultaten` + `statussen` (Geannuleerd); the aggregate then moves 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
|
`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).
|
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).
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ should teach.
|
|||||||
- **[Product Requirements](PRD.md)** — what we're building and why.
|
- **[Product Requirements](PRD.md)** — what we're building and why.
|
||||||
- **[ADR-0001: Loose coupling](architecture/adr-0001-loose-coupling.md)** — the
|
- **[ADR-0001: Loose coupling](architecture/adr-0001-loose-coupling.md)** — the
|
||||||
non-negotiable integration stance; the template for future ADRs.
|
non-negotiable integration stance; the template for future ADRs.
|
||||||
- **[FDS architecture](architecture/fds/README.md)** — participating in the Federatief
|
|
||||||
Datastelsel as an afnemer: FDS ADR-0001…0006, the L3 component view, the slice-1 proposal.
|
|
||||||
In Dutch; the strategic framing lives in `Respellion/innovation-lab`.
|
|
||||||
- **[Working in Gitea](gitea-workflow.md)** — issues, milestones, branches, PRs.
|
- **[Working in Gitea](gitea-workflow.md)** — issues, milestones, branches, PRs.
|
||||||
- **[CI runbook](runbooks/ci.md)** — the pipeline and the `make ci` local gate.
|
- **[CI runbook](runbooks/ci.md)** — the pipeline and the `make ci` local gate.
|
||||||
|
|
||||||
|
|||||||
@@ -245,47 +245,3 @@ the verify-stack check table, and per-spec e2e results (`infra/playwright-summar
|
|||||||
- Getting a report out of the e2e container: Playwright writes `playwright-report.json`
|
- Getting a report out of the e2e container: Playwright writes `playwright-report.json`
|
||||||
inside the container; `infra/run-e2e-check.sh` `docker cp`s it back to the host
|
inside the container; `infra/run-e2e-check.sh` `docker cp`s it back to the host
|
||||||
(capturing the test exit code first) so the summary step can read it.
|
(capturing the test exit code first) so the summary step can read it.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. `if: always()` does not survive the job being killed — bound the work itself
|
|
||||||
|
|
||||||
`if: always()` makes a step run when an *earlier step failed*. It does **not** help when
|
|
||||||
the job as a whole is stopped: the run's remaining steps are simply never dispatched.
|
|
||||||
|
|
||||||
That is how #161 lost its diagnosis. `verify-stack` entered `make verify-e2e` at 09:48:17
|
|
||||||
and the job ended at 10:14:54 — 26½ minutes later, mid-suite. Every step after the e2e
|
|
||||||
shows a **0-second `failure`** stamped at that same instant:
|
|
||||||
|
|
||||||
```
|
|
||||||
14 failure 09:48:17 -> 10:14:54 Self-service e2e (Playwright, login → submit → success)
|
|
||||||
15 failure 10:14:54 -> 10:14:54 verify-stack check summary ← if: always()
|
|
||||||
16 failure 10:14:54 -> 10:14:54 e2e spec summary ← if: always()
|
|
||||||
17 failure 10:14:54 -> 10:14:54 Dump container logs on failure ← if: failure()
|
|
||||||
18 failure 10:14:54 -> 10:14:54 Tear down ← if: always()
|
|
||||||
```
|
|
||||||
|
|
||||||
So the per-spec summary, the container-log dump and the teardown never ran, and the job
|
|
||||||
log — which also loses whatever the killed process had buffered — ended at a single `✘`
|
|
||||||
line. A job that dies takes its own post-mortem with it.
|
|
||||||
|
|
||||||
**Read the step timings, not just the log.** `GET /api/v1/repos/{owner}/{repo}/actions/jobs/{id}`
|
|
||||||
returns every step with `started_at`/`completed_at`; a row of identical zero-length
|
|
||||||
steps at the end means *killed*, not *silent*. (Job ids come from
|
|
||||||
`…/actions/runs/{run}/jobs`, and that route returns only the **latest attempt** — a
|
|
||||||
re-run hides the failed one, so keep the failing job id from the original report. Logs:
|
|
||||||
`…/actions/jobs/{id}/logs`, see also `gitea-ci-logs`.)
|
|
||||||
|
|
||||||
**Conventions that follow:**
|
|
||||||
|
|
||||||
- **Bound long-running work inside the tool**, where it can still report. Playwright's
|
|
||||||
`globalTimeout` (`tests/e2e/playwright.config.ts`) ends the run, writes the JSON
|
|
||||||
report and exits, so the summary and log-dump steps still get their turn. A
|
|
||||||
`timeout-minutes` on the job would reproduce the very failure above.
|
|
||||||
- **Never let an auto-waiting action be the timeout.** Playwright actions (`fill`,
|
|
||||||
`click`) inherit the *test* timeout, not `expect.timeout`, so a missing element costs
|
|
||||||
the full 90 s and reports `locator.fill: Test timeout …` — the symptom. Assert the
|
|
||||||
element visible first with its own budget and a message (`tests/e2e/keycloak-login.ts`).
|
|
||||||
- Remember `concurrency.cancel-in-progress: true` in `ci.yaml`: a new push to the same
|
|
||||||
ref, or a re-run, kills the in-flight run the same way. Check `run_attempt` before
|
|
||||||
concluding a job hung.
|
|
||||||
|
|||||||
@@ -23,9 +23,6 @@ login per realm and asserts the identifying claim:
|
|||||||
| eidas | pierre-dupont | `eidas_id` |
|
| eidas | pierre-dupont | `eidas_id` |
|
||||||
| medewerker | merel-behandelaar | role `behandelaar` |
|
| 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).
|
All test users / credentials are in [../synthetic-data.md](../synthetic-data.md).
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
@@ -38,36 +35,3 @@ All test users / credentials are in [../synthetic-data.md](../synthetic-data.md)
|
|||||||
- **Image** pinned to `quay.io/keycloak/keycloak:26.1`.
|
- **Image** pinned to `quay.io/keycloak/keycloak:26.1`.
|
||||||
- Claims are injected by OIDC protocol mappers on `big-portal` (user attribute → token
|
- Claims are injected by OIDC protocol mappers on `big-portal` (user attribute → token
|
||||||
claim); `medewerker` roles come through `realm_access.roles`.
|
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,11 +19,6 @@ All test users share the password **`test123`**.
|
|||||||
| `eidas` | eIDAS (EU) | `pierre-dupont` | `eidas_id` = `FR/NL/AB-1234-5678` |
|
| `eidas` | eIDAS (EU) | `pierre-dupont` | `eidas_id` = `FR/NL/AB-1234-5678` |
|
||||||
| `medewerker` | Internal staff | `merel-behandelaar` | role `behandelaar` |
|
| `medewerker` | Internal staff | `merel-behandelaar` | role `behandelaar` |
|
||||||
| `medewerker` | Internal staff | `tom-teamlead` | roles `behandelaar`, `teamlead` |
|
| `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`
|
The identifying claims are injected via OIDC protocol mappers on `big-portal`
|
||||||
(user-attribute → token claim); `medewerker` roles appear in `realm_access.roles`.
|
(user-attribute → token claim); `medewerker` roles appear in `realm_access.roles`.
|
||||||
@@ -37,8 +32,5 @@ curl -s -X POST \
|
|||||||
-d username=jan-burger -d password=test123 -d scope=openid | jq -r .access_token
|
-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
|
Decode the JWT payload to see the `bsn` claim. `make keycloak-smoke` checks every realm
|
||||||
automatically.
|
automatically.
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
# Overlay: make the CI compose stack usable from a HOST browser.
|
|
||||||
# Same two mechanisms infra/docker-compose.local.yml already uses — pin Keycloak's issuer to the
|
|
||||||
# host-published address, and point each portal's runtime config.json at it. The BFF needs no
|
|
||||||
# change: it discovers metadata over keycloak:8080 and the discovered issuer is the pinned
|
|
||||||
# localhost:8180, which is what browser tokens carry.
|
|
||||||
services:
|
|
||||||
keycloak:
|
|
||||||
environment:
|
|
||||||
KC_HOSTNAME: http://localhost:8180
|
|
||||||
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"
|
|
||||||
self-service:
|
|
||||||
volumes:
|
|
||||||
- ./local-config/self-service.config.json:/usr/share/nginx/html/config.json:ro,z
|
|
||||||
behandel:
|
|
||||||
volumes:
|
|
||||||
- ./local-config/behandel.config.json:/usr/share/nginx/html/config.json:ro,z
|
|
||||||
# beheer is the same medewerker realm as behandel, so it reuses behandel's config verbatim.
|
|
||||||
beheer:
|
|
||||||
volumes:
|
|
||||||
- ./local-config/behandel.config.json:/usr/share/nginx/html/config.json:ro,z
|
|
||||||
@@ -1,25 +1,19 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Smoke-check the Keycloak realms: each realm's OIDC login works (password grant)
|
"""Smoke-check the Keycloak realms: each realm's OIDC login works (password grant)
|
||||||
and returns its expected identifying claim. The medewerker realm additionally enforces
|
and returns its expected identifying claim. Stdlib only. Exits non-zero on failure.
|
||||||
MFA (S-15c), so its login must be refused without a TOTP code. Stdlib only.
|
|
||||||
Exits non-zero on failure.
|
|
||||||
"""
|
"""
|
||||||
import base64, hashlib, hmac, json, struct, sys, time, urllib.error, urllib.parse, urllib.request
|
import base64, json, sys, urllib.error, urllib.parse, urllib.request
|
||||||
|
|
||||||
BASE = "http://localhost:8180"
|
BASE = "http://localhost:8180"
|
||||||
CLIENT = "big-portal"
|
CLIENT = "big-portal"
|
||||||
PWD = "test123"
|
PWD = "test123"
|
||||||
|
|
||||||
# Fixture TOTP secret seeded into every medewerker in infra/keycloak/realms/medewerker-realm.json.
|
# realm, user, claim ("__roles__" => check realm_access.roles), expected-contains
|
||||||
# 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 = [
|
CHECKS = [
|
||||||
("digid", "jan-burger", "bsn", "123456782", False),
|
("digid", "jan-burger", "bsn", "123456782"),
|
||||||
("eherkenning", "acme-ondernemer", "kvk", "12345678", False),
|
("eherkenning", "acme-ondernemer", "kvk", "12345678"),
|
||||||
("eidas", "pierre-dupont", "eidas_id", "FR/NL", False),
|
("eidas", "pierre-dupont", "eidas_id", "FR/NL"),
|
||||||
("medewerker", "merel-behandelaar", "__roles__", "behandelaar", True),
|
("medewerker", "merel-behandelaar", "__roles__", "behandelaar"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -29,17 +23,10 @@ def decode(jwt):
|
|||||||
return json.loads(base64.urlsafe_b64decode(p))
|
return json.loads(base64.urlsafe_b64decode(p))
|
||||||
|
|
||||||
|
|
||||||
def totp(secret=OTP_SECRET, period=30, digits=6):
|
def grant(realm, user):
|
||||||
"""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({
|
data = urllib.parse.urlencode({
|
||||||
"grant_type": "password", "client_id": CLIENT,
|
"grant_type": "password", "client_id": CLIENT,
|
||||||
"username": user, "password": PWD, "scope": "openid", **extra,
|
"username": user, "password": PWD, "scope": "openid",
|
||||||
}).encode()
|
}).encode()
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
f"{BASE}/realms/{realm}/protocol/openid-connect/token", data=data,
|
f"{BASE}/realms/{realm}/protocol/openid-connect/token", data=data,
|
||||||
@@ -48,27 +35,11 @@ def grant(realm, user, **extra):
|
|||||||
return json.loads(r.read())
|
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():
|
def main():
|
||||||
ok = True
|
ok = True
|
||||||
for realm, user, claim, expect, mfa in CHECKS:
|
for realm, user, claim, expect 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:
|
try:
|
||||||
at = decode(grant(realm, user, **extra)["access_token"])
|
at = decode(grant(realm, user)["access_token"])
|
||||||
if claim == "__roles__":
|
if claim == "__roles__":
|
||||||
val = at.get("realm_access", {}).get("roles", [])
|
val = at.get("realm_access", {}).get("roles", [])
|
||||||
good = expect in val
|
good = expect in val
|
||||||
@@ -86,9 +57,4 @@ def main():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__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()
|
main()
|
||||||
|
|||||||
@@ -2,16 +2,6 @@
|
|||||||
"realm": "medewerker",
|
"realm": "medewerker",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"displayName": "Medewerkers",
|
"displayName": "Medewerkers",
|
||||||
"requiredActions": [
|
|
||||||
{
|
|
||||||
"alias": "CONFIGURE_TOTP",
|
|
||||||
"name": "Configure OTP",
|
|
||||||
"providerId": "CONFIGURE_TOTP",
|
|
||||||
"enabled": true,
|
|
||||||
"defaultAction": true,
|
|
||||||
"priority": 10
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"roles": {
|
"roles": {
|
||||||
"realm": [
|
"realm": [
|
||||||
{ "name": "behandelaar", "description": "Behandelt registratieaanvragen" },
|
{ "name": "behandelaar", "description": "Behandelt registratieaanvragen" },
|
||||||
@@ -53,15 +43,7 @@
|
|||||||
"lastName": "Behandelaar",
|
"lastName": "Behandelaar",
|
||||||
"email": "merel@big.example.nl",
|
"email": "merel@big.example.nl",
|
||||||
"emailVerified": true,
|
"emailVerified": true,
|
||||||
"credentials": [
|
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
||||||
{ "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"]
|
"realmRoles": ["behandelaar"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -71,15 +53,7 @@
|
|||||||
"lastName": "Teamlead",
|
"lastName": "Teamlead",
|
||||||
"email": "tom@big.example.nl",
|
"email": "tom@big.example.nl",
|
||||||
"emailVerified": true,
|
"emailVerified": true,
|
||||||
"credentials": [
|
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
||||||
{ "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"]
|
"realmRoles": ["behandelaar", "teamlead"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -89,15 +63,7 @@
|
|||||||
"lastName": "Beheerder",
|
"lastName": "Beheerder",
|
||||||
"email": "bram@big.example.nl",
|
"email": "bram@big.example.nl",
|
||||||
"emailVerified": true,
|
"emailVerified": true,
|
||||||
"credentials": [
|
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
||||||
{ "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"]
|
"realmRoles": ["beheerder"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -25,15 +25,3 @@ storage:
|
|||||||
path: /var/tempo/blocks
|
path: /var/tempo/blocks
|
||||||
wal:
|
wal:
|
||||||
path: /var/tempo/wal
|
path: /var/tempo/wal
|
||||||
|
|
||||||
# #156: don't let the distributor evict its own ingester. Tempo runs single-binary here, so the
|
|
||||||
# distributor and the ingester are the same process and the "pool" holds exactly one, in-process,
|
|
||||||
# member. dskit still health-checks it over loopback gRPC with a 1s deadline (checkinterval 15s);
|
|
||||||
# on the shared CI runner a transient stall blows that deadline, the only ingester is dropped from
|
|
||||||
# the pool ("removing distributor_pool failing healthcheck"), and every push then fails ("pusher
|
|
||||||
# failed to consume trace data", err="context canceled") until the next check — silently losing
|
|
||||||
# spans, which is how verify-tracing flaked. With one in-process ingester the check can never route
|
|
||||||
# around a failure, so it can only ever discard data. Turn it off.
|
|
||||||
ingester_client:
|
|
||||||
pool_config:
|
|
||||||
healthcheckenabled: false
|
|
||||||
|
|||||||
@@ -7,34 +7,10 @@ redirects it into $GITHUB_STEP_SUMMARY. Stdlib only.
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
STATUS_ICON = {"expected": "✅", "unexpected": "❌", "skipped": "⏭️", "flaky": "⚠️"}
|
STATUS_ICON = {"expected": "✅", "unexpected": "❌", "skipped": "⏭️", "flaky": "⚠️"}
|
||||||
|
|
||||||
# A verdict alone still costs a log dive, and a killed or truncated job leaves no log to dive into
|
|
||||||
# (#161) — so a failing spec carries its first error into the table. Playwright errors are multi-line
|
|
||||||
# with a "Call log:", which a markdown table cell cannot hold, so they are flattened and clipped.
|
|
||||||
ERROR_CLIP = 300
|
|
||||||
|
|
||||||
|
|
||||||
def first_error(spec):
|
|
||||||
"""The first error message across a spec's test results, flattened for one table cell."""
|
|
||||||
for test in spec.get("tests", []):
|
|
||||||
for result in test.get("results", []):
|
|
||||||
for error in result.get("errors", []):
|
|
||||||
message = (error.get("message") or "").strip()
|
|
||||||
if not message:
|
|
||||||
continue
|
|
||||||
# Strip ANSI colour, collapse to one line, and keep it inside the cell.
|
|
||||||
message = re.sub(r"\x1b\[[0-9;]*m", "", message)
|
|
||||||
message = " ".join(message.split())
|
|
||||||
if len(message) > ERROR_CLIP:
|
|
||||||
message = message[:ERROR_CLIP - 1].rstrip() + "…"
|
|
||||||
# `|` would end the cell early.
|
|
||||||
return message.replace("|", "\\|")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def walk(suite, out):
|
def walk(suite, out):
|
||||||
for spec in suite.get("specs", []):
|
for spec in suite.get("specs", []):
|
||||||
@@ -46,8 +22,7 @@ def walk(suite, out):
|
|||||||
else "expected" if spec.get("ok", False)
|
else "expected" if spec.get("ok", False)
|
||||||
else "unexpected")
|
else "unexpected")
|
||||||
out.append({"file": spec.get("file") or suite.get("file") or suite.get("title", ""),
|
out.append({"file": spec.get("file") or suite.get("file") or suite.get("title", ""),
|
||||||
"title": spec.get("title", ""), "status": status,
|
"title": spec.get("title", ""), "status": status})
|
||||||
"error": first_error(spec) if status in ("unexpected", "flaky") else ""})
|
|
||||||
for child in suite.get("suites", []):
|
for child in suite.get("suites", []):
|
||||||
walk(child, out)
|
walk(child, out)
|
||||||
|
|
||||||
@@ -71,13 +46,6 @@ def main(path):
|
|||||||
if not specs:
|
if not specs:
|
||||||
print("_No specs ran._")
|
print("_No specs ran._")
|
||||||
return 0
|
return 0
|
||||||
# The failure column only earns its width when something failed.
|
|
||||||
if any(s["error"] for s in specs):
|
|
||||||
print("| Spec | Result | Why |")
|
|
||||||
print("| ---- | :----: | --- |")
|
|
||||||
for s in specs:
|
|
||||||
print(f"| {s['file']} › {s['title']} | {STATUS_ICON.get(s['status'], '❔')} | {s['error']} |")
|
|
||||||
else:
|
|
||||||
print("| Spec | Result |")
|
print("| Spec | Result |")
|
||||||
print("| ---- | :----: |")
|
print("| ---- | :----: |")
|
||||||
for s in specs:
|
for s in specs:
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Self-check for infra/playwright-summary.py — stdlib asserts, no framework.
|
|
||||||
|
|
||||||
Run: python3 infra/test_playwright_summary.py (also runs in `make unit`).
|
|
||||||
|
|
||||||
A red e2e is only useful if the job summary says WHY it failed: #161 lost a 36-minute
|
|
||||||
verify-stack job whose only surviving output was one ✘ line with no assertion detail.
|
|
||||||
"""
|
|
||||||
import importlib.util
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
from contextlib import redirect_stdout
|
|
||||||
|
|
||||||
# The script's filename is not a valid module name, so load it by path.
|
|
||||||
spec = importlib.util.spec_from_file_location(
|
|
||||||
"playwright_summary",
|
|
||||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "playwright-summary.py"),
|
|
||||||
)
|
|
||||||
summary = importlib.util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(summary)
|
|
||||||
|
|
||||||
|
|
||||||
def render(report):
|
|
||||||
"""Run the renderer over a report dict and return its markdown."""
|
|
||||||
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fh:
|
|
||||||
json.dump(report, fh)
|
|
||||||
path = fh.name
|
|
||||||
try:
|
|
||||||
out = io.StringIO()
|
|
||||||
with redirect_stdout(out):
|
|
||||||
summary.main(path)
|
|
||||||
return out.getvalue()
|
|
||||||
finally:
|
|
||||||
os.unlink(path)
|
|
||||||
|
|
||||||
|
|
||||||
def spec_entry(title, status, errors=()):
|
|
||||||
return {
|
|
||||||
"title": title,
|
|
||||||
"file": "catalogus.spec.ts",
|
|
||||||
"ok": status == "expected",
|
|
||||||
"tests": [{"status": status, "results": [{"errors": [{"message": m} for m in errors]}]}],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_failing_spec_reports_why():
|
|
||||||
md = render({
|
|
||||||
"stats": {"expected": 4, "unexpected": 1, "flaky": 0, "skipped": 0, "duration": 108_000},
|
|
||||||
"suites": [{"file": "catalogus.spec.ts", "specs": [
|
|
||||||
spec_entry("a beheerder sees the published zaaktypen in the catalogus", "unexpected",
|
|
||||||
["locator.fill: Test timeout of 90000ms exceeded.\n"
|
|
||||||
"Call log:\n - waiting for locator('#username')\n"]),
|
|
||||||
]}],
|
|
||||||
})
|
|
||||||
assert "❌" in md, md
|
|
||||||
# The point of the slice: the summary names the cause, not just the verdict.
|
|
||||||
assert "Test timeout of 90000ms exceeded" in md, md
|
|
||||||
assert "waiting for locator('#username')" in md, md
|
|
||||||
# A multi-line Playwright error must not break out of its table row.
|
|
||||||
assert not any(line.startswith("Call log:") for line in md.splitlines()), md
|
|
||||||
|
|
||||||
|
|
||||||
def test_real_playwright_error_is_flattened():
|
|
||||||
# A real report's message is multi-line and ANSI-coloured, and embeds the source snippet with
|
|
||||||
# `|` gutters — all three would break the table cell. Shape verified against an actual
|
|
||||||
# @playwright/test 1.61 JSON report.
|
|
||||||
md = render({
|
|
||||||
"stats": {"expected": 0, "unexpected": 1, "flaky": 0, "skipped": 0, "duration": 1_000},
|
|
||||||
"suites": [{"file": "catalogus.spec.ts", "specs": [
|
|
||||||
spec_entry("a beheerder sees the catalogus", "unexpected",
|
|
||||||
["Error: expect(locator).toBeVisible() failed\n\n"
|
|
||||||
"\x1b[2mLocator: \x1b[22mgetByRole('heading')\n"
|
|
||||||
" 12 | await login(page);\n> 13 | await expect(heading).toBeVisible();\n"]),
|
|
||||||
]}],
|
|
||||||
})
|
|
||||||
row = [line for line in md.splitlines() if line.startswith("| catalogus.spec.ts")][0]
|
|
||||||
assert "\x1b" not in row, row
|
|
||||||
assert "Locator: getByRole('heading')" in row, row
|
|
||||||
# Every literal `|` from the snippet gutters is escaped, so the row keeps exactly 3 cells.
|
|
||||||
assert row.count("|") - row.count("\\|") == 4, row
|
|
||||||
|
|
||||||
|
|
||||||
def test_passing_run_stays_quiet():
|
|
||||||
md = render({
|
|
||||||
"stats": {"expected": 1, "unexpected": 0, "flaky": 0, "skipped": 0, "duration": 5_000},
|
|
||||||
"suites": [{"file": "catalogus.spec.ts",
|
|
||||||
"specs": [spec_entry("a beheerder sees the catalogus", "expected")]}],
|
|
||||||
})
|
|
||||||
assert "✅" in md, md
|
|
||||||
assert "timeout" not in md.lower(), md
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_report_is_not_a_crash():
|
|
||||||
out = io.StringIO()
|
|
||||||
with redirect_stdout(out):
|
|
||||||
rc = summary.main("/nonexistent/playwright-report.json")
|
|
||||||
assert rc == 0
|
|
||||||
assert "did not reach the e2e step" in out.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
for name, fn in sorted(globals().items()):
|
|
||||||
if name.startswith("test_") and callable(fn):
|
|
||||||
fn()
|
|
||||||
print(f" ok {name}")
|
|
||||||
print("playwright-summary self-check passed")
|
|
||||||
@@ -59,20 +59,6 @@ def services_in_trace(trace_id):
|
|||||||
return names
|
return names
|
||||||
|
|
||||||
|
|
||||||
def tempo_ingest_state():
|
|
||||||
"""#156: distinguish a broken trace chain from Tempo dropping spans. `ingester_clients` is 0
|
|
||||||
when the distributor has evicted its (single, in-process) ingester over a failed loopback
|
|
||||||
health check — pushes fail and spans are lost, which looks identical to missing instrumentation
|
|
||||||
from here. Diagnostics only; never fails the check."""
|
|
||||||
try:
|
|
||||||
for line in _get(f"{TEMPO}/metrics").decode().splitlines():
|
|
||||||
if line.startswith("tempo_distributor_ingester_clients "):
|
|
||||||
return f"tempo {line.strip()} (0 = no ingester in the pool — evicted, so pushes\n are failing and spans are being dropped; see #156)"
|
|
||||||
except Exception as e:
|
|
||||||
return f"tempo /metrics unreadable: {e}"
|
|
||||||
return "tempo_distributor_ingester_clients not reported"
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
deadline = time.time() + TIMEOUT
|
deadline = time.time() + TIMEOUT
|
||||||
generate_traffic()
|
generate_traffic()
|
||||||
@@ -88,7 +74,6 @@ def main():
|
|||||||
generate_traffic()
|
generate_traffic()
|
||||||
print(f"FAIL — no single trace spanned {sorted(WANT)}; services seen: {sorted(seen)}",
|
print(f"FAIL — no single trace spanned {sorted(WANT)}; services seen: {sorted(seen)}",
|
||||||
file=sys.stderr)
|
file=sys.stderr)
|
||||||
print(f" {tempo_ingest_state()}", file=sys.stderr)
|
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-16
@@ -32,17 +32,6 @@ nav:
|
|||||||
- "ADR-0008: Read projection store": architecture/adr-0008-read-projection-store.md
|
- "ADR-0008: Read projection store": architecture/adr-0008-read-projection-store.md
|
||||||
- "ADR-0009: External-task job worker": architecture/adr-0009-external-task-job-worker.md
|
- "ADR-0009: External-task job worker": architecture/adr-0009-external-task-job-worker.md
|
||||||
- "ADR-0010: BFF OIDC validation": architecture/adr-0010-bff-oidc.md
|
- "ADR-0010: BFF OIDC validation": architecture/adr-0010-bff-oidc.md
|
||||||
- FDS-architectuur:
|
|
||||||
- Overzicht: architecture/fds/README.md
|
|
||||||
- Componentview (L3): architecture/fds/c4-component-view.md
|
|
||||||
- "Slice 1: walking skeleton": architecture/fds/slice-1-proposal.md
|
|
||||||
- "FDS ADR-0001: ACL op elke registergrens": architecture/fds/adr/0001-acl-at-every-register-boundary.md
|
|
||||||
- "FDS ADR-0002: FSC voor connectiviteit": architecture/fds/adr/0002-fsc-for-connectivity.md
|
|
||||||
- "FDS ADR-0003: PBAC via OPA": architecture/fds/adr/0003-pbac-via-opa.md
|
|
||||||
- "FDS ADR-0004: Begrensde cache": architecture/fds/adr/0004-bounded-cache.md
|
|
||||||
- "FDS ADR-0005: Verwerkingenlog via events": architecture/fds/adr/0005-ldv-verwerkingenlog.md
|
|
||||||
- "FDS ADR-0006: Modulegrens en hergebruik": architecture/fds/adr/0006-module-boundary-and-reuse.md
|
|
||||||
- "FDS ADR-template": architecture/fds/adr/template.md
|
|
||||||
- Working in Gitea: gitea-workflow.md
|
- Working in Gitea: gitea-workflow.md
|
||||||
- Frontend decisions: frontend-decisions.md
|
- Frontend decisions: frontend-decisions.md
|
||||||
- Demo script: demo-script.md
|
- Demo script: demo-script.md
|
||||||
@@ -53,11 +42,7 @@ markdown_extensions:
|
|||||||
- admonition
|
- admonition
|
||||||
- toc:
|
- toc:
|
||||||
permalink: true
|
permalink: true
|
||||||
- pymdownx.superfences:
|
- pymdownx.superfences
|
||||||
custom_fences:
|
|
||||||
- name: mermaid
|
|
||||||
class: mermaid
|
|
||||||
format: !!python/name:pymdownx.superfences.fence_code_format
|
|
||||||
|
|
||||||
# Many docs referenced by PRD.md land in later slices; don't fail the build on them.
|
# Many docs referenced by PRD.md land in later slices; don't fail the build on them.
|
||||||
validation:
|
validation:
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import { loginMedewerker } from './keycloak-login';
|
|
||||||
|
|
||||||
// S-15a walking skeleton: a beheerder logs in to the beheer portal (medewerker realm) and sees the
|
// 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
|
// read-only ZTC catalogus. The verify stack seeds and publishes the BIG-REGISTRATIE zaaktype (the
|
||||||
@@ -8,9 +7,10 @@ import { loginMedewerker } from './keycloak-login';
|
|||||||
test('a beheerder sees the published zaaktypen in the catalogus', async ({ page }) => {
|
test('a beheerder sees the published zaaktypen in the catalogus', async ({ page }) => {
|
||||||
await page.goto('http://beheer/');
|
await page.goto('http://beheer/');
|
||||||
|
|
||||||
// The beheer portal redirects to the Keycloak medewerker realm login (same realm as behandel),
|
// The beheer portal redirects to the Keycloak medewerker realm login (same realm as behandel).
|
||||||
// which enforces MFA: password, then a TOTP code.
|
await page.locator('#username').fill('bram-beheerder');
|
||||||
await loginMedewerker(page, 'bram-beheerder');
|
await page.locator('#password').fill('test123');
|
||||||
|
await page.locator('#kc-login').click();
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: /Catalogus/i })).toBeVisible();
|
await expect(page.getByRole('heading', { name: /Catalogus/i })).toBeVisible();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import { loginMedewerker } from './keycloak-login';
|
|
||||||
|
|
||||||
// S-15b: a beheerder edits the ACL default-fill in the beheer portal and gets a saved confirmation.
|
// 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
|
// Runs against the shared verify stack; it edits + saves (the ACL store is in-memory, ADR-0026) and
|
||||||
@@ -7,8 +6,10 @@ import { loginMedewerker } from './keycloak-login';
|
|||||||
test('a beheerder edits and saves the default-fill', async ({ page }) => {
|
test('a beheerder edits and saves the default-fill', async ({ page }) => {
|
||||||
await page.goto('http://beheer/');
|
await page.goto('http://beheer/');
|
||||||
|
|
||||||
// Keycloak medewerker-realm login (same realm as behandel) — password + enforced TOTP.
|
// Keycloak medewerker-realm login (same realm as behandel).
|
||||||
await loginMedewerker(page, 'bram-beheerder');
|
await page.locator('#username').fill('bram-beheerder');
|
||||||
|
await page.locator('#password').fill('test123');
|
||||||
|
await page.locator('#kc-login').click();
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: /Catalogus/i })).toBeVisible();
|
await expect(page.getByRole('heading', { name: /Catalogus/i })).toBeVisible();
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
|
||||||
import { OTP_PERIOD_MS, nextUnusedCounter } from './keycloak-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
|
|
||||||
});
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import { createHmac } from 'node:crypto';
|
|
||||||
import { readFileSync, writeFileSync } from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { expect, type Page } from '@playwright/test';
|
|
||||||
|
|
||||||
// Every portal login in the suite goes through this module — citizen realms (mock DigiD) and the
|
|
||||||
// medewerker realm alike — so the shared Keycloak form handling lives in exactly one place.
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How long a Keycloak form gets to appear. Generous enough for a cold first browser launch and a
|
|
||||||
* loaded stack, far short of the 90-second test timeout an auto-waiting action would otherwise eat.
|
|
||||||
*/
|
|
||||||
const FORM_TIMEOUT_MS = 20_000;
|
|
||||||
const FORM_NEVER_APPEARED =
|
|
||||||
'the Keycloak login form never appeared — the portal did not reach Keycloak (check its ' +
|
|
||||||
'config.json fetch and the OIDC discovery on the authority it was built with)';
|
|
||||||
const OTP_NEVER_APPEARED =
|
|
||||||
'the Keycloak OTP form never appeared — the password step did not complete (check the ' +
|
|
||||||
'medewerker realm seeded this user with both a password and a TOTP credential)';
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fill Keycloak's login form. Every portal is guarded, so the first navigation redirects here; the
|
|
||||||
* form ids are stable across themes.
|
|
||||||
*
|
|
||||||
* The form is asserted visible *before* it is filled. A portal that never reaches Keycloak — its
|
|
||||||
* runtime `config.json` fetch or the OIDC discovery behind `authorize()` failed, so it never
|
|
||||||
* bootstrapped and shows a blank page (main.ts only logs to the console) — would otherwise leave
|
|
||||||
* `fill()` auto-waiting until the whole test times out: 90 seconds spent to report
|
|
||||||
* `locator.fill: Test timeout of 90000ms exceeded`, naming the symptom and not the cause. That is
|
|
||||||
* how #161's catalogus.spec burned 1.8 minutes. This fails in a quarter of the time and says which
|
|
||||||
* step never happened.
|
|
||||||
*/
|
|
||||||
async function submitPassword(page: Page, username: string): Promise<void> {
|
|
||||||
await expect(page.locator('#username'), FORM_NEVER_APPEARED).toBeVisible({ timeout: FORM_TIMEOUT_MS });
|
|
||||||
await page.locator('#username').fill(username);
|
|
||||||
await page.locator('#password').fill('test123');
|
|
||||||
await page.locator('#kc-login').click();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A citizen login on a mock-DigiD realm — no second factor (ADR-0031). */
|
|
||||||
export async function loginBurger(page: Page, username: string): Promise<void> {
|
|
||||||
await submitPassword(page, username);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A staff login on the medewerker realm: password, then the enforced TOTP second factor. */
|
|
||||||
export async function loginMedewerker(page: Page, username: string): Promise<void> {
|
|
||||||
await submitPassword(page, username);
|
|
||||||
|
|
||||||
// Keycloak's conditional-OTP step. Same reasoning as the password form above: assert it arrived
|
|
||||||
// rather than letting `fill()` swallow the test timeout.
|
|
||||||
await expect(page.locator('#otp'), OTP_NEVER_APPEARED).toBeVisible({ timeout: FORM_TIMEOUT_MS });
|
|
||||||
|
|
||||||
// Wait out the rest of the window if the counter we may spend is still in the future; Keycloak's
|
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
@@ -15,12 +15,6 @@ export default defineConfig({
|
|||||||
timeout: 90_000,
|
timeout: 90_000,
|
||||||
expect: { timeout: 15_000 },
|
expect: { timeout: 15_000 },
|
||||||
retries: 1,
|
retries: 1,
|
||||||
// Bound the whole run, not just each test (#161). A wedged suite used to run until CI killed the
|
|
||||||
// job — which also killed the `if: always()` steps that would have said why: the per-spec summary
|
|
||||||
// and the container-log dump never ran, leaving a 36-minute job whose entire surviving output was
|
|
||||||
// one ✘ line. On `globalTimeout` Playwright stops and *reports*, so the JSON report is written and
|
|
||||||
// those steps still run. Generous over the ~1-minute suite: this is a backstop, not a budget.
|
|
||||||
globalTimeout: 12 * 60_000,
|
|
||||||
// Run the specs serially. Each spec drives a full `channel: 'chromium'` browser, and the e2e
|
// Run the specs serially. Each spec drives a full `channel: 'chromium'` browser, and the e2e
|
||||||
// shares an 8 GB runner with the entire compose stack (OpenZaak, NRC, Keycloak, Flowable, 4×
|
// shares an 8 GB runner with the entire compose stack (OpenZaak, NRC, Keycloak, Flowable, 4×
|
||||||
// Postgres, every service + 3 portals). Two parallel browsers exhaust memory and the renderer is
|
// Postgres, every service + 3 portals). Two parallel browsers exhaust memory and the renderer is
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { expect, request, test } from '@playwright/test';
|
import { expect, request, test } from '@playwright/test';
|
||||||
import { loginBurger, loginMedewerker } from './keycloak-login';
|
|
||||||
|
|
||||||
// Walking-skeleton happy path (S-08d + S-09 + S-09b + S-12 + S-10a + S-19b-2): a zorgprofessional
|
// 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
|
// logs in via mock DigiD and submits through the self-service portal → BFF → domain; the entry
|
||||||
@@ -23,7 +22,9 @@ test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt
|
|||||||
// checks submit as jan-burger (bsn 123456782) before the e2e runs on the shared stack, and
|
// checks submit as jan-burger (bsn 123456782) before the e2e runs on the shared stack, and
|
||||||
// resume-on-load (S-26) would otherwise restore one of those on login — so each self-service spec
|
// resume-on-load (S-26) would otherwise restore one of those on login — so each self-service spec
|
||||||
// uses a dedicated citizen no other actor touches.
|
// uses a dedicated citizen no other actor touches.
|
||||||
await loginBurger(page, 'emma-burger');
|
await page.locator('#username').fill('emma-burger');
|
||||||
|
await page.locator('#password').fill('test123');
|
||||||
|
await page.locator('#kc-login').click();
|
||||||
|
|
||||||
// Back on the portal, authenticated.
|
// Back on the portal, authenticated.
|
||||||
await expect(page.getByRole('heading', { name: /Zelfservice/i })).toBeVisible();
|
await expect(page.getByRole('heading', { name: /Zelfservice/i })).toBeVisible();
|
||||||
@@ -57,32 +58,12 @@ test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt
|
|||||||
await expect(staff.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGEDIEND' }))
|
await expect(staff.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGEDIEND' }))
|
||||||
.toBeVisible();
|
.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.
|
// 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 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
|
// 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
|
// 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
|
// wait and no-op. (S-10b turns this into a real file upload; here it is the trigger that unblocks
|
||||||
// beoordeling.)
|
// beoordeling.)
|
||||||
await page.bringToFront();
|
|
||||||
await page.setInputFiles('#diploma', {
|
await page.setInputFiles('#diploma', {
|
||||||
name: 'diploma.pdf',
|
name: 'diploma.pdf',
|
||||||
mimeType: 'application/pdf',
|
mimeType: 'application/pdf',
|
||||||
@@ -91,11 +72,27 @@ test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt
|
|||||||
await page.getByRole('button', { name: /documenten aanleveren/i }).click();
|
await page.getByRole('button', { name: /documenten aanleveren/i }).click();
|
||||||
await expect(page.getByText(/documenten zijn aangeleverd/i)).toBeVisible();
|
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
|
// A behandelaar picks the registration up in the behandel-portal werkbak and approves it (goedkeuren)
|
||||||
// DMN routes the registration to Beoordelen. (Foregrounded so Chromium doesn't throttle the page's
|
// — the S-12 flow that replaces the temporary admin endpoint. The staff tab switches to the
|
||||||
// refresh timer as a hidden tab.)
|
// medewerker realm (a different Keycloak realm than the citizen's digid session).
|
||||||
await staff.bringToFront();
|
await staff.goto('http://behandel/');
|
||||||
await expect(goedkeuren).toBeVisible({ timeout: 30_000 });
|
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);
|
||||||
|
|
||||||
// Click and wait for the decide POST to finish (204) BEFORE leaving the page. `click()` only
|
// 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
|
// dispatches the request; navigating away immediately cancels it in flight (nginx logs a 499) and
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import { loginBurger } from './keycloak-login';
|
|
||||||
|
|
||||||
// S-26: a zorgprofessional submits, then reloads the self-service portal. On load the portal asks the
|
// S-26: a zorgprofessional submits, then reloads the self-service portal. On load the portal asks the
|
||||||
// BFF for the caller's current open registration (owner-scoped by the DigiD token's bsn) and restores
|
// BFF for the caller's current open registration (owner-scoped by the DigiD token's bsn) and restores
|
||||||
@@ -10,7 +9,9 @@ test('DigiD submit → reload → self-service restores the existing registratio
|
|||||||
// Its own DigiD user (like every self-service spec): on the shared verify stack, resume-on-load
|
// Its own DigiD user (like every self-service spec): on the shared verify stack, resume-on-load
|
||||||
// (S-26) restores any open registration for the bsn, so each spec uses a dedicated citizen that no
|
// (S-26) restores any open registration for the bsn, so each spec uses a dedicated citizen that no
|
||||||
// other spec or verify-* check touches. This one in particular leaves an open registration.
|
// other spec or verify-* check touches. This one in particular leaves an open registration.
|
||||||
await loginBurger(page, 'sanne-burger');
|
await page.locator('#username').fill('sanne-burger');
|
||||||
|
await page.locator('#password').fill('test123');
|
||||||
|
await page.locator('#kc-login').click();
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: /Zelfservice/i })).toBeVisible();
|
await expect(page.getByRole('heading', { name: /Zelfservice/i })).toBeVisible();
|
||||||
await page.getByRole('button', { name: /indienen/i }).click();
|
await page.getByRole('button', { name: /indienen/i }).click();
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import { loginBurger } from './keycloak-login';
|
|
||||||
|
|
||||||
// S-11 (Flow 3): a zorgprofessional logs in via mock DigiD, submits a registration, then withdraws
|
// S-11 (Flow 3): a zorgprofessional logs in via mock DigiD, submits a registration, then withdraws
|
||||||
// it ("trek aanvraag in") from the self-service portal. The withdrawal goes portal → BFF (owner-
|
// it ("trek aanvraag in") from the self-service portal. The withdrawal goes portal → BFF (owner-
|
||||||
@@ -11,7 +10,9 @@ test('DigiD submit → trek aanvraag in → self-service confirms ingetrokken',
|
|||||||
|
|
||||||
// Its own DigiD user — isolated from the verify-* checks (jan-burger/123456782) so resume-on-load
|
// Its own DigiD user — isolated from the verify-* checks (jan-burger/123456782) so resume-on-load
|
||||||
// (S-26) can't restore someone else's registration on the shared stack.
|
// (S-26) can't restore someone else's registration on the shared stack.
|
||||||
await loginBurger(page, 'lars-burger');
|
await page.locator('#username').fill('lars-burger');
|
||||||
|
await page.locator('#password').fill('test123');
|
||||||
|
await page.locator('#kc-login').click();
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: /Zelfservice/i })).toBeVisible();
|
await expect(page.getByRole('heading', { name: /Zelfservice/i })).toBeVisible();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user