feat(auth): authorization catalogues + Principal builder skeleton (ADR-0025) #206
Reference in New Issue
Block a user
Delete Branch "feat/auth-catalogue-skeleton"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
First implementation PR after ADR-0025 was accepted in #205. Lands the closed-set catalogues + the OIDC-callback hook that composes the session-resident
Principal. No new guards yet — those come in the next PR per the ADR's§More Informationphasing.What lands
New shared library:
libs/shared/auth/(scope:shared,type:shared, framework-agnostic, vitest)src/lib/authorization.types.tsPRIVILEGES(4),FUNCTIONAL_ROLES(24),SCOPE_KINDS(6).Principal,Scopetypes.isPrivilege/isFunctionalRole/isScopeKindtype-guards for the drift gate.src/lib/entra-group-to-role.tsparseEntraGroupMap(validates GUID + slug closedness + dedup) +EntraGroupToRoleResolver(translates the Entragroupsclaim into orderedapf-role-*slugs, reports unknown GUIDs via callback).src/lib/*.spec.tsBFF wiring (
apps/portal-bff/src/auth/+src/config/)auth.service.tsgroupsclaim.AuthenticatedUsergrowsgroups: readonly string[].principal-builder.tsrolesclaim throughisPrivilege(drops + WARNs on unknown), resolvesgroupsclaim through theEntraGroupToRoleResolver(drops + WARNs on unknown).scope-resolver.tsScopeResolverabstract class +StubScopeResolverreturning[{ kind: 'unrestricted' }](per ADR-0025 §331 — replaced by a Prisma implementation when ADR-0026'suser_scopestable lands).session-establisher.service.tsPrincipalBuilder.build()once, persists the result asreq.session.principal. The legacyreq.session.usershape is untouched (audit + AI bridge keep working).session.types.tsprincipal?: Principalfield on the express-session augmentation.entra-group-to-role.token.tsconfig/load-entra-group-map.tsinfra/<env>-tenant.entra.jsonat boot. Missing path → empty resolver + WARN. Malformed file → hard fail (alternative would be silently dropping a role).Tests against the 19 test-tenant personas (
principal-builder.spec.ts)Every persona provisioned in
apfrd.onmicrosoft.comon 2026-05-20 is exercised end-to-end:admin,directeur-bordeaux,directeur-complexe,rh-aquitaine,rh-siege,collaborateur-simple,tresorier-bordeaux,dpo,it,benevole-aquitaine,chef-equipe-bordeaux,chef-service-bordeaux,directeur-territorial-aquitaine,juriste-siege,rssi,communication-siege,elu-ca-national,president-cd-aquitaine,secretaire-cd-aquitaine. A coverage assertion confirms the personas cover all 4 privileges + 23 of 24 functional roles (thepartenairegap is intentional per ADR-0025).Infra + docs
infra/test-tenant.entra.example.json*-tenant.entra.json) gitignored.infra/README.mdapps/portal-bff/.env.exampleENTRA_GROUP_MAP_PATHblock..gitignoreinfra/*-tenant.entra.json(except.example.json).tsconfig.base.jsonshared-authpath alias.notes/entra-groups-claim-activation.mdADR amendment
docs/decisions/0025-authorization-model-privileges-roles-scopes.mdlibs/feature/auth/...→libs/shared/auth/...(4 occurrences), and theENTRA_GROUP_TO_ROLEstatic-record example replaced by the actualEntraGroupToRoleResolver+ JSON shape.Notes for the reviewer
Why
libs/shared/auth/and notlibs/feature/auth/(as the ADR initially said). The existinglibs/feature/auth/carriestags: ['scope:portal-shell', 'type:feature'], which the Nxenforce-module-boundariesrule ineslint.config.mjs:36-46blocks the BFF from importing. The catalogue is needed by both sides (BFF builds thePrincipal, SPA will gate UI conditionally later), so ascope:sharedlib is the correct home. The ADR is patched in the same PR so the document and the code agree.Why drop unknown privileges with a WARN instead of preserving them.
Portal.GhostRolein therolesclaim is either a leftover from a different app registration or a v1+1 experiment that hasn't been added to the catalogue yet. Both paths should not silently grant access — drop with a WARN so an operator notices, and the drift CI gate (next PR) catches the source-side equivalent before merge.Why the principal builder uses a
ScopeResolverseam now. Per ADR-0025 §331 the v1 implementation returnsunrestrictedfor everyone; the real version queries theuser_scopesPrisma table that lands with ADR-0026. The seam is here so the next PR replaces only the implementation, not the call-site inPrincipalBuilderor its 24 tests. Per-persona stub scope data was deliberately not embedded in this PR — no guard consumes scopes yet, so it would be write-only documentation that drifts from the eventual Prisma seed.Why
user.idanduser.personIdplaceholder toentraOid. Same logic: the real UUIDs land with thePerson+UserPrisma schema (proposed ADR-0026). Until then, populating both fields withentraOidlets consumers key onprincipal.user.idtoday and get a real value later without branching on availability. The seam is one place (in the builder), not 24 guards.Why session.types.ts carries both
userandprincipal. Additive instead of replacement: the audit module + the AI-bridge controller key onuser.oid/user.tiddirectly, and migrating them in the same PR would balloon the diff for zero behavioural benefit. New consumers (@RequirePrivilege/@RequireRole/@RequireScopedecorators, next PR) reach forprincipal.Map file fails soft on absence, hard on malformation. Path unset OR file missing → empty resolver + WARN. Sign-in still succeeds, every user gets
roles: []. This is deliberately fail-soft so a fresh dev environment isn't blocked by an Entra-side dependency. Bad JSON / unknown slug / duplicate GUID → throws at boot. The alternative would silently mis-resolve a role.Why
boot-timevalidation only (no per-request) for the map file. Catalogue drift is detected once at boot; per-request validation would re-read the file from disk on every sign-in. The trade-off is that a hot-edit to the file requires a BFF restart — acceptable because the file changes once per tenant lifecycle, not per session.Entra
groupsclaim must be activated on the BFF's app registration before this code does anything useful. The runbook innotes/entra-groups-claim-activation.mdwalks through the steps; if the claim isn't activated, every user signs in withprincipal.roles = []and a future warn for every group GUID that would have arrived (none, in that case).No drift gate yet. ADR-0025's §"Confirmation" §346 calls for an ESLint custom rule (or
pnpm runscript) that greps every@RequireRole('...')literal and asserts membership in the catalogue. That's the third PR in the phasing — there's no@RequireRoleconsumer in the tree yet, so the gate would have nothing to assert against today.Test plan
pnpm nx affected -t lint test build --base=main— 13 projects greengroups-claim tests inauth.service.spec.ts, 1 new test insession-establisher.service.spec.ts, 6 new tests inload-entra-group-map.spec.ts, 24 new tests inprincipal-builder.spec.ts)shared-authtests (catalogue counts + resolver contracts)shared-utiltest (unchanged)pnpm nx format:check— clean afterpnpm nx format:writetsc --build libs/shared/auth/tsconfig.lib.jsonemits.d.tsfiles at the pathportal-bff's webpack expects.PRIVILEGES,FUNCTIONAL_ROLES,SCOPE_KINDS) verbatim match ADR-0025; the 19 personas verbatim matchnotes/test-tenant-role-assignments.md; thePrincipalshape matches the ADR §"Principal shape" (modulo theuser.id/personIdplaceholder); fail-soft on missing map file, hard on malformed.groupsclaim pernotes/entra-groups-claim-activation.mdand drop the real GUIDs intoinfra/test-tenant.entra.json(gitignored). Then a sign-in withadmin@apfrd.onmicrosoft.comshould landprincipal.privileges = ['Portal.Admin']andprincipal.roles = ['collaborateur', 'rh']in Redis.What's next
Per ADR-0025 §"More Information" phasing:
@RequirePrivilege+@RequireRole+@RequireScopedecorators + guard integration tests against the 19 personas.@RequireX('...')literal is in the catalogue).user_scopesPrisma table + seed +PrismaScopeResolverreplacingStubScopeResolver.