ADR-0002 SS3 models Zorgverlener/Medewerker as different Principal variants with different login flows. Actor #2 (apps/behandelportal) landed in WP-61/67 and the union never followed: grep -rn "Principal" returned one hit, a comment. Both apps' auth/domain/session.ts stayed byte-identical (`{ bsn, naam }`), so the backoffice's Behandelaar carried a BSN and logged into the backoffice as a citizen, by DigiD, under a fabricated citizen's name (login.page.ts). The divergence ADR-0002 predicted took an orthogonal side door instead (medewerker.interceptor.ts's X-Medewerker/X-Rollen stamp, which never touches SessionStore) -- which is why ssp/auth and bhp/auth still measured as 100%/84% duplicated after ADR-C-006 shared the route guards. RB-09 (landed the day before) made the backend's IIdentityProvider able to say "no identity" and fail closed; this ticket is its named FE half. Each app's auth/domain/session.ts becomes principal.ts, holding the one Principal variant that app actually has an actor for: ssp keeps `{ kind: 'zorgverlener', bsn, naam }` (G1 still strips the BSN before persisting); behandelportal gets `{ kind: 'medewerker', medewerkerId, naam, rollen }` (no BSN to strip -- G2 shape validation only). A new MedewerkerAdapter replaces DigidAdapter in behandelportal, resolving the existing MEDEWERKER_ID/currentRollen() dev stand-in into a Principal; because there is no credential to check, it returns Principal directly rather than a Result whose error variant could never occur. login.page.ts stops being a BSN/wachtwoord form -- one explainer line and an "Inloggen met SSO" button -- and its dead error-handling branch goes with the Result wrapper that justified it. Measured with tools/baseline-scan.mjs --dup: auth duplication drops from 168/168 (ssp) and 168/200 (bhp) to 32/179 and 32/259 -- under the backlog's <40 target. What remains is the ADR-C-006 route-guard re-export (deliberately identical), generic test/story-file boilerplate, and one shared fragment of the root-singleton-store idiom -- not re-converged identity or login-flow logic. SS3's prediction that the two actors would authenticate differently enough to justify not sharing auth has now actually been tested, not just asserted, and held. Also: renamed Session.bsn to Principal.bsn in two doc comments (libs/shared/src/infrastructure/subject.ts, subject.interceptor.ts) that cited the old type name; regenerated libs/shared/docs/behaviour-spec.mdx (generated file, per its own banner); recorded the resolution in ADR-0002 as a new amendment, replacing its "Known debt" section. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
18 KiB
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 libsreturned exactly one hit — a comment inlibs/shared/src/infrastructure/role.ts:8. No such type existed.apps/ssp/src/app/auth/domain/session.tsandapps/behandelportal/src/app/auth/domain/session.tswere byte-identical:interface Session { readonly bsn: string; readonly naam: string }— a Behandelaar carrying absn, which §3 names as precisely the state the union exists to make unrepresentable.apps/behandelportal/src/app/auth/ui/login.page.tsrenderedintro="Log in op uw persoonlijke BIG-register omgeving."and calledSessionStore.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.tsalready stamps every backend request withX-Medewerker/X-Rollen, independently ofSessionStore— the divergence ADR-0002 predicted took this orthogonal side door instead of thePrincipalunion, which is why the twoauthcontexts still measured as identical.tools/baseline-scan.mjs --dup, measured immediately before this ticket (after ADR-C-006 shared the route guards):ssp/auth168/168 dup lines (100.0%),bhp/auth168/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<void>() |
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
Principalholds 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 ("Inapps/behandelportal: replaceSessionwith themedewerkervariant … Inapps/ssp: thezorgverlenervariant"), and it matches how the codebase already splitsauthper app.kindstays 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()returnsPromise<Principal>, notPromise<Result<string, Principal>>. The first draft mirroredDigidAdapter'sResult-returning shape for symmetry, but thatResult'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 directPromise<Principal>and dropped the now-dead error-handling branch fromlogin.page.ts(errorsignal, the<app-alert type="error">, theAlertComponentimport) — 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.rollenis typedreadonly Rol[]withRol = 'behandelaar', andparseRollenlives indomain/, not the adapter. The rawcurrentRollen()stand-in returns an unvalidated string (medewerker.ts, untouched by this ticket); turning it into typedRol[]is pure string logic with no Angular dependency, so it belongs indomain/principal.tsper CLAUDE.md §1's layer table — the adapter (infrastructure/) stays a thin wire-up that only reaches forMEDEWERKER_ID/currentRollen()and hands them to a pure function.parseRollendeliberately mirrors the backend's ownStubIdentityProvider.ParseRollen(comma-separated, unrecognized tokens dropped, so?rollen=geenyields[]) — this is not the FE recomputing a business rule (ADR-0001's boundary is about authorization decisions, which still come only fromGET /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 wayDigidAdapteralready fabricates its own fake identity.SessionStore(bhp) persists the wholePrincipaltolocalStorage, not a stripped-down{ naam }copy. The SSP's G1 guarantee ("never persist the BSN") doesn't apply here — amedewerkerprincipal has no national identifier — so there is nothing to strip.parseStoredPrincipalvalidates the full shape (G2 only) and restores it as-is. This was a deliberate choice against an alternative: reconstructingmedewerkerId/rollenfrom the liveMEDEWERKER_ID/currentRollen()on every restore, which would have madedomain/principal.tsdepend oninfrastructure/medewerker.ts— backwards per CLAUDE.md §1's inward-only dependency rule, and it would have madeparseStoredPrincipalimpure. Consequence: changing?rollen=mid-session does not retroactively change an already-restoredPrincipaluntil the nextlogin()/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, sincemedewerkerInterceptorreadscurrentRollen()fresh on every HTTP request regardless of whatSessionStoreholds.- Session/store class names (
SessionStore,SESSION_PORT,SessionPort) were left unchanged. ADR-0002 §3's own Consequences section namesSessionStore— alongsideauth.guard.ts— as one of the seams that localise theSession → Principalchange, not as something the change renames.libs/shared/src/application/session.port.ts'sSessionPort(ADR-C-006) is unaffected: it only ever exposed{ naam }andisAuthenticated, neither of which iskind-dependent. libs/shared/src/infrastructure/subject.ts/subject.interceptor.tsdoc 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 onlySESSION_PORT/AccessStore, neverPrincipal, so there was nothing for this ticket to change there.- No backend change. RB-09 already made
IIdentityProvidernullable 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-stubIIdentityProviderexists) 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 paralleldescribe/itstructure (including the sharedimport { describe, it, expect } from 'vitest';line); the assertions themselves differ (BSN-stripping vs. kind/rollen validation).login-form.stories.ts(3 windows) — the generic StorybookMeta/StoryObj/Defaultscaffold, unavoidable for any two co-located.stories.tsfiles 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/computedwiring 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): changedbsn: ''tobsn: parsed.bsn ?? ''→G1: a stored bsn is never restored…failed withexpected { bsn: '19012345601', …} to deeply equal { bsn: '', … }. Reverted; all other tests unaffected. - bhp
parseStoredPrincipal(kind guard): dropped theparsed?.kind === 'medewerker'clause →returns null when kind is not medewerkerfailed, returning the parsed zorgverlener-shaped object instead ofnull. Reverted. - bhp
parseRollen: dropped.filter(isRol)→drops unrecognized tokensandreturns an empty list for an empty stringboth 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 <target>). npm run ci: green (see the commit this
doc ships with).