diff --git a/docs/project/backlog/README.md b/docs/project/backlog/README.md index 57e633e..33b487d 100644 --- a/docs/project/backlog/README.md +++ b/docs/project/backlog/README.md @@ -84,7 +84,7 @@ for its existing violations, so every WP ends green. | [WP-37](WP-37-dev-switcher-reset.md) | Dev-switcher reset fix (scenario/role URL param) | 8 · platform/DX/showcase | done | | [WP-38](WP-38-dependency-graph-boundaries.md) | Dependency graph + declarative boundaries (visualize + enforce) | 8 · platform/DX/showcase | done | | [WP-39](WP-39-showcase-snippets-animations.md) | Showcase: linked code snippets + teaching animations | 8 · platform/DX/showcase | done | -| [WP-40](WP-40-pii-kernel.md) | PII kernel: branded `Bsn` VO (elfproef) + masked-value atom | 8 · platform/DX/showcase | todo | +| [WP-40](WP-40-pii-kernel.md) | PII kernel: branded `Bsn` VO (elfproef) + masked-value atom | 8 · platform/DX/showcase | done | | [WP-41](WP-41-persisted-authz-audit.md) | Persisted, queryable authz/PII-reveal audit (no PII) | 8 · platform/DX/showcase | todo | | [WP-42](WP-42-privacy-security-showcase.md) | Privacy & security showcase page (mask + no-PII log) | 8 · platform/DX/showcase | todo | | [WP-43](WP-43-scaffold-generators.md) | Runnable generators: value-object / form-machine / bff-endpoint / ui-component | 8 · platform/DX/showcase | todo | diff --git a/docs/project/backlog/WP-40-pii-kernel.md b/docs/project/backlog/WP-40-pii-kernel.md index a5c5277..af18740 100644 --- a/docs/project/backlog/WP-40-pii-kernel.md +++ b/docs/project/backlog/WP-40-pii-kernel.md @@ -1,9 +1,27 @@ # WP-40 — PII kernel: branded `Bsn` VO + masked-value atom -Status: todo +Status: done Phase: 8 — platform/DX/showcase Priority: P2 +## Outcome + +- `shared/kernel/bsn.ts` — `Bsn` branded VO + `parseBsn` with the **elfproef** (11-test) checksum + (+spec). Wired into the DigiD login boundary (`digid.adapter`): login now does real BSN + validation instead of "any 9 digits" — login hint + e2e BSNs updated to a valid one (`123456782`). +- `shared/kernel/pii.ts` — pure `maskBsn`/`maskTail`/`REDACTED` (+spec), the functional core. Moved + out of `debug-state/mask.ts`, which keeps only `redactProfile` (it depends on the registratie + `BigProfile`, so it can't live in `shared/kernel` — boundary; debug-state is the sanctioned + cross-context devtool). `mask.spec` ported to `pii.spec`. +- `` atom (`shared/ui/masked-value`) + story — masked-by-default value with an + optional reveal affordance; **centralises** the `.includes('*')` masked-detection that + `behandel-scherm` used to sniff inline (now refactored to the atom). The atom only emits + `reveal`; the caller keeps the step-up confirm + audited fetch. +- Deviation (flagged): `Session.bsn` stays typed `string` — the brand guards the login ENTRY, but + Session crosses a persistence boundary where the restore path deliberately drops the bsn + (privacy) and JSON can't carry a brand, so re-typing it would add a `''`/nullable ripple for no + real gain. Backend `MaskTail` twin unchanged (still in sync). + ## Why Masking is real but ad-hoc: pure helpers live in `debug-state/mask.ts`, the BSN is a bare `string` @@ -29,6 +47,6 @@ AVG art. 9 (BSN = special category) territory; the pieces should be first-class ## Acceptance criteria -- [ ] `parseBsn` accepts valid elfproef numbers, rejects bad checksum/length (spec). -- [ ] `` renders masked by default with an accessible reveal; behandel-scherm uses it. -- [ ] Pure maskers have specs (no TestBed); backend `MaskTail` parity noted; `npm run ci` green. +- [x] `parseBsn` accepts valid elfproef numbers, rejects bad checksum/length/all-zeros (spec). +- [x] `` renders masked by default with an optional reveal; behandel-scherm uses it (no more inline sniff). +- [x] Pure maskers have specs (no TestBed); backend `MaskTail` parity noted; `npm run ci` green. diff --git a/e2e/error-state.spec.ts b/e2e/error-state.spec.ts index d5af436..9797d0a 100644 --- a/e2e/error-state.spec.ts +++ b/e2e/error-state.spec.ts @@ -12,7 +12,7 @@ import { expect, test } from '@playwright/test'; // here: a real reload cycle, not a no-op button. test('dashboard error state renders, retry re-fetches (and fails again)', async ({ page }) => { await page.goto('/login'); - await page.getByLabel('BSN').fill('123456789'); + await page.getByLabel('BSN').fill('123456782'); await page.getByRole('button', { name: 'Inloggen met DigiD' }).click(); await expect(page).toHaveURL(/\/dashboard$/); diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 5336244..5b10f45 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -12,7 +12,7 @@ import { expect, test } from '@playwright/test'; // stricter future test might. test('login → dashboard → registratie wizard → submitted', async ({ page }) => { await page.goto('/login'); - await page.getByLabel('BSN').fill('123456789'); + await page.getByLabel('BSN').fill('123456782'); await page.getByLabel('Wachtwoord').fill('demo'); await page.getByRole('button', { name: 'Inloggen met DigiD' }).click(); diff --git a/src/app/auth/infrastructure/digid.adapter.ts b/src/app/auth/infrastructure/digid.adapter.ts index 489bec3..a4956d0 100644 --- a/src/app/auth/infrastructure/digid.adapter.ts +++ b/src/app/auth/infrastructure/digid.adapter.ts @@ -1,16 +1,16 @@ import { Injectable } from '@angular/core'; -import { Result, ok, err } from '@shared/kernel/fp'; +import { Result, ok } from '@shared/kernel/fp'; +import { parseBsn } from '@shared/kernel/bsn'; import { Session } from '../domain/session'; /** Infrastructure: talks to the (mock) DigiD identity provider. */ @Injectable({ providedIn: 'root' }) export class DigidAdapter { - // ponytail: fake DigiD — any 9-digit BSN authenticates to a fixed identity. - // Swap for a real OIDC redirect flow when there's a backend. + // ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity. + // Real BSN validation (parseBsn, WP-40) is the trust boundary; swap the fixed identity + // for a real OIDC redirect flow when there's an IdP. async authenticate(bsn: string): Promise> { - const t = bsn.trim(); - if (!/^\d{9}$/.test(t)) - return err($localize`:@@validation.bsn:Voer een geldig BSN van 9 cijfers in.`); - return ok({ bsn: t, naam: 'Dr. A. (Anna) de Vries' }); + const r = parseBsn(bsn); + return r.ok ? ok({ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r; } } diff --git a/src/app/auth/ui/login-form/login-form.component.ts b/src/app/auth/ui/login-form/login-form.component.ts index 5fa2dc9..d11c5d3 100644 --- a/src/app/auth/ui/login-form/login-form.component.ts +++ b/src/app/auth/ui/login-form/login-form.component.ts @@ -22,14 +22,14 @@ import { ButtonComponent } from '@shared/ui/button/button.component'; fieldId="bsn" required i18n-description="@@login.bsnDescription" - description="9 cijfers (demo: vul iets in)" + description="9-cijferig BSN, elfproef-geldig (demo: 123456782)" > diff --git a/src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts b/src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts index 104a30d..996efbb 100644 --- a/src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts +++ b/src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts @@ -2,6 +2,7 @@ import { Component, ElementRef, computed, input, output, viewChild } from '@angu import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component'; import { ButtonComponent } from '@shared/ui/button/button.component'; import { HeadingComponent } from '@shared/ui/heading/heading.component'; +import { MaskedValueComponent } from '@shared/ui/masked-value/masked-value.component'; import { StepperComponent } from '@shared/ui/stepper/stepper.component'; import { Besluit, Brief, CaseContext, LibraryPassage } from '@brief/domain/brief'; import { inferSelection } from '@brief/domain/besluit'; @@ -25,6 +26,7 @@ import { BesluitPanelComponent } from '@brief/ui/besluit-panel/besluit-panel.com imports: [ ButtonComponent, HeadingComponent, + MaskedValueComponent, StepperComponent, LetterCanvasComponent, DiagnosticsPanelComponent, @@ -92,10 +94,13 @@ import { BesluitPanelComponent } from '@brief/ui/besluit-panel/besluit-panel.com {{ caseContext().aanvraagReferentie }} {{ caseContext().zorgverlenerNaam }} - {{ bigLabel() }} {{ caseContext().bigNummer }} - @if (canRevealBigNummer() && isMasked()) { - {{ revealLabel() }} - } + {{ bigLabel() }} + {{ caseContext().beroep }} @@ -172,10 +177,6 @@ export class BehandelSchermComponent { locate = output(); revealBigNummer = output(); - /** The BIG-nummer arrives masked (contains `*`); once revealed the swapped value has - no `*`, so the reveal action hides itself — no separate "revealed" flag needed. */ - protected isMasked = computed(() => this.caseContext().bigNummer.includes('*')); - /** Step-up (PRD-0002 §5d) stubbed as a native confirm — the extra verification gesture before an audited PII reveal. ponytail: real systems prompt MFA / recent re-auth. */ protected onReveal() { diff --git a/src/app/shared/kernel/bsn.spec.ts b/src/app/shared/kernel/bsn.spec.ts new file mode 100644 index 0000000..976512d --- /dev/null +++ b/src/app/shared/kernel/bsn.spec.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { parseBsn } from './bsn'; + +describe('parseBsn (elfproef)', () => { + it('accepts a valid BSN (passes the elfproef)', () => { + const r = parseBsn('123456782'); // Σ d·w = 154, divisible by 11 + expect(r.ok && r.value).toBe('123456782'); + }); + + it('rejects a 9-digit number that fails the elfproef', () => { + expect(parseBsn('123456789').ok).toBe(false); // sum 147, not divisible + }); + + it('rejects wrong length / non-digits / all zeros', () => { + expect(parseBsn('12345').ok).toBe(false); + expect(parseBsn('abcdefghi').ok).toBe(false); + expect(parseBsn('000000000').ok).toBe(false); + }); +}); diff --git a/src/app/shared/kernel/bsn.ts b/src/app/shared/kernel/bsn.ts new file mode 100644 index 0000000..d9ec4cf --- /dev/null +++ b/src/app/shared/kernel/bsn.ts @@ -0,0 +1,26 @@ +import { Brand, Result, ok, err } from '@shared/kernel/fp'; + +/** + * Value object: a Dutch **BSN** (burgerservicenummer) — art. 9 GDPR/AVG special-category + * data. "Parse, don't validate": a `Bsn` is a distinct type from a raw string, mintable only + * via `parseBsn`, so holding one is proof it passed the **elfproef** (11-test) checksum, not + * just a 9-digit shape. Format/checksum only — identity is still faked in this POC (DigiD stub). + */ +export type Bsn = Brand; + +// Positional weights for the elfproef: 9·d1 + 8·d2 + … + 2·d8 − 1·d9 ≡ 0 (mod 11). +const WEIGHTS = [9, 8, 7, 6, 5, 4, 3, 2, -1]; + +export function parseBsn(raw: string): Result { + const t = raw.trim(); + if (!/^\d{9}$/.test(t)) { + return err($localize`:@@validation.bsn:Voer een geldig BSN van 9 cijfers in.`); + } + const sum = [...t].reduce((acc, ch, i) => acc + Number(ch) * WEIGHTS[i], 0); + if (t === '000000000' || sum % 11 !== 0) { + return err( + $localize`:@@validation.bsnElfproef:Dit is geen geldig BSN (klopt niet met de elfproef).`, + ); + } + return ok(t as Bsn); +} diff --git a/src/app/shared/kernel/pii.spec.ts b/src/app/shared/kernel/pii.spec.ts new file mode 100644 index 0000000..5fece27 --- /dev/null +++ b/src/app/shared/kernel/pii.spec.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import { maskBsn, maskTail } from './pii'; + +describe('pii maskers', () => { + it('maskBsn keeps the last 3 digits', () => { + expect(maskBsn('123456789')).toBe('******789'); + }); + + it('maskTail keeps the requested tail length', () => { + expect(maskTail('abcdef', 2)).toBe('****ef'); + }); + + it('masks the whole value when it is not longer than the kept tail', () => { + expect(maskBsn('12')).toBe('**'); + expect(maskBsn('')).toBe(''); + }); +}); diff --git a/src/app/shared/kernel/pii.ts b/src/app/shared/kernel/pii.ts new file mode 100644 index 0000000..85b54f2 --- /dev/null +++ b/src/app/shared/kernel/pii.ts @@ -0,0 +1,17 @@ +/** + * PII masking — pure functional core (WP-40). Data-minimisation helpers shared by the app + * (dev state panel, the masked-value atom, anywhere sensitive data is shown). No framework, + * no domain imports. The backend keeps a `MaskTail` twin in sync (see Program.cs). + */ +export const REDACTED = '‹redacted›'; + +/** Keep the last `keep` characters, mask the rest with `*`. */ +export function maskTail(value: string, keep: number): string { + if (value.length <= keep) return '*'.repeat(value.length); + return '*'.repeat(value.length - keep) + value.slice(-keep); +} + +/** Mask a BSN / BIG-nummer for display: keep the last 3 digits, mask the rest. */ +export function maskBsn(value: string): string { + return maskTail(value, 3); +} diff --git a/src/app/shared/ui/debug-state/debug-state.component.ts b/src/app/shared/ui/debug-state/debug-state.component.ts index 474002f..488756e 100644 --- a/src/app/shared/ui/debug-state/debug-state.component.ts +++ b/src/app/shared/ui/debug-state/debug-state.component.ts @@ -8,7 +8,8 @@ import { Role } from '@shared/domain/role'; import { ROLES, currentRole, setRole } from '@shared/infrastructure/role'; import { Scenario, SCENARIOS, currentScenario, setScenario } from '@shared/infrastructure/scenario'; import { stripDevParams } from '@shared/infrastructure/dev-params'; -import { maskBsn, redactProfile } from './mask'; +import { maskBsn } from '@shared/kernel/pii'; +import { redactProfile } from './mask'; // CIBG-GAP EXTENSION: n/a — devtool, no corresponding CIBG concept; deliberately // off-theme by design (see the ponytail note below), see cibg-gaps.mdx. diff --git a/src/app/shared/ui/debug-state/mask.spec.ts b/src/app/shared/ui/debug-state/mask.spec.ts deleted file mode 100644 index 06bedbe..0000000 --- a/src/app/shared/ui/debug-state/mask.spec.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { maskBsn } from './mask'; - -describe('maskBsn', () => { - it('keeps the last 3 digits and masks the rest', () => { - expect(maskBsn('123456789')).toBe('******789'); - }); - - it('handles short and empty input without throwing', () => { - expect(maskBsn('12')).toBe('**'); - expect(maskBsn('')).toBe(''); - }); -}); diff --git a/src/app/shared/ui/debug-state/mask.ts b/src/app/shared/ui/debug-state/mask.ts index 111513c..0328649 100644 --- a/src/app/shared/ui/debug-state/mask.ts +++ b/src/app/shared/ui/debug-state/mask.ts @@ -1,23 +1,14 @@ import { BigProfile } from '@registratie/domain/big-profile'; - -const REDACTED = '‹redacted›'; - -/** Keep the last `keep` characters, mask the rest. */ -function maskTail(value: string, keep: number): string { - if (value.length <= keep) return '*'.repeat(value.length); - return '*'.repeat(value.length - keep) + value.slice(-keep); -} - -/** Redact a BSN for the dev state view: keep the last 3 digits, mask the rest. */ -export function maskBsn(bsn: string): string { - return maskTail(bsn, 3); -} +import { REDACTED, maskTail } from '@shared/kernel/pii'; /** * Data minimisation for the dev "show the Model" panel: keep the structural / * decision-relevant fields (status, beroep, dates of registration) but redact * direct personal identifiers (name, address, date of birth) and mask the BIG - * number. The panel is for inspecting state SHAPE, never for reading PII. + * number. The panel is for inspecting state SHAPE, never for reading PII. The + * generic maskers live in `@shared/kernel/pii`; this stays here because it depends + * on the registratie `BigProfile` domain type (debug-state is the sanctioned + * cross-context devtool). */ export function redactProfile(p: BigProfile): unknown { return { diff --git a/src/app/shared/ui/masked-value/masked-value.component.ts b/src/app/shared/ui/masked-value/masked-value.component.ts new file mode 100644 index 0000000..260c436 --- /dev/null +++ b/src/app/shared/ui/masked-value/masked-value.component.ts @@ -0,0 +1,32 @@ +import { Component, computed, input, output } from '@angular/core'; +import { ButtonComponent } from '@shared/ui/button/button.component'; + +/** + * Atom: a possibly-masked sensitive value (BSN, BIG-nummer, …) with an optional, audited + * reveal affordance (WP-40). The value arrives masked from the server (data-minimisation) + * and is swapped for the full value on reveal; the reveal button shows only when the value + * is still masked AND the caller says the principal may reveal it. Centralises the + * masked-detection that consumers used to sniff inline. The atom only emits `reveal`; the + * caller owns the step-up gesture + the audited fetch (see behandel-scherm). + * + * ponytail: masked-detection is the mask character (`*`) — a POC heuristic. A server-sent + * `masked` boolean would remove the sniff; wire it here without touching consumers. + */ +@Component({ + selector: 'app-masked-value', + imports: [ButtonComponent], + template: ` + {{ value() }} + @if (canReveal() && masked()) { + {{ revealLabel() }} + } + `, +}) +export class MaskedValueComponent { + value = input.required(); + canReveal = input(false); + revealLabel = input($localize`:@@maskedValue.reveal:Tonen`); + reveal = output(); + + protected masked = computed(() => this.value().includes('*')); +} diff --git a/src/app/shared/ui/masked-value/masked-value.stories.ts b/src/app/shared/ui/masked-value/masked-value.stories.ts new file mode 100644 index 0000000..77cc543 --- /dev/null +++ b/src/app/shared/ui/masked-value/masked-value.stories.ts @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { MaskedValueComponent } from './masked-value.component'; + +const meta: Meta = { + title: 'Design System/Atoms/Masked Value', + component: MaskedValueComponent, +}; +export default meta; +type Story = StoryObj; + +/** Masked + the principal may reveal → the reveal button shows. */ +export const RevealableMasked: Story = { + args: { value: '******601', canReveal: true, revealLabel: 'Toon BIG-nummer' }, +}; + +/** Masked but no reveal right → just the masked value, no affordance. */ +export const MaskedNoReveal: Story = { + args: { value: '******601', canReveal: false }, +}; + +/** Already revealed (no mask character) → no reveal button even with the right. */ +export const Revealed: Story = { + args: { value: '990000000012', canReveal: true }, +}; diff --git a/src/locale/messages.en.xlf b/src/locale/messages.en.xlf index 0d7aea5..2ca24d7 100644 --- a/src/locale/messages.en.xlf +++ b/src/locale/messages.en.xlf @@ -6,8 +6,24 @@ Voer een geldig BSN van 9 cijfers in. Enter a valid 9-digit BSN. - src/app/auth/infrastructure/digid.adapter.ts - 13 + src/app/shared/kernel/bsn.ts + 15 + + + + Dit is geen geldig BSN (klopt niet met de elfproef). + This is not a valid BSN (fails the eleven-test checksum). + + src/app/shared/kernel/bsn.ts + 20 + + + + Tonen + Show + + src/app/shared/ui/masked-value/masked-value.component.ts + 27 @@ -35,8 +51,8 @@ - 9 cijfers (demo: vul iets in) - 9 digits (demo: fill in anything) + 9-cijferig BSN, elfproef-geldig (demo: 123456782) + 9-digit BSN, valid eleven-test checksum (demo: 123456782) src/app/auth/ui/login-form/login-form.component.ts 25,28 diff --git a/src/locale/messages.xlf b/src/locale/messages.xlf index dc7b2d6..a9df589 100644 --- a/src/locale/messages.xlf +++ b/src/locale/messages.xlf @@ -2,13 +2,6 @@ - - Voer een geldig BSN van 9 cijfers in. - - src/app/auth/infrastructure/digid.adapter.ts - 13 - - * verplichte velden @@ -32,7 +25,7 @@ - 9 cijfers (demo: vul iets in) + 9-cijferig BSN, elfproef-geldig (demo: 123456782) src/app/auth/ui/login-form/login-form.component.ts 25,28 @@ -322,81 +315,81 @@ Opnieuw indienen src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts - 221 + 222 Indienen ter beoordeling src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts - 222 + 223 Beoordelen src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts - 226 + 227 Brief opstellen src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts - 227 + 228 src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts - 231 + 232 Indienen src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts - 228 + 229 Herregistratie behandelen src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts - 230 + 231 Aanvraag herregistratie src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts - 232 + 233 BIG-nummer src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts - 233 + 234 Toon BIG-nummer src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts - 234 + 235 Extra verificatie vereist. Het tonen van het BIG-nummer wordt vastgelegd. Doorgaan? src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts - 236 + 237 Voorbeeld src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts - 238 + 239 src/app/brief/ui/letter-composer/letter-composer.component.ts @@ -407,21 +400,21 @@ Openen als document (PDF) src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts - 240 + 241 Sluiten src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts - 242 + 243 Vul eerst alle verplichte secties en los fouten op. src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts - 244 + 245 @@ -1070,11 +1063,11 @@ Maak een keuze. src/app/herregistratie/domain/intake.machine.ts - 100 + 102 src/app/herregistratie/domain/intake.machine.ts - 112 + 114 src/app/registratie/domain/registratie-wizard.machine.ts @@ -1085,7 +1078,7 @@ Vul een land in. src/app/herregistratie/domain/intake.machine.ts - 103 + 105 @@ -1681,7 +1674,7 @@ Voer een geldige postcode in, bijv. 1234 AB. src/app/registratie/domain/value-objects/postcode.ts - 13 + 14 @@ -2614,6 +2607,20 @@ 28 + + Voer een geldig BSN van 9 cijfers in. + + src/app/shared/kernel/bsn.ts + 17 + + + + Dit is geen geldig BSN (klopt niet met de elfproef). + + src/app/shared/kernel/bsn.ts + 21 + + Huisstijl @@ -2936,6 +2943,13 @@ 31 + + Tonen + + src/app/shared/ui/masked-value/masked-value.component.ts + 28 + + wordt automatisch ingevuld