feat(auth): authorization catalogues + Principal builder skeleton (ADR-0025) #206

Merged
julien merged 1 commits from feat/auth-catalogue-skeleton into main 2026-05-23 21:09:14 +02:00
Owner

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 Information phasing.

What lands

New shared library: libs/shared/auth/ (scope:shared, type:shared, framework-agnostic, vitest)

File Role
src/lib/authorization.types.ts Closed catalogues: PRIVILEGES (4), FUNCTIONAL_ROLES (24), SCOPE_KINDS (6). Principal, Scope types. isPrivilege / isFunctionalRole / isScopeKind type-guards for the drift gate.
src/lib/entra-group-to-role.ts parseEntraGroupMap (validates GUID + slug closedness + dedup) + EntraGroupToRoleResolver (translates the Entra groups claim into ordered apf-role-* slugs, reports unknown GUIDs via callback).
src/lib/*.spec.ts 17 unit tests against the catalogue counts + resolver contracts.

BFF wiring (apps/portal-bff/src/auth/ + src/config/)

File Role
auth.service.ts Extracts the Entra groups claim. AuthenticatedUser grows groups: readonly string[].
principal-builder.ts Composes the three axes at sign-in. Filters roles claim through isPrivilege (drops + WARNs on unknown), resolves groups claim through the EntraGroupToRoleResolver (drops + WARNs on unknown).
scope-resolver.ts ScopeResolver abstract class + StubScopeResolver returning [{ kind: 'unrestricted' }] (per ADR-0025 §331 — replaced by a Prisma implementation when ADR-0026's user_scopes table lands).
session-establisher.service.ts Calls PrincipalBuilder.build() once, persists the result as req.session.principal. The legacy req.session.user shape is untouched (audit + AI bridge keep working).
session.types.ts Adds optional principal?: Principal field on the express-session augmentation.
entra-group-to-role.token.ts DI token for the lazily-loaded resolver.
config/load-entra-group-map.ts Reads the gitignored infra/<env>-tenant.entra.json at 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.com on 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 (the partenaire gap is intentional per ADR-0025).

Infra + docs

File Change
infra/test-tenant.entra.example.json Template GUID → slug map (24 entries, full v1 catalogue). Real file (*-tenant.entra.json) gitignored.
infra/README.md New row + "Entra group map" section documenting the load semantics + provisioning workflow.
apps/portal-bff/.env.example New ENTRA_GROUP_MAP_PATH block.
.gitignore infra/*-tenant.entra.json (except .example.json).
tsconfig.base.json shared-auth path alias.
notes/entra-groups-claim-activation.md Operator runbook for the Entra admin-centre step (Token configuration → Add groups claim → Security groups → Group ID).

ADR amendment

File Change
docs/decisions/0025-authorization-model-privileges-roles-scopes.md Path references updated libs/feature/auth/...libs/shared/auth/... (4 occurrences), and the ENTRA_GROUP_TO_ROLE static-record example replaced by the actual EntraGroupToRoleResolver + JSON shape.

Notes for the reviewer

  • Why libs/shared/auth/ and not libs/feature/auth/ (as the ADR initially said). The existing libs/feature/auth/ carries tags: ['scope:portal-shell', 'type:feature'], which the Nx enforce-module-boundaries rule in eslint.config.mjs:36-46 blocks the BFF from importing. The catalogue is needed by both sides (BFF builds the Principal, SPA will gate UI conditionally later), so a scope:shared lib 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.GhostRole in the roles claim 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 ScopeResolver seam now. Per ADR-0025 §331 the v1 implementation returns unrestricted for everyone; the real version queries the user_scopes Prisma table that lands with ADR-0026. The seam is here so the next PR replaces only the implementation, not the call-site in PrincipalBuilder or 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.id and user.personId placeholder to entraOid. Same logic: the real UUIDs land with the Person + User Prisma schema (proposed ADR-0026). Until then, populating both fields with entraOid lets consumers key on principal.user.id today 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 user and principal. Additive instead of replacement: the audit module + the AI-bridge controller key on user.oid / user.tid directly, and migrating them in the same PR would balloon the diff for zero behavioural benefit. New consumers (@RequirePrivilege / @RequireRole / @RequireScope decorators, next PR) reach for principal.

  • 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-time validation 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 groups claim must be activated on the BFF's app registration before this code does anything useful. The runbook in notes/entra-groups-claim-activation.md walks through the steps; if the claim isn't activated, every user signs in with principal.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 run script) that greps every @RequireRole('...') literal and asserts membership in the catalogue. That's the third PR in the phasing — there's no @RequireRole consumer 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 green
    • 497 BFF tests (was 465 — added 4 new groups-claim tests in auth.service.spec.ts, 1 new test in session-establisher.service.spec.ts, 6 new tests in load-entra-group-map.spec.ts, 24 new tests in principal-builder.spec.ts)
    • 17 shared-auth tests (catalogue counts + resolver contracts)
    • 1 shared-util test (unchanged)
    • full lint + full build
  • pnpm nx format:check — clean after pnpm nx format:write
  • Manual: tsc --build libs/shared/auth/tsconfig.lib.json emits .d.ts files at the path portal-bff's webpack expects.
  • Review focus — the closed catalogues (PRIVILEGES, FUNCTIONAL_ROLES, SCOPE_KINDS) verbatim match ADR-0025; the 19 personas verbatim match notes/test-tenant-role-assignments.md; the Principal shape matches the ADR §"Principal shape" (modulo the user.id / personId placeholder); fail-soft on missing map file, hard on malformed.
  • After merge — operator step : activate the Entra groups claim per notes/entra-groups-claim-activation.md and drop the real GUIDs into infra/test-tenant.entra.json (gitignored). Then a sign-in with admin@apfrd.onmicrosoft.com should land principal.privileges = ['Portal.Admin'] and principal.roles = ['collaborateur', 'rh'] in Redis.

What's next

Per ADR-0025 §"More Information" phasing:

  1. This PR — types + Principal builder + group-to-role mapping skeleton.
  2. Next PR@RequirePrivilege + @RequireRole + @RequireScope decorators + guard integration tests against the 19 personas.
  3. PR after that — drift CI gate (ESLint custom rule asserting every @RequireX('...') literal is in the catalogue).
  4. Depends on ADR-0026user_scopes Prisma table + seed + PrismaScopeResolver replacing StubScopeResolver.
## Summary First implementation PR after [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md) 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 Information` phasing. ## What lands **New shared library: `libs/shared/auth/`** (`scope:shared`, `type:shared`, framework-agnostic, vitest) | File | Role | | --- | --- | | `src/lib/authorization.types.ts` | Closed catalogues: `PRIVILEGES` (4), `FUNCTIONAL_ROLES` (24), `SCOPE_KINDS` (6). `Principal`, `Scope` types. `isPrivilege` / `isFunctionalRole` / `isScopeKind` type-guards for the drift gate. | | `src/lib/entra-group-to-role.ts` | `parseEntraGroupMap` (validates GUID + slug closedness + dedup) + `EntraGroupToRoleResolver` (translates the Entra `groups` claim into ordered `apf-role-*` slugs, reports unknown GUIDs via callback). | | `src/lib/*.spec.ts` | 17 unit tests against the catalogue counts + resolver contracts. | **BFF wiring** (`apps/portal-bff/src/auth/` + `src/config/`) | File | Role | | --- | --- | | `auth.service.ts` | Extracts the Entra `groups` claim. `AuthenticatedUser` grows `groups: readonly string[]`. | | `principal-builder.ts` | Composes the three axes at sign-in. Filters `roles` claim through `isPrivilege` (drops + WARNs on unknown), resolves `groups` claim through the `EntraGroupToRoleResolver` (drops + WARNs on unknown). | | `scope-resolver.ts` | `ScopeResolver` abstract class + `StubScopeResolver` returning `[{ kind: 'unrestricted' }]` (per ADR-0025 §331 — replaced by a Prisma implementation when ADR-0026's `user_scopes` table lands). | | `session-establisher.service.ts` | Calls `PrincipalBuilder.build()` once, persists the result as `req.session.principal`. The legacy `req.session.user` shape is untouched (audit + AI bridge keep working). | | `session.types.ts` | Adds optional `principal?: Principal` field on the express-session augmentation. | | `entra-group-to-role.token.ts` | DI token for the lazily-loaded resolver. | | `config/load-entra-group-map.ts` | Reads the gitignored `infra/<env>-tenant.entra.json` at 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.com` on 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 (the `partenaire` gap is intentional per ADR-0025). **Infra + docs** | File | Change | | --- | --- | | `infra/test-tenant.entra.example.json` | Template GUID → slug map (24 entries, full v1 catalogue). Real file (`*-tenant.entra.json`) gitignored. | | `infra/README.md` | New row + "Entra group map" section documenting the load semantics + provisioning workflow. | | `apps/portal-bff/.env.example` | New `ENTRA_GROUP_MAP_PATH` block. | | `.gitignore` | `infra/*-tenant.entra.json` (except `.example.json`). | | `tsconfig.base.json` | `shared-auth` path alias. | | `notes/entra-groups-claim-activation.md` | Operator runbook for the Entra admin-centre step (Token configuration → Add groups claim → Security groups → Group ID). | **ADR amendment** | File | Change | | --- | --- | | `docs/decisions/0025-authorization-model-privileges-roles-scopes.md` | Path references updated `libs/feature/auth/...` → `libs/shared/auth/...` (4 occurrences), and the `ENTRA_GROUP_TO_ROLE` static-record example replaced by the actual `EntraGroupToRoleResolver` + JSON shape. | ## Notes for the reviewer - **Why `libs/shared/auth/` and not `libs/feature/auth/` (as the ADR initially said).** The existing `libs/feature/auth/` carries `tags: ['scope:portal-shell', 'type:feature']`, which the Nx `enforce-module-boundaries` rule in `eslint.config.mjs:36-46` blocks the BFF from importing. The catalogue is needed by both sides (BFF builds the `Principal`, SPA will gate UI conditionally later), so a `scope:shared` lib 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.GhostRole` in the `roles` claim 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 `ScopeResolver` seam now.** Per ADR-0025 §331 the v1 implementation returns `unrestricted` for everyone; the real version queries the `user_scopes` Prisma table that lands with ADR-0026. The seam is here so the next PR replaces only the implementation, not the call-site in `PrincipalBuilder` or 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.id` and `user.personId` placeholder to `entraOid`.** Same logic: the real UUIDs land with the `Person` + `User` Prisma schema (proposed ADR-0026). Until then, populating both fields with `entraOid` lets consumers key on `principal.user.id` today 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 `user` and `principal`.** Additive instead of replacement: the audit module + the AI-bridge controller key on `user.oid` / `user.tid` directly, and migrating them in the same PR would balloon the diff for zero behavioural benefit. New consumers (`@RequirePrivilege` / `@RequireRole` / `@RequireScope` decorators, next PR) reach for `principal`. - **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-time` validation 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 `groups` claim must be activated** on the BFF's app registration before this code does anything useful. The runbook in `notes/entra-groups-claim-activation.md` walks through the steps; if the claim isn't activated, every user signs in with `principal.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 run` script) that greps every `@RequireRole('...')` literal and asserts membership in the catalogue. That's the third PR in the phasing — there's no `@RequireRole` consumer in the tree yet, so the gate would have nothing to assert against today. ## Test plan - [x] `pnpm nx affected -t lint test build --base=main` — 13 projects green - 497 BFF tests (was 465 — added 4 new `groups`-claim tests in `auth.service.spec.ts`, 1 new test in `session-establisher.service.spec.ts`, 6 new tests in `load-entra-group-map.spec.ts`, 24 new tests in `principal-builder.spec.ts`) - 17 `shared-auth` tests (catalogue counts + resolver contracts) - 1 `shared-util` test (unchanged) - full lint + full build - [x] `pnpm nx format:check` — clean after `pnpm nx format:write` - [x] Manual: `tsc --build libs/shared/auth/tsconfig.lib.json` emits `.d.ts` files at the path `portal-bff`'s webpack expects. - [ ] **Review focus** — the closed catalogues (`PRIVILEGES`, `FUNCTIONAL_ROLES`, `SCOPE_KINDS`) verbatim match ADR-0025; the 19 personas verbatim match `notes/test-tenant-role-assignments.md`; the `Principal` shape matches the ADR §"Principal shape" (modulo the `user.id` / `personId` placeholder); fail-soft on missing map file, hard on malformed. - [ ] **After merge — operator step** : activate the Entra `groups` claim per `notes/entra-groups-claim-activation.md` and drop the real GUIDs into `infra/test-tenant.entra.json` (gitignored). Then a sign-in with `admin@apfrd.onmicrosoft.com` should land `principal.privileges = ['Portal.Admin']` and `principal.roles = ['collaborateur', 'rh']` in Redis. ## What's next Per ADR-0025 §"More Information" phasing: 1. ✅ **This PR** — types + Principal builder + group-to-role mapping skeleton. 2. **Next PR** — `@RequirePrivilege` + `@RequireRole` + `@RequireScope` decorators + guard integration tests against the 19 personas. 3. **PR after that** — drift CI gate (ESLint custom rule asserting every `@RequireX('...')` literal is in the catalogue). 4. **Depends on ADR-0026** — `user_scopes` Prisma table + seed + `PrismaScopeResolver` replacing `StubScopeResolver`.
julien added 1 commit 2026-05-23 21:08:56 +02:00
feat(auth): authorization catalogues + Principal builder skeleton (ADR-0025)
CI / check (pull_request) Failing after 28s
CI / commits (pull_request) Failing after 25s
CI / scan (pull_request) Failing after 56s
CI / a11y (pull_request) Failing after 33s
CI / perf (pull_request) Failing after 47s
Docs site / build (pull_request) Failing after 26s
540d9dc28a
Per ADR-0025's implementation phasing, lands the closed-set
catalogues + the OIDC-callback hook that composes the
session-resident Principal. No new guards yet.

- libs/shared/auth: framework-agnostic lib hosting
  authorization.types.ts (4 privileges + 24 functional roles + 6
  scope kinds + Principal type) and entra-group-to-role.ts
  (validated GUID -> slug resolver). 17 unit tests against the
  catalogues + resolver contracts.
- BFF: PrincipalBuilder composes the three axes once at sign-in;
  ScopeResolver seam (StubScopeResolver returns unrestricted in
  v1, replaced by a Prisma-backed implementation when ADR-0026's
  user_scopes table lands). 19 persona-driven tests covering the
  test tenant's full provisioning + edge cases (unknown
  privilege drift, unknown group GUID, empty permissions).
- AuthService extracts the Entra "groups" claim; AuthenticatedUser
  grows the groups: readonly string[] field.
- SessionEstablisher builds + persists the principal alongside
  the legacy user shape (additive — guards consuming user.oid
  unchanged).
- ENTRA_GROUP_MAP_PATH env var + load-entra-group-map.ts read
  the tenant-private map from infra/<env>-tenant.entra.json
  (gitignored; .example.json committed with the schema).
- ADR-0025 path references updated from libs/feature/auth
  (Angular-scoped, scope:portal-shell) to libs/shared/auth
  (consumable by both BFF and SPA).
- notes/entra-groups-claim-activation.md: operator runbook for
  the "Token configuration -> Add groups claim" step in the
  Entra admin centre.

Test plan:
- pnpm nx affected -t lint test build : 13 projects green
  (497 BFF tests + 17 shared-auth tests + 1 shared-util test +
  full lint + full build).
julien merged commit 12136f7a8a into main 2026-05-23 21:09:14 +02:00
julien deleted branch feat/auth-catalogue-skeleton 2026-05-23 21:09:21 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#206