refactor(auth): land Session -> Principal, add MedewerkerAdapter (RB-13)

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>
This commit is contained in:
eho
2026-08-27 16:54:26 +02:00
co-authored by Claude Opus 5
parent 988612cd7e
commit f19185ed81
22 changed files with 588 additions and 319 deletions
@@ -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
+1 -1
View File
@@ -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