diff --git a/apps/behandelportal/src/app/auth/application/session.store.ts b/apps/behandelportal/src/app/auth/application/session.store.ts index 88ed651..d865c3a 100644 --- a/apps/behandelportal/src/app/auth/application/session.store.ts +++ b/apps/behandelportal/src/app/auth/application/session.store.ts @@ -1,51 +1,50 @@ import { Injectable, computed, effect, inject, signal } from '@angular/core'; -import { Result } from '@shared/kernel/fp'; -import { Session, parseStoredSession } from '../domain/session'; -import { DigidAdapter } from '../infrastructure/digid.adapter'; +import { Principal, parseStoredPrincipal } from '../domain/principal'; +import { MedewerkerAdapter } from '../infrastructure/medewerker.adapter'; const STORAGE_KEY = 'session-v1'; -/** Restore a persisted session (best-effort; corrupt entry → logged out). - The parse + shape validation (G1/G2) lives in `parseStoredSession` - (`../domain/session`) — pure, spec'd, and testable without stubbing - `localStorage`; this just supplies the raw value. */ -function restore(): Session | null { - return parseStoredSession(localStorage.getItem(STORAGE_KEY)); +/** Restore a persisted principal (best-effort; corrupt entry → logged out). + The shape validation (G2 — there is no BSN here, so no G1 to enforce) lives in + `parseStoredPrincipal` (`../domain/principal`) — pure, spec'd, and testable + without stubbing `localStorage`; this just supplies the raw value. */ +function restore(): Principal | null { + return parseStoredPrincipal(localStorage.getItem(STORAGE_KEY)); } /** - * Holds the current session for the whole app. Because it is providedIn:'root' - * there is exactly one instance — every component that injects it sees the same - * session signal, so logging in is instantly visible everywhere (the guard, the - * header, etc.). The session is mirrored to localStorage so a refresh, a deep-link, - * or the full-page navigation the language switch performs (nl at `/` ⇄ en at `/en/`, - * separate bundles) keeps you logged in. ponytail: localStorage, not sessionStorage — - * sessionStorage's per-tab clearing dropped the login on the cross-bundle language - * switch. Trade-off: the demo session now survives tab close; a real portal keeps auth - * in an httpOnly cookie/token, not web storage. + * Holds the current medewerker principal for the whole backoffice app. One + * `providedIn: 'root'` instance, so logging in is instantly visible everywhere + * (the guard, the header). Persisted to localStorage — a refresh or the + * cross-bundle language switch (nl at `/` ⇄ en at `/en/`) keeps you logged in — + * which is safe to do verbatim here: a medewerker principal carries no BSN or + * other national identifier, unlike the SSP's `SessionStore`, whose equivalent + * comment explains why *that* app strips a field before writing. A real + * deployment keeps auth in an httpOnly cookie/token, not web storage, regardless. */ @Injectable({ providedIn: 'root' }) export class SessionStore { - private digid = inject(DigidAdapter); - private _session = signal(restore()); + private medewerker = inject(MedewerkerAdapter); + private _session = signal(restore()); readonly session = this._session.asReadonly(); readonly isAuthenticated = computed(() => this._session() !== null); constructor() { effect(() => { - const s = this._session(); - // G1: persist only `naam` — never write the BSN (national ID) to storage. - if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: s.naam })); + const p = this._session(); + if (p) localStorage.setItem(STORAGE_KEY, JSON.stringify(p)); else localStorage.removeItem(STORAGE_KEY); }); } - /** Effectful command: authenticate, then store the session on success. */ - async login(bsn: string): Promise> { - const r = await this.digid.authenticate(bsn); - if (r.ok) this._session.set(r.value); - return r; + /** Effectful command: authenticate via the SSO stand-in, then store the + resulting principal. No credential to pass in, and nothing that can fail + today — see `MedewerkerAdapter`. */ + async login(): Promise { + const p = await this.medewerker.authenticate(); + this._session.set(p); + return p; } logout() { diff --git a/apps/behandelportal/src/app/auth/domain/principal.spec.ts b/apps/behandelportal/src/app/auth/domain/principal.spec.ts new file mode 100644 index 0000000..3cd76d8 --- /dev/null +++ b/apps/behandelportal/src/app/auth/domain/principal.spec.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { isAuthenticated, parseRollen, parseStoredPrincipal, Principal } from './principal'; + +const principal: Principal = { + kind: 'medewerker', + medewerkerId: 'medewerker-1', + naam: 'Test', + rollen: ['behandelaar'], +}; + +describe('isAuthenticated', () => { + it('narrows a present principal to Principal', () => { + expect(isAuthenticated(principal)).toBe(true); + }); + + it('reports no principal as not authenticated', () => { + expect(isAuthenticated(null)).toBe(false); + }); +}); + +describe('parseStoredPrincipal', () => { + it('returns null when nothing is stored', () => { + expect(parseStoredPrincipal(null)).toBeNull(); + }); + + it('returns null for a non-JSON string', () => { + expect(parseStoredPrincipal('not json')).toBeNull(); + }); + + it('returns null when the stored shape is wrong (no naam)', () => { + expect( + parseStoredPrincipal(JSON.stringify({ kind: 'medewerker', medewerkerId: 'medewerker-1' })), + ).toBeNull(); + }); + + it('returns null when kind is not medewerker', () => { + expect( + parseStoredPrincipal( + JSON.stringify({ + kind: 'zorgverlener', + medewerkerId: 'medewerker-1', + naam: 'Test', + rollen: [], + }), + ), + ).toBeNull(); + }); + + it('returns null when rollen holds an unrecognized token', () => { + expect( + parseStoredPrincipal( + JSON.stringify({ + kind: 'medewerker', + medewerkerId: 'medewerker-1', + naam: 'Test', + rollen: ['geen'], + }), + ), + ).toBeNull(); + }); + + it('restores a well-shaped stored principal as-is (no BSN to strip)', () => { + const restored = parseStoredPrincipal(JSON.stringify(principal)); + expect(restored).toEqual(principal); + }); +}); + +describe('parseRollen', () => { + it('parses a single recognized rol', () => { + expect(parseRollen('behandelaar')).toEqual(['behandelaar']); + }); + + it('is case-insensitive and trims whitespace', () => { + expect(parseRollen(' Behandelaar , behandelaar ')).toEqual(['behandelaar', 'behandelaar']); + }); + + it('drops unrecognized tokens (the deny-path toggle, e.g. ?rollen=geen)', () => { + expect(parseRollen('geen')).toEqual([]); + }); + + it('returns an empty list for an empty string', () => { + expect(parseRollen('')).toEqual([]); + }); +}); diff --git a/apps/behandelportal/src/app/auth/domain/principal.ts b/apps/behandelportal/src/app/auth/domain/principal.ts new file mode 100644 index 0000000..fd68bb2 --- /dev/null +++ b/apps/behandelportal/src/app/auth/domain/principal.ts @@ -0,0 +1,71 @@ +/** + * Who is logged in. Framework-free domain type. + * + * The `medewerker` variant of ADR-0002 §3's `Principal` union — the backoffice has + * exactly one actor kind (an employee, authenticated via SSO), so this app's own copy + * of the union only ever holds this one member. Unlike the SSP's `zorgverlener` + * variant, there is no BSN: a Behandelaar is not a citizen, and §3 names this + * unrepresentable-by-construction distinction as the whole point of the union. + * `rollen` is the FE-visible echo of the same dev stand-in `medewerker.interceptor.ts` + * already stamps onto every backend request — it does not itself grant anything; + * `AccessStore`/`GET /me` (server-resolved capabilities) is still the sole authority + * on what this principal may do (ADR-0001, ADR-0002 §3). + */ +export type Rol = 'behandelaar'; + +const ROLLEN: readonly Rol[] = ['behandelaar']; +export const isRol = (v: unknown): v is Rol => typeof v === 'string' && ROLLEN.includes(v as Rol); + +export interface Principal { + readonly kind: 'medewerker'; + readonly medewerkerId: string; + readonly naam: string; + readonly rollen: readonly Rol[]; +} + +export function isAuthenticated(p: Principal | null): p is Principal { + return p !== null; +} + +/** + * Turn the raw `X-Rollen` stand-in value (`medewerker.ts`'s `currentRollen()`) into + * typed `Rol[]`, mirroring the backend's own `StubIdentityProvider.ParseRollen`: + * comma-separated, case-insensitive, unrecognized tokens dropped — so + * `?rollen=geen` (the deny-path toggle) yields an empty list here too, rather than + * a fabricated recognized role. Pure so `MedewerkerAdapter` (infrastructure) can + * stay a thin wire-up instead of holding logic of its own. + */ +export function parseRollen(raw: string): Rol[] { + return raw + .split(',') + .map((t) => t.trim().toLowerCase()) + .filter(isRol); +} + +/** + * Parse a persisted principal out of a raw `localStorage` string (best-effort; + * anything that isn't a well-shaped record → logged out). G2: validate the shape + * before trusting it. Unlike the zorgverlener variant there is no G1 field to strip + * — a medewerker carries no national identifier — so a well-shaped record is + * restored as-is rather than reconstructed field-by-field. + */ +export function parseStoredPrincipal(raw: string | null): Principal | null { + try { + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + return parsed?.kind === 'medewerker' && + typeof parsed.medewerkerId === 'string' && + typeof parsed.naam === 'string' && + Array.isArray(parsed.rollen) && + parsed.rollen.every(isRol) + ? { + kind: 'medewerker', + medewerkerId: parsed.medewerkerId, + naam: parsed.naam, + rollen: parsed.rollen, + } + : null; + } catch { + return null; + } +} diff --git a/apps/behandelportal/src/app/auth/domain/session.spec.ts b/apps/behandelportal/src/app/auth/domain/session.spec.ts deleted file mode 100644 index af90034..0000000 --- a/apps/behandelportal/src/app/auth/domain/session.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { isAuthenticated, parseStoredSession, Session } from './session'; - -const session: Session = { bsn: '19012345601', naam: 'Test' }; - -describe('isAuthenticated', () => { - it('narrows a present session to Session', () => { - expect(isAuthenticated(session)).toBe(true); - }); - - it('reports no session as not authenticated', () => { - expect(isAuthenticated(null)).toBe(false); - }); -}); - -describe('parseStoredSession', () => { - it('returns null when nothing is stored', () => { - expect(parseStoredSession(null)).toBeNull(); - }); - - it('returns null for a non-JSON string', () => { - expect(parseStoredSession('not json')).toBeNull(); - }); - - it('returns null when the stored shape is wrong (no naam)', () => { - expect(parseStoredSession(JSON.stringify({ bsn: '19012345601' }))).toBeNull(); - }); - - it('G1: a stored bsn is never restored, even if present in the raw value', () => { - const restored = parseStoredSession(JSON.stringify({ bsn: '19012345601', naam: 'Test' })); - expect(restored).toEqual({ bsn: '', naam: 'Test' }); - }); -}); diff --git a/apps/behandelportal/src/app/auth/domain/session.ts b/apps/behandelportal/src/app/auth/domain/session.ts deleted file mode 100644 index abbbbf6..0000000 --- a/apps/behandelportal/src/app/auth/domain/session.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** Who is logged in. Framework-free domain type. */ -export interface Session { - readonly bsn: string; - readonly naam: string; -} - -export function isAuthenticated(s: Session | null): s is Session { - return s !== null; -} - -/** - * Parse a persisted session out of a raw `localStorage` string (best-effort; - * anything that isn't a well-shaped record → logged out). G2: validate the - * shape before trusting it. G1: even if a stored entry carries a `bsn`, the - * restored session's `bsn` is always `''` — the BSN is never persisted (see - * the `SessionStore` effect that writes it), so a legacy or tampered entry - * cannot resurrect one. - */ -export function parseStoredSession(raw: string | null): Session | null { - try { - if (!raw) return null; - const parsed = JSON.parse(raw) as Partial; - return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null; - } catch { - return null; - } -} diff --git a/apps/behandelportal/src/app/auth/infrastructure/digid.adapter.ts b/apps/behandelportal/src/app/auth/infrastructure/digid.adapter.ts deleted file mode 100644 index a4956d0..0000000 --- a/apps/behandelportal/src/app/auth/infrastructure/digid.adapter.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Injectable } from '@angular/core'; -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 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 r = parseBsn(bsn); - return r.ok ? ok({ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r; - } -} diff --git a/apps/behandelportal/src/app/auth/infrastructure/medewerker.adapter.ts b/apps/behandelportal/src/app/auth/infrastructure/medewerker.adapter.ts new file mode 100644 index 0000000..85cc7ee --- /dev/null +++ b/apps/behandelportal/src/app/auth/infrastructure/medewerker.adapter.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@angular/core'; +import { Principal, parseRollen } from '../domain/principal'; +import { MEDEWERKER_ID, currentRollen } from './medewerker'; + +/** + * Infrastructure: resolves the current medewerker identity into a `Principal` + * (ADR-C-004/RB-13). Stands in for a real employee-SSO redirect flow (ADR-0002 §3, + * "out of scope here") — there is no credential to enter and, unlike `DigidAdapter`'s + * BSN check, no format to reject, so `authenticate()` takes no input and returns the + * `Principal` directly rather than a `Result` with an error variant that can never + * actually occur. A real SSO callback (which *can* fail — session expired, access + * denied) swaps in behind this same method; that is the point where this return + * type would gain a `Result`, not before. + * + * Resolves the same `MEDEWERKER_ID` + `currentRollen()` the dev-only + * `medewerkerInterceptor` already stamps onto every backend request as + * `X-Medewerker`/`X-Rollen` — this only makes that identity visible on the + * frontend (the guard, the header, `SessionStore`'s persisted principal), it does + * not change what the backend resolves or authorizes. + */ +@Injectable({ providedIn: 'root' }) +export class MedewerkerAdapter { + // ponytail: fake employee SSO — a fixed medewerker, no credential exchange. + async authenticate(): Promise { + return { + kind: 'medewerker', + medewerkerId: MEDEWERKER_ID, + naam: 'H. (Hassan) Bakker', + rollen: parseRollen(currentRollen()), + }; + } +} diff --git a/apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts b/apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts index d11c5d3..6d9b5ce 100644 --- a/apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts +++ b/apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts @@ -1,50 +1,27 @@ import { Component, output } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; -import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { ButtonComponent } from '@shared/ui/button/button.component'; -/** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */ +/** + * Organism: employee-SSO-style mock login (ADR-C-004/RB-13). No real auth — and, + * unlike the SSP's DigiD form, no credential to enter at all: a Behandelaar has no + * BSN, and this app has no password of its own to check either way. There is + * nothing to compose beyond one button, which is itself evidence for the ADR — the + * two apps' login flows are meant to look this different. + */ @Component({ selector: 'app-login-form', - imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent], + imports: [ButtonComponent], template: ` -
-
-
- * verplichte velden -
-
- - - - - - - - - - Inloggen met DigiD -
+
+

+ U meldt zich aan via de SSO van uw organisatie — er is geen wachtwoord nodig. +

+ + Inloggen met SSO + +
`, }) export class LoginFormComponent { - bsn = ''; - password = ''; - submitted = output(); + submitted = output(); } diff --git a/apps/behandelportal/src/app/auth/ui/login.page.ts b/apps/behandelportal/src/app/auth/ui/login.page.ts index 59671f9..aca9da5 100644 --- a/apps/behandelportal/src/app/auth/ui/login.page.ts +++ b/apps/behandelportal/src/app/auth/ui/login.page.ts @@ -1,36 +1,35 @@ -import { Component, inject, signal } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { Router } from '@angular/router'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; -import { AlertComponent } from '@shared/ui/alert/alert.component'; import { LoginFormComponent } from '@auth/ui/login-form/login-form.component'; import { SessionStore } from '@auth/application/session.store'; +/** + * No error alert here — unlike the SSP's DigiD form, `SessionStore.login()` has + * nothing to fail on (see `MedewerkerAdapter`). A real SSO integration is where + * this page would grow one back. + */ @Component({ selector: 'app-login-page', - imports: [PageShellComponent, AlertComponent, LoginFormComponent], + imports: [PageShellComponent, LoginFormComponent], template: ` - @if (error()) { - {{ error() }} - } - + `, }) export class LoginPage { private store = inject(SessionStore); private router = inject(Router); - error = signal(''); - async login(bsn: string) { - const r = await this.store.login(bsn); - if (r.ok) this.router.navigate(['/dashboard']); - else this.error.set(r.error); + async login() { + await this.store.login(); + this.router.navigate(['/dashboard']); } } diff --git a/apps/behandelportal/src/locale/messages.en.xlf b/apps/behandelportal/src/locale/messages.en.xlf index 6977b09..e538c31 100644 --- a/apps/behandelportal/src/locale/messages.en.xlf +++ b/apps/behandelportal/src/locale/messages.en.xlf @@ -26,65 +26,33 @@ 27 - - * verplichte velden - * required fields + + U meldt zich aan via de SSO van uw organisatie — er is geen wachtwoord nodig. + You sign in through your organization's SSO — no password is needed. src/app/auth/ui/login-form/login-form.component.ts - 15,18 - - - src/app/registratie/ui/change-request-form/change-request-form.component.ts - 44,46 - - - src/app/shared/layout/wizard-shell/wizard-shell.component.ts - 90,92 - - - - BSN - BSN - - src/app/auth/ui/login-form/login-form.component.ts - 22,23 - - - - 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 - - - - Wachtwoord - Password - - src/app/auth/ui/login-form/login-form.component.ts - 36,37 + 17,19 - Inloggen met DigiD - Log in with DigiD + Inloggen met SSO + Log in with SSO src/app/auth/ui/login-form/login-form.component.ts - 41,43 + 20,21 - Inloggen - Log in + Inloggen bij het behandelportal + Log in to the treatment portal src/app/auth/ui/login.page.ts 14,16 - Log in op uw persoonlijke BIG-register omgeving. - Log in to your personal BIG register environment. + Voor medewerkers die aanvragen beoordelen. + For staff who assess applications. src/app/auth/ui/login.page.ts 17,19 diff --git a/apps/ssp/src/app/auth/application/session.store.ts b/apps/ssp/src/app/auth/application/session.store.ts index 88ed651..dd891b1 100644 --- a/apps/ssp/src/app/auth/application/session.store.ts +++ b/apps/ssp/src/app/auth/application/session.store.ts @@ -1,48 +1,50 @@ import { Injectable, computed, effect, inject, signal } from '@angular/core'; import { Result } from '@shared/kernel/fp'; -import { Session, parseStoredSession } from '../domain/session'; +import { Principal, parseStoredPrincipal } from '../domain/principal'; import { DigidAdapter } from '../infrastructure/digid.adapter'; const STORAGE_KEY = 'session-v1'; -/** Restore a persisted session (best-effort; corrupt entry → logged out). - The parse + shape validation (G1/G2) lives in `parseStoredSession` - (`../domain/session`) — pure, spec'd, and testable without stubbing +/** Restore a persisted principal (best-effort; corrupt entry → logged out). + The parse + shape validation (G1/G2) lives in `parseStoredPrincipal` + (`../domain/principal`) — pure, spec'd, and testable without stubbing `localStorage`; this just supplies the raw value. */ -function restore(): Session | null { - return parseStoredSession(localStorage.getItem(STORAGE_KEY)); +function restore(): Principal | null { + return parseStoredPrincipal(localStorage.getItem(STORAGE_KEY)); } /** - * Holds the current session for the whole app. Because it is providedIn:'root' - * there is exactly one instance — every component that injects it sees the same - * session signal, so logging in is instantly visible everywhere (the guard, the - * header, etc.). The session is mirrored to localStorage so a refresh, a deep-link, - * or the full-page navigation the language switch performs (nl at `/` ⇄ en at `/en/`, - * separate bundles) keeps you logged in. ponytail: localStorage, not sessionStorage — - * sessionStorage's per-tab clearing dropped the login on the cross-bundle language - * switch. Trade-off: the demo session now survives tab close; a real portal keeps auth - * in an httpOnly cookie/token, not web storage. + * Holds the current zorgverlener principal for the whole SSP. One + * `providedIn: 'root'` instance, so logging in is instantly visible everywhere + * (the guard, the header). Persisted to localStorage — a refresh or the + * cross-bundle language switch (nl at `/` ⇄ en at `/en/`) keeps you logged in — + * but never the BSN itself (G1 in the `effect` below): this principal carries a + * citizen's national identifier, which the behandelportal's equivalent store does + * not have to guard against, because its `medewerker` principal has no BSN. + * ponytail: localStorage, not sessionStorage — sessionStorage's per-tab clearing + * dropped the login on the cross-bundle language switch. Trade-off: the demo + * session now survives tab close; a real portal keeps auth in an httpOnly + * cookie/token, not web storage. */ @Injectable({ providedIn: 'root' }) export class SessionStore { private digid = inject(DigidAdapter); - private _session = signal(restore()); + private _session = signal(restore()); readonly session = this._session.asReadonly(); readonly isAuthenticated = computed(() => this._session() !== null); constructor() { effect(() => { - const s = this._session(); + const p = this._session(); // G1: persist only `naam` — never write the BSN (national ID) to storage. - if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: s.naam })); + if (p) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: p.naam })); else localStorage.removeItem(STORAGE_KEY); }); } - /** Effectful command: authenticate, then store the session on success. */ - async login(bsn: string): Promise> { + /** Effectful command: authenticate, then store the principal on success. */ + async login(bsn: string): Promise> { const r = await this.digid.authenticate(bsn); if (r.ok) this._session.set(r.value); return r; diff --git a/apps/ssp/src/app/auth/domain/principal.spec.ts b/apps/ssp/src/app/auth/domain/principal.spec.ts new file mode 100644 index 0000000..386111a --- /dev/null +++ b/apps/ssp/src/app/auth/domain/principal.spec.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { isAuthenticated, parseStoredPrincipal, Principal } from './principal'; + +const principal: Principal = { kind: 'zorgverlener', bsn: '19012345601', naam: 'Test' }; + +describe('isAuthenticated', () => { + it('narrows a present principal to Principal', () => { + expect(isAuthenticated(principal)).toBe(true); + }); + + it('reports no principal as not authenticated', () => { + expect(isAuthenticated(null)).toBe(false); + }); +}); + +describe('parseStoredPrincipal', () => { + it('returns null when nothing is stored', () => { + expect(parseStoredPrincipal(null)).toBeNull(); + }); + + it('returns null for a non-JSON string', () => { + expect(parseStoredPrincipal('not json')).toBeNull(); + }); + + it('returns null when the stored shape is wrong (no naam)', () => { + expect(parseStoredPrincipal(JSON.stringify({ bsn: '19012345601' }))).toBeNull(); + }); + + it('G1: a stored bsn is never restored, even if present in the raw value', () => { + const restored = parseStoredPrincipal(JSON.stringify({ bsn: '19012345601', naam: 'Test' })); + expect(restored).toEqual({ kind: 'zorgverlener', bsn: '', naam: 'Test' }); + }); +}); diff --git a/apps/ssp/src/app/auth/domain/principal.ts b/apps/ssp/src/app/auth/domain/principal.ts new file mode 100644 index 0000000..3672e07 --- /dev/null +++ b/apps/ssp/src/app/auth/domain/principal.ts @@ -0,0 +1,39 @@ +/** + * Who is logged in. Framework-free domain type. + * + * The `zorgverlener` variant of ADR-0002 §3's `Principal` union — the SSP has exactly + * one actor kind (a citizen, authenticated via DigiD/BSN), so this app's own copy of + * the union only ever holds this one member. `kind` is still a discriminant, not + * decoration: it is what makes `apps/behandelportal`'s `medewerker` variant a + * genuinely different type rather than a same-shaped coincidence, and what a future + * third actor (§4 — admin/auditor/institution-rep) would add a member to. + */ +export interface Principal { + readonly kind: 'zorgverlener'; + readonly bsn: string; + readonly naam: string; +} + +export function isAuthenticated(p: Principal | null): p is Principal { + return p !== null; +} + +/** + * Parse a persisted principal out of a raw `localStorage` string (best-effort; + * anything that isn't a well-shaped record → logged out). G2: validate the + * shape before trusting it. G1: even if a stored entry carries a `bsn`, the + * restored principal's `bsn` is always `''` — the BSN is never persisted (see + * the `SessionStore` effect that writes it), so a legacy or tampered entry + * cannot resurrect one. + */ +export function parseStoredPrincipal(raw: string | null): Principal | null { + try { + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + return typeof parsed?.naam === 'string' + ? { kind: 'zorgverlener', bsn: '', naam: parsed.naam } + : null; + } catch { + return null; + } +} diff --git a/apps/ssp/src/app/auth/domain/session.spec.ts b/apps/ssp/src/app/auth/domain/session.spec.ts deleted file mode 100644 index af90034..0000000 --- a/apps/ssp/src/app/auth/domain/session.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { isAuthenticated, parseStoredSession, Session } from './session'; - -const session: Session = { bsn: '19012345601', naam: 'Test' }; - -describe('isAuthenticated', () => { - it('narrows a present session to Session', () => { - expect(isAuthenticated(session)).toBe(true); - }); - - it('reports no session as not authenticated', () => { - expect(isAuthenticated(null)).toBe(false); - }); -}); - -describe('parseStoredSession', () => { - it('returns null when nothing is stored', () => { - expect(parseStoredSession(null)).toBeNull(); - }); - - it('returns null for a non-JSON string', () => { - expect(parseStoredSession('not json')).toBeNull(); - }); - - it('returns null when the stored shape is wrong (no naam)', () => { - expect(parseStoredSession(JSON.stringify({ bsn: '19012345601' }))).toBeNull(); - }); - - it('G1: a stored bsn is never restored, even if present in the raw value', () => { - const restored = parseStoredSession(JSON.stringify({ bsn: '19012345601', naam: 'Test' })); - expect(restored).toEqual({ bsn: '', naam: 'Test' }); - }); -}); diff --git a/apps/ssp/src/app/auth/domain/session.ts b/apps/ssp/src/app/auth/domain/session.ts deleted file mode 100644 index abbbbf6..0000000 --- a/apps/ssp/src/app/auth/domain/session.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** Who is logged in. Framework-free domain type. */ -export interface Session { - readonly bsn: string; - readonly naam: string; -} - -export function isAuthenticated(s: Session | null): s is Session { - return s !== null; -} - -/** - * Parse a persisted session out of a raw `localStorage` string (best-effort; - * anything that isn't a well-shaped record → logged out). G2: validate the - * shape before trusting it. G1: even if a stored entry carries a `bsn`, the - * restored session's `bsn` is always `''` — the BSN is never persisted (see - * the `SessionStore` effect that writes it), so a legacy or tampered entry - * cannot resurrect one. - */ -export function parseStoredSession(raw: string | null): Session | null { - try { - if (!raw) return null; - const parsed = JSON.parse(raw) as Partial; - return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null; - } catch { - return null; - } -} diff --git a/apps/ssp/src/app/auth/infrastructure/digid.adapter.ts b/apps/ssp/src/app/auth/infrastructure/digid.adapter.ts index a4956d0..652622c 100644 --- a/apps/ssp/src/app/auth/infrastructure/digid.adapter.ts +++ b/apps/ssp/src/app/auth/infrastructure/digid.adapter.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { Result, ok } from '@shared/kernel/fp'; import { parseBsn } from '@shared/kernel/bsn'; -import { Session } from '../domain/session'; +import { Principal } from '../domain/principal'; /** Infrastructure: talks to the (mock) DigiD identity provider. */ @Injectable({ providedIn: 'root' }) @@ -9,8 +9,8 @@ export class DigidAdapter { // 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> { + async authenticate(bsn: string): Promise> { const r = parseBsn(bsn); - return r.ok ? ok({ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r; + return r.ok ? ok({ kind: 'zorgverlener', bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r; } } diff --git a/apps/ssp/src/app/shell/debug-state/debug-state.component.ts b/apps/ssp/src/app/shell/debug-state/debug-state.component.ts index 8a6f190..b3faf88 100644 --- a/apps/ssp/src/app/shell/debug-state/debug-state.component.ts +++ b/apps/ssp/src/app/shell/debug-state/debug-state.component.ts @@ -1,7 +1,7 @@ import { Component, Injector, computed, inject, isDevMode, signal } from '@angular/core'; import { JsonPipe } from '@angular/common'; import { SessionStore } from '@auth/application/session.store'; -import { Session } from '@auth/domain/session'; +import { Principal } from '@auth/domain/principal'; import { BigProfileStore } from '@registratie/application/big-profile.store'; import { map } from '@shared/application/remote-data'; import { Role } from '@shared/domain/role'; @@ -172,6 +172,6 @@ export class DebugStateComponent { } } -function maskSession(s: Session | null): Session | null { - return s ? { ...s, bsn: maskBsn(s.bsn) } : null; +function maskSession(p: Principal | null): Principal | null { + return p ? { ...p, bsn: maskBsn(p.bsn) } : null; } diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-13.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-13.md new file mode 100644 index 0000000..f2c2185 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-13.md @@ -0,0 +1,186 @@ +# RB-13 — land `Session → Principal`; `MedewerkerAdapter`; the backoffice login stops being a DigiD/BSN form + +Status: **implemented** · 2026-08-27 · Source findings: `06-adr-conformance.md` ADR-C-004 · `00-baseline.md` BL-002 · `docs/reference/architecture/0002-user-groups-and-bounded-contexts.md` §3, "Known debt" · `99-backlog.md` RB-13 + +## What was wrong + +ADR-0002 §3 ("Separate identity from authorization") specifies a discriminated +`Principal` union — `{ kind: 'zorgverlener'; bsn; naam } | { kind: 'medewerker'; +medewerkerId; naam; rollen }` — as "the one concrete FE change when actor #2 lands." +Actor #2 (`apps/behandelportal`) landed in WP-61/67; the union did not follow. + +Verified before this ticket: + +- `grep -rn "Principal" apps libs` returned exactly one hit — a comment in + `libs/shared/src/infrastructure/role.ts:8`. No such type existed. +- `apps/ssp/src/app/auth/domain/session.ts` and + `apps/behandelportal/src/app/auth/domain/session.ts` were byte-identical: + `interface Session { readonly bsn: string; readonly naam: string }` — a Behandelaar + carrying a `bsn`, which §3 names as precisely the state the union exists to make + unrepresentable. +- `apps/behandelportal/src/app/auth/ui/login.page.ts` rendered `intro="Log in op uw +persoonlijke BIG-register omgeving."` and called `SessionStore.login(bsn)` → + `DigidAdapter.authenticate(bsn)`, resolving `{ bsn: r.value, naam: 'Dr. A. (Anna) de +Vries' }` — a backoffice employee logging into the backoffice as a citizen, by DigiD, + under a citizen's name. +- `apps/behandelportal/src/app/auth/infrastructure/medewerker.interceptor.ts` already + stamps every backend request with `X-Medewerker`/`X-Rollen`, independently of + `SessionStore` — the divergence ADR-0002 predicted took this orthogonal side door + instead of the `Principal` union, which is why the two `auth` contexts still measured + as identical. +- `tools/baseline-scan.mjs --dup`, measured immediately before this ticket (after + ADR-C-006 shared the route guards): `ssp/auth` 168/168 dup lines (100.0%), + `bhp/auth` 168/200 (84.0%) — down from the original 211/211, but the WP-67 amendment's + "auth stays duplicated because it's expected to diverge" claim had never actually been + tested, only asserted. + +RB-09 (a prerequisite, landed the day before) made the backend's `IIdentityProvider` +able to say "no identity" and fail closed; this ticket is its stated FE half — without +it, a production behandelportal falls through to the seeded zorgverlener by default, +open on every citizen-scoped endpoint and holding `CanRevealBigNummer`. This ticket +does not touch that backend behaviour — it makes the FE identity model honest about +who is actually authenticating. + +## What changed + +| File | Change | +| -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apps/ssp/src/app/auth/domain/session.ts` → `principal.ts` | `Session` → `Principal`, `{ kind: 'zorgverlener'; bsn; naam }`; `parseStoredSession` → `parseStoredPrincipal` (G1/G2 unchanged) | +| `apps/ssp/src/app/auth/domain/session.spec.ts` → `principal.spec.ts` | renamed, updated to the `Principal`/`kind` shape | +| `apps/ssp/src/app/auth/application/session.store.ts` | `Session` → `Principal`; header doc rewritten to state _why_ G1 applies here and not in behandelportal (cross-reference, not shared prose) | +| `apps/ssp/src/app/auth/infrastructure/digid.adapter.ts` | resolves `{ kind: 'zorgverlener', bsn, naam }` | +| `apps/ssp/src/app/shell/debug-state/debug-state.component.ts` | `Session` → `Principal` (the one other consumer of the domain type) | +| `apps/behandelportal/src/app/auth/domain/session.ts` → `principal.ts` | new `medewerker` variant: `{ kind: 'medewerker'; medewerkerId; naam; rollen: readonly Rol[] }`; `parseStoredPrincipal` validates the full shape (no BSN to strip — G2 only); new `parseRollen(raw): Rol[]`, mirroring the backend's `StubIdentityProvider.ParseRollen` (comma-separated, case-insensitive, unrecognized tokens dropped) | +| `apps/behandelportal/src/app/auth/domain/session.spec.ts` → `principal.spec.ts` | rewritten: `isAuthenticated`, `parseStoredPrincipal` (5 cases including "kind is not medewerker" and "unrecognized rol"), `parseRollen` (4 cases) | +| `apps/behandelportal/src/app/auth/infrastructure/digid.adapter.ts` → `medewerker.adapter.ts` | **new `MedewerkerAdapter`** — resolves `MEDEWERKER_ID` + `currentRollen()` (`medewerker.ts`, unchanged) into a `Principal`; no input, returns the `Principal` directly (no `Result` — there is nothing for this stand-in to fail on) | +| `apps/behandelportal/src/app/auth/application/session.store.ts` | `MedewerkerAdapter` replaces `DigidAdapter`; `login()` takes no argument; the whole principal round-trips through `localStorage` (no G1 field to strip); header doc rewritten, cross-referencing the SSP's instead of repeating it | +| `apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts` | rewritten: no BSN/wachtwoord fields — one explainer line + one "Inloggen met SSO" button, `submitted = output()` | +| `apps/behandelportal/src/app/auth/ui/login.page.ts` | new heading/intro copy ("Inloggen bij het behandelportal" / "Voor medewerkers die aanvragen beoordelen."); `login()` takes no argument; the error-alert branch is gone (nothing can fail) | +| `apps/behandelportal/src/locale/messages.en.xlf` | new id `login.ssoExplainer`; `login.submit`/`login.heading`/`login.intro` updated to the new source text + English target; `login.bsnLabel`/`bsnDescription`/`wachtwoordLabel`/`form.verplichteVelden` removed (no longer reachable from this app — confirmed by grep and by a trial `extract-i18n:behandelportal` run) | +| `libs/shared/src/infrastructure/subject.ts`, `subject.interceptor.ts` | doc comments: `` `Session.bsn` `` → `` `Principal.bsn` `` (the type these comments cite renamed; the design they describe — `libs/shared` can't reach an app-local `auth` context, so `?subject=` exists instead — is unchanged) | +| `docs/reference/architecture/0002-user-groups-and-bounded-contexts.md` | new "Amendment (RB-13, 2026-08-27)" replacing the "Known debt" section it closes out; records what landed and the re-measured duplication figure | +| `libs/shared/docs/behaviour-spec.mdx` | regenerated (`npm run gen:behaviour-spec`) — reflects the renamed spec titles and the new `parseRollen`/medewerker `parseStoredPrincipal` cases | + +## Judgement calls + +- **Each app's `Principal` holds only the one variant it has an actor for**, not the + full two-member union ADR-0002 §3 writes as a single illustrative type. The ADR's own + proposed resolution under ADR-C-004 says this explicitly ("In `apps/behandelportal`: + replace `Session` with the `medewerker` variant … In `apps/ssp`: the `zorgverlener` + variant"), and it matches how the codebase already splits `auth` per app. `kind` stays + on both single-member types anyway — it is what makes the two types genuinely + different rather than a same-shaped coincidence, and it is where a third actor (§4 — + admin/auditor/institution-rep) would add a member. +- **`MedewerkerAdapter.authenticate()` returns `Promise`, not + `Promise>`.** The first draft mirrored `DigidAdapter`'s + `Result`-returning shape for symmetry, but that `Result`'s error variant could never + actually be produced — there is no credential to check, so wrapping the return in a + type that claims to have a failure mode was itself a small instance of the thing + CLAUDE.md §3 warns against (representing a state that can't happen). Reverted to a + direct `Promise` and dropped the now-dead error-handling branch from + `login.page.ts` (`error` signal, the ``, the `AlertComponent` + import) — a real SSO integration is where that branch would come back, not before. + This was also the change that did the most to bring the duplication figure down (see + below): `login.page.ts`'s 7-window overlap with the SSP's disappeared once the two + pages' control flow, not just their copy, actually differed. +- **`rollen` is typed `readonly Rol[]` with `Rol = 'behandelaar'`, and `parseRollen` + lives in `domain/`, not the adapter.** The raw `currentRollen()` stand-in returns an + unvalidated string (`medewerker.ts`, untouched by this ticket); turning it into typed + `Rol[]` is pure string logic with no Angular dependency, so it belongs in + `domain/principal.ts` per CLAUDE.md §1's layer table — the adapter (`infrastructure/`) + stays a thin wire-up that only reaches for `MEDEWERKER_ID`/`currentRollen()` and + hands them to a pure function. `parseRollen` deliberately mirrors the backend's own + `StubIdentityProvider.ParseRollen` (comma-separated, unrecognized tokens dropped, so + `?rollen=geen` yields `[]`) — this is not the FE recomputing a business rule + (ADR-0001's boundary is about _authorization decisions_, which still come only from + `GET /me`/`AccessStore`); it is the FE's own dev-only identity stand-in echoing the + same header value it is about to send, for display, the same way `DigidAdapter` + already fabricates its own fake identity. +- **`SessionStore` (bhp) persists the whole `Principal` to `localStorage`, not a + stripped-down `{ naam }` copy.** The SSP's G1 guarantee ("never persist the BSN") + doesn't apply here — a `medewerker` principal has no national identifier — so there is + nothing to strip. `parseStoredPrincipal` validates the full shape (G2 only) and + restores it as-is. This was a deliberate choice against an alternative: reconstructing + `medewerkerId`/`rollen` from the live `MEDEWERKER_ID`/`currentRollen()` on every + restore, which would have made `domain/principal.ts` depend on + `infrastructure/medewerker.ts` — backwards per CLAUDE.md §1's inward-only dependency + rule, and it would have made `parseStoredPrincipal` impure. Consequence: changing + `?rollen=` mid-session does not retroactively change an already-restored `Principal` + until the next `login()`/`logout()` — the same way changing the DigiD demo BSN + requires a fresh login in the SSP. The backend's own authorization is unaffected + either way, since `medewerkerInterceptor` reads `currentRollen()` fresh on every HTTP + request regardless of what `SessionStore` holds. +- **Session/store class names (`SessionStore`, `SESSION_PORT`, `SessionPort`) were left + unchanged.** ADR-0002 §3's own Consequences section names `SessionStore` — alongside + `auth.guard.ts` — as one of the _seams that localise_ the `Session → Principal` change, + not as something the change renames. `libs/shared/src/application/session.port.ts`'s + `SessionPort` (ADR-C-006) is unaffected: it only ever exposed `{ naam }` and + `isAuthenticated`, neither of which is `kind`-dependent. +- **`libs/shared/src/infrastructure/subject.ts`/`subject.interceptor.ts` doc comments + updated, code untouched.** Both cite `` `Session.bsn` `` by name to explain why + `?subject=` exists instead of reading the store directly; renaming the type these + comments describe without updating the comment would have left them citing a type + that no longer exists. +- **`auth.guard.ts`'s verbatim re-export in both apps was left alone.** ADR-C-006 is + explicit that a route guard is actor-agnostic and out of ADR-0002 §3's scope — it + reads only `SESSION_PORT`/`AccessStore`, never `Principal`, so there was nothing for + this ticket to change there. +- **No backend change.** RB-09 already made `IIdentityProvider` nullable and + Production-fail-fast; this ticket is purely the frontend counterpart it named. The + residual RB-09 flagged (`GET /uploads/{documentId}/content`'s plain-navigation + callers carrying no identity header once a real, non-stub `IIdentityProvider` exists) + is unaffected by anything here — it is about a _future_ real provider replacing the + Development-only stub, which this ticket does not touch. + +## Duplication, measured (`tools/baseline-scan.mjs --dup`) + +| When | `ssp/auth` dup lines | `bhp/auth` dup lines | +| ----------------------------------- | -------------------: | -------------------: | +| Before ADR-C-006 (baseline, BL-002) | 211/211 (100%) | — | +| After ADR-C-006, before this ticket | 168/168 (100.0%) | 168/200 (84.0%) | +| **After this ticket** | **32/179 (17.9%)** | **32/259 (12.4%)** | + +Expected by the backlog: "<40 after this." Measured: **32 lines each side** — under +target. The full clone-pair listing (the script's own output truncates to the top 15 +pairs repo-wide; re-run with the pair filter widened to confirm nothing auth-related was +hiding below that cut) resolves to exactly four remaining pairs: + +- `principal.spec.ts` (6 windows) — both files test the same G2 "validate before + trusting a stored shape" concept with a parallel `describe`/`it` structure (including + the shared `import { describe, it, expect } from 'vitest';` line); the assertions + themselves differ (BSN-stripping vs. kind/rollen validation). +- `login-form.stories.ts` (3 windows) — the generic Storybook `Meta`/`StoryObj`/`Default` + scaffold, unavoidable for any two co-located `.stories.ts` files regardless of subject. +- `auth.guard.ts` (2 windows) — the intentional verbatim re-export (ADR-C-006); this is + meant to stay identical. +- `session.store.ts` (1 window) — down from 33 windows before this ticket to one small + shared fragment (the `@Injectable`/signal/`asReadonly`/`computed` wiring any root + singleton store in this codebase shares). + +None of what remains is re-converged identity or login-flow logic — the domain type, +the adapter, and the login UI all now differ in kind, not just in copy. §3's prediction +("the two groups authenticate differently") has been tested for the first time by this +ticket, not just asserted, and it held. + +## Verification + +Confirmed each non-trivial change is red without its fix (edited in place, verified red, +edited back — never `git checkout`): + +- **ssp `parseStoredPrincipal` (G1):** changed `bsn: ''` to `bsn: parsed.bsn ?? ''` → + `G1: a stored bsn is never restored…` failed with `expected { bsn: '19012345601', …} +to deeply equal { bsn: '', … }`. Reverted; all other tests unaffected. +- **bhp `parseStoredPrincipal` (kind guard):** dropped the `parsed?.kind === 'medewerker'` + clause → `returns null when kind is not medewerker` failed, returning the parsed + zorgverlener-shaped object instead of `null`. Reverted. +- **bhp `parseRollen`:** dropped `.filter(isRol)` → `drops unrecognized tokens` and + `returns an empty list for an empty string` both failed (`['geen']`/`['']` returned + instead of `[]`). Reverted. + +`npm test` (both apps + both libraries): all green, 258 (ssp) + 37 (behandelportal) + +133 (shared) + 23 (beheer) tests passing, including the new/renamed auth specs. +`npm run lint`: clean. `npm run dep:check`: 0 violations, both apps. `ng build ssp +--localize` and `ng build behandelportal --localize`: both succeed (the new +`login.ssoExplainer` id and the updated `login.submit`/`login.heading`/`login.intro` +sources all resolve to an English ``). `npm run ci`: green (see the commit this +doc ships with). diff --git a/docs/reference/architecture/0002-user-groups-and-bounded-contexts.md b/docs/reference/architecture/0002-user-groups-and-bounded-contexts.md index 67774bc..f4c12e6 100644 --- a/docs/reference/architecture/0002-user-groups-and-bounded-contexts.md +++ b/docs/reference/architecture/0002-user-groups-and-bounded-contexts.md @@ -1,6 +1,6 @@ # ADR 0002 — User groups as actors, not bounded contexts -Status: Accepted · Date: 2026-07-01 · Amended 2026-08-01 (WP-67) +Status: Accepted · Date: 2026-07-01 · Amended 2026-08-01 (WP-67), 2026-08-27 (RB-13) ## Problem @@ -167,28 +167,34 @@ status lifecycle + authorization endpoints/DTOs — **shipped** (WP-61…WP-67): `AanvraagStatusTag` (`Domain/Applications/AanvraagStatus.cs`), `GET /me` (`Program.cs:578`), `Domain/Authorization/Authz.cs`. -## Known debt: `Session → Principal` was never built +A third bullet stood here too — `Session → Principal` — from 2026-08-26 until it was paid +off by RB-13 the next day. See the amendment below for the historical record and what +landed. -§3's `Principal` union is the one decision here that has **not** been executed, and it is now -debt rather than a deferral. Actor #2 arrived — `apps/behandelportal` shipped — and the union -did not follow. `grep -rn "Principal" apps libs` returns a single hit: a comment in -`libs/shared/src/infrastructure/role.ts`. There is no such type. +## Amendment (RB-13, 2026-08-27): `Session → Principal` landed -What that omission actually costs, measured 2026-08-26: +§3's `Principal` union was accepted on 2026-07-01 and not executed until now — see the +"Known debt" record this replaces, added 2026-08-26 by the refactor-backlog audit +(`ADR-C-004`) that found it. `apps/ssp/src/app/auth/domain/principal.ts` now exports the +`zorgverlener` variant (`{ kind: 'zorgverlener'; bsn; naam }`); +`apps/behandelportal/src/app/auth/domain/principal.ts` exports the `medewerker` variant +(`{ kind: 'medewerker'; medewerkerId; naam; rollen }`) — each app holds only the one +member of the union it actually has an actor for, per this ADR's own proposed resolution. +`apps/behandelportal`'s `DigidAdapter` is gone; a `MedewerkerAdapter` resolves the +dev-stand-in medewerker identity (`medewerker.ts`'s `MEDEWERKER_ID`/`currentRollen()` — +unchanged, still the mechanism `medewerkerInterceptor` uses for the backend headers) into +a `Principal` instead, and `login.page.ts` is an SSO-stand-in entry (one button, no BSN +field) rather than the citizen DigiD form it used to share with the SSP verbatim. -- `apps/ssp/src/app/auth` and `apps/behandelportal/src/app/auth` are byte-identical — - `diff -rq` reports **zero** content differences across 9 of 11 files, the only delta being - two extra files in behandelportal. -- `behandelportal`'s Behandelaar still carries a `bsn` and logs in through `DigidAdapter`. - A backoffice user authenticates as a citizen, which is precisely what §3 was written to prevent. -- The divergence that _did_ occur took an orthogonal side door — `medewerker.interceptor.ts`, - a dev-only `X-Medewerker` header stamp that never touches `Session`. - -The WP-67 amendment above justifies keeping `auth` duplicated on the grounds that it is -"expected to diverge". That reasoning still holds — but it has never been **tested**, because -the change that would test it is this one. Read the two identical copies as evidence that -§3 is unexecuted, not as evidence that §3 was wrong. - -ponytail: this ADR draws the boundaries so nothing has to be undone later. The original -"YAGNI until the backoffice work starts" call was right when written and has now expired — -the backoffice started. `Principal` is owed. +The two `auth` contexts, measured 2026-08-27 after the change +(`tools/baseline-scan.mjs --dup`): **32 duplicated lines each** (from 168 at the +2026-08-26 measurement above; from 211 before ADR-C-006 shared the route guards). What +remains is not re-converged identity/login-flow code — it is `auth.guard.ts`'s intentional +verbatim re-export (ADR-C-006: a route guard is actor-agnostic, not in this ADR's scope) +plus ordinary test/story-file boilerplate (`describe`/`it` shape, a `Meta`/`StoryObj` +scaffold) that any two spec or story files share regardless of subject. The prediction in +§3 — that Zorgverlener and Medewerker, modelled as distinct `Principal` variants, would +turn out to authenticate differently enough that sharing `auth` would have been the wrong +call — has now actually been tested, not just asserted, and held: the two contexts diverge +in domain type, adapter, and login UI as soon as the union exists to make that +divergence possible. diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index 37de06e..6f265f5 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -20,7 +20,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page -**is** the suite, reshaped for a business reader. 440 frontend behaviours across +**is** the suite, reshaped for a business reader. 446 frontend behaviours across 9 contexts; 231 backend behaviours across 39 test classes. @@ -30,17 +30,26 @@ classes. #### isAuthenticated -- narrows a present session to Session -- reports no session as not authenticated -- narrows a present session to Session -- reports no session as not authenticated +- narrows a present principal to Principal +- reports no principal as not authenticated +- narrows a present principal to Principal +- reports no principal as not authenticated -#### parseStoredSession +#### parseRollen + +- parses a single recognized rol +- is case-insensitive and trims whitespace +- drops unrecognized tokens (the deny-path toggle, e.g. ?rollen=geen) +- returns an empty list for an empty string + +#### parseStoredPrincipal - returns null when nothing is stored - returns null for a non-JSON string - returns null when the stored shape is wrong (no naam) -- G1: a stored bsn is never restored, even if present in the raw value +- returns null when kind is not medewerker +- returns null when rollen holds an unrecognized token +- restores a well-shaped stored principal as-is (no BSN to strip) - returns null when nothing is stored - returns null for a non-JSON string - returns null when the stored shape is wrong (no naam) diff --git a/libs/shared/src/infrastructure/subject.interceptor.ts b/libs/shared/src/infrastructure/subject.interceptor.ts index 9890495..a25e002 100644 --- a/libs/shared/src/infrastructure/subject.interceptor.ts +++ b/libs/shared/src/infrastructure/subject.interceptor.ts @@ -12,7 +12,7 @@ import { currentSubject } from './subject'; * middleware resolves a `CallerIdentity` for every request, not just some endpoints. * * **BSN source — a deliberate compromise, read before changing:** the "obvious" - * source would be the authenticated `Session.bsn` held by each app's own + * source would be the authenticated `Principal.bsn` held by each app's own * `SessionStore`, but `libs/shared` may not depend on an app-local `auth` context * (the import-direction rule), and the one sanctioned cross-context seam — * `SessionPort` (`@shared/application/session.port`) — deliberately exposes only diff --git a/libs/shared/src/infrastructure/subject.ts b/libs/shared/src/infrastructure/subject.ts index ec64d7f..0d261aa 100644 --- a/libs/shared/src/infrastructure/subject.ts +++ b/libs/shared/src/infrastructure/subject.ts @@ -3,7 +3,7 @@ import { isDevMode } from '@angular/core'; /** * Dev-only role stand-in's sibling (the reading MECHANISM for `X-Subject`; see * `role.ts`'s own doc comment for the twin `X-Role` mechanism this mirrors). This - * POC has no real DigiD identity — `Session.bsn` lives only in each app's own + * POC has no real DigiD identity — `Principal.bsn` lives only in each app's own * in-memory `SessionStore` and is deliberately never persisted (see that store's G1 * comment) — so `subject.interceptor.ts` can't reach it without a layering * violation (`libs/shared` may not depend on an app-local `auth` context). Instead a