feat(auth): authorization catalogues + Principal builder skeleton (ADR-0025) (#206)
CI / commits (push) Has been skipped
CI / check (push) Failing after 20s
CI / a11y (push) Failing after 20s
CI / perf (push) Failing after 44s
CI / scan (push) Failing after 55s
Docs site / build (push) Failing after 29s

## 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`.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #206
This commit was merged in pull request #206.
This commit is contained in:
2026-05-23 21:09:13 +02:00
parent c9a1e195fe
commit 12136f7a8a
35 changed files with 1709 additions and 29 deletions
@@ -63,7 +63,7 @@ The four privileges above are the entire v1 catalogue. The last three were provi
**Definition.** A _functional role_ is what someone does in APF. Multiple per user is the norm (a Directeur is also a Collaborateur; an RH-Aquitaine might also be a Bénévole de la Délégation 33). Functional roles carry **no** privilege flags and **no** scope by themselves — those live on the other axes.
**Source of truth.** Entra security groups, named `apf-role-<role-slug>`. Memberships emitted in the `groups` claim. The BFF's OIDC callback resolves each Entra group GUID to a role slug via a static mapping (`libs/feature/auth/src/lib/entra-group-to-role.ts`) and populates `Principal.roles`.
**Source of truth.** Entra security groups, named `apf-role-<role-slug>`. Memberships emitted in the `groups` claim. The BFF's OIDC callback resolves each Entra group GUID to a role slug via a static mapping (`libs/shared/auth/src/lib/entra-group-to-role.ts`) and populates `Principal.roles`.
**v1 catalogue.** Grouped for readability; the slugs are kebab-case and intent-bearing.
@@ -234,18 +234,26 @@ Two configurations live on the Entra app registration:
so the ID token includes the user's group GUIDs in the `groups` claim. The BFF's OIDC callback resolves group GUIDs to role slugs via a static map (committed to the repo). Unknown group GUIDs are logged at WARN and ignored — they do not break sign-in, but they signal a tenant misconfiguration that should be cleaned up.
**The group GUIDs are tenant-specific.** The map file (`libs/feature/auth/src/lib/entra-group-to-role.ts`) keys the slug on the GUID _per environment_ — the dev/test/preprod/prod tenants all have distinct GUIDs for the same role slug. The map is structured as:
**The group GUIDs are tenant-specific.** The map _data_ is keyed on Entra group GUID _per environment_ — the dev/test/preprod/prod tenants all have distinct GUIDs for the same role slug. The data lives in a gitignored `infra/<env>-tenant.entra.json` (see `infra/test-tenant.entra.example.json` for the schema). The BFF loads it at boot through the `EntraGroupToRoleResolver` exported from `libs/shared/auth/src/lib/entra-group-to-role.ts`:
```ts
export const ENTRA_GROUP_TO_ROLE: Record<string, FunctionalRole> = {
// test tenant — sourced from `infra/test-tenant.entra.json`
'11111111-1111-1111-1111-111111111111': 'collaborateur',
'22222222-2222-2222-2222-222222222222': 'rh',
// …
};
```json
{
"11111111-1111-1111-1111-111111111111": "collaborateur",
"22222222-2222-2222-2222-222222222222": "rh"
}
```
A dedicated config file per environment (`apps/portal-bff/.env.*`) holds an override token if needed. The `groups`-claim overage scenario (Entra emits a `_claim_sources` hint instead of the full list when the user is in too many groups) is handled by the BFF calling Microsoft Graph at sign-in — out of scope for v1's small test tenant, lands when the production rollout brings real population in.
```ts
// libs/shared/auth/src/lib/entra-group-to-role.ts
export class EntraGroupToRoleResolver {
resolve(
groupIds: ReadonlyArray<string>,
onUnknownGroup?: (groupId: string) => void,
): ReadonlyArray<FunctionalRole>;
}
```
The map file path is passed via `ENTRA_GROUP_MAP_PATH` (per-environment in `apps/portal-bff/.env.*`). The `groups`-claim overage scenario (Entra emits a `_claim_sources` hint instead of the full list when the user is in too many groups) is handled by the BFF calling Microsoft Graph at sign-in — out of scope for v1's small test tenant, lands when the production rollout brings real population in.
### Sources of truth — apf_portal-side `user_scopes` table
@@ -326,7 +334,7 @@ The four privileges live in the `apfrd.onmicrosoft.com` app registration with th
| `Portal.SecurityOfficer` | `6f50ce58-a1e1-496b-a3c0-655559c66a28` |
| `Portal.DPO` | `39b8815f-b3fd-4597-9679-77dcbf788a07` |
The 24 `apf-role-*` security groups were provisioned with the membership matrix above. Their GUIDs are tenant-specific and stay out of the repo; the implementation PR captures them in a gitignored `infra/test-tenant.entra.json` and references them by name in `libs/feature/auth/src/lib/entra-group-to-role.ts`.
The 24 `apf-role-*` security groups were provisioned with the membership matrix above. Their GUIDs are tenant-specific and stay out of the repo; the implementation PR captures them in a gitignored `infra/test-tenant.entra.json` and references them by name in `libs/shared/auth/src/lib/entra-group-to-role.ts`.
Scopes are **not** carried by Entra. They live in the portal-side `user_scopes` table, populated by `prisma/seed.ts` once the `Person` + `User` schema (proposed ADR-0026) lands. Until then the implementation skeleton honours the `unrestricted` default for testing.
@@ -343,7 +351,7 @@ Scopes are **not** carried by Entra. They live in the portal-side `user_scopes`
### Confirmation
- **Schema test** in `apps/portal-bff/src/auth/principal.spec.ts` (lands with the auth wiring PR): every guard composition produces the expected allow/deny on each of the 10 test personas. Theory-style test matrix mirrors `apf-ai-service/tests/Apf.Ai.Tests/Rbac/RbacMatrix.cs`.
- **Catalogue-vs-code drift check** in CI: a small ESLint custom rule (or a `pnpm run` script) greps every `@RequireRole('...')` / `@RequirePrivilege('...')` / scope literal in the codebase and asserts each one exists in the catalogue constants exported from `libs/feature/auth/src/lib/authorization.types.ts`. Fails the build on drift.
- **Catalogue-vs-code drift check** in CI: a small ESLint custom rule (or a `pnpm run` script) greps every `@RequireRole('...')` / `@RequirePrivilege('...')` / scope literal in the codebase and asserts each one exists in the catalogue constants exported from `libs/shared/auth/src/lib/authorization.types.ts`. Fails the build on drift.
- **Audit-event linkage**: every `403 Forbidden` from `@RequireRole` / `@RequireScope` writes an `admin.access_denied` row (per [ADR-0013](0013-audit-trail-separated-postgres-append-only.md) §"v1 events"), with the missing role/scope in the payload. Auditors can spot privilege-escalation attempts by pivoting on `outcome=denied`.
- **PrincipalProjector test**: snapshot test asserting that the AI-service projection of a known persona produces a known flat-list output. Same fixture is reused on the AI service side as part of its RBAC matrix.
@@ -393,7 +401,7 @@ This door is recorded here so a future contributor does not feel they have to re
## More Information
- **Phasing.** This ADR is decision-only. The implementation phasing is:
1. **PR — `Authorization` types + Principal builder + `entra-group-to-role` mapping skeleton.** Lands `libs/feature/auth/src/lib/authorization.types.ts` (the catalogues) and the OIDC callback hook that populates the new `privileges` / `roles` / `scopes` fields on the session principal. No new guards yet.
1. **PR — `Authorization` types + Principal builder + `entra-group-to-role` mapping skeleton.** Lands `libs/shared/auth/src/lib/authorization.types.ts` (the catalogues) and the OIDC callback hook that populates the new `privileges` / `roles` / `scopes` fields on the session principal. No new guards yet.
2. **PR — `@RequireRole` + `@RequireScope` decorators + guard tests.** Adds the guards against a stubbed principal; integration with the actual session lands in the same PR.
3. **PR — Drift CI gate.** ESLint rule (or `pnpm run` script) that asserts every role / privilege / scope literal in the codebase is in the catalogue.
4. **PR — Test-tenant seed.** `prisma/seed.ts` populating the 10 test personas' `user_scopes` rows. Depends on the `Person` + `User` schema landing first (proposed ADR-0026).