feat(privacy): WP-40 — PII kernel (Bsn value object + masked-value atom)
Branded Bsn value object with the elfproef (11-test) checksum in shared/kernel/bsn.ts,
wired into the DigiD login boundary so login does real BSN validation (hint + e2e BSNs
updated to a valid 123456782). Consolidate the pure maskers into shared/kernel/pii.ts
(maskBsn/maskTail/REDACTED); debug-state keeps redactProfile (needs the registratie
BigProfile — boundary). New <app-masked-value> atom (+story) centralises the masked
`.includes('*')` detection + reveal affordance; behandel-scherm refactored onto it.
Session.bsn stays string (persistence boundary drops it for privacy). +specs for bsn/pii.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Result<string, Session>> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="bsn"
|
||||
hasDescription
|
||||
[(ngModel)]="bsn"
|
||||
name="bsn"
|
||||
placeholder="123456789"
|
||||
placeholder="123456782"
|
||||
/>
|
||||
</app-form-field>
|
||||
|
||||
|
||||
@@ -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
|
||||
<span>{{ caseContext().aanvraagReferentie }}</span>
|
||||
<span>{{ caseContext().zorgverlenerNaam }}</span>
|
||||
<span>
|
||||
{{ bigLabel() }} {{ caseContext().bigNummer }}
|
||||
@if (canRevealBigNummer() && isMasked()) {
|
||||
<app-button variant="subtle" (click)="onReveal()">{{ revealLabel() }}</app-button>
|
||||
}
|
||||
{{ bigLabel() }}
|
||||
<app-masked-value
|
||||
[value]="caseContext().bigNummer"
|
||||
[canReveal]="canRevealBigNummer()"
|
||||
[revealLabel]="revealLabel()"
|
||||
(reveal)="onReveal()"
|
||||
/>
|
||||
</span>
|
||||
<span>{{ caseContext().beroep }}</span>
|
||||
</div>
|
||||
@@ -172,10 +177,6 @@ export class BehandelSchermComponent {
|
||||
locate = output<Diagnostic>();
|
||||
revealBigNummer = output<void>();
|
||||
|
||||
/** 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() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, 'Bsn'>;
|
||||
|
||||
// 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<string, Bsn> {
|
||||
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);
|
||||
}
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: `
|
||||
<span class="value">{{ value() }}</span>
|
||||
@if (canReveal() && masked()) {
|
||||
<app-button variant="subtle" (click)="reveal.emit()">{{ revealLabel() }}</app-button>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MaskedValueComponent {
|
||||
value = input.required<string>();
|
||||
canReveal = input(false);
|
||||
revealLabel = input($localize`:@@maskedValue.reveal:Tonen`);
|
||||
reveal = output<void>();
|
||||
|
||||
protected masked = computed(() => this.value().includes('*'));
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { MaskedValueComponent } from './masked-value.component';
|
||||
|
||||
const meta: Meta<MaskedValueComponent> = {
|
||||
title: 'Design System/Atoms/Masked Value',
|
||||
component: MaskedValueComponent,
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<MaskedValueComponent>;
|
||||
|
||||
/** 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 },
|
||||
};
|
||||
Reference in New Issue
Block a user