12136f7a8a
## 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
418 lines
40 KiB
Markdown
418 lines
40 KiB
Markdown
---
|
||
status: accepted
|
||
date: 2026-05-20
|
||
decision-makers: R&D Lead
|
||
tags: [security, backend, data]
|
||
---
|
||
|
||
# Authorization model — three orthogonal axes (privileges × functional roles × scopes), Entra-backed with apf_portal-side projections
|
||
|
||
## Context and Problem Statement
|
||
|
||
`apf_portal` has authentication settled — Entra ID OIDC with PKCE per [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md), session storage in Redis per [ADR-0010](0010-session-management-redis.md), the workforce identity model per [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md). The first authorization gate exists too: `Portal.Admin` Entra app role + `AdminRoleGuard` per [ADR-0020](0020-portal-admin-app.md). What is missing is a **general-purpose authorization model** — one that scales to the business surfaces the portal will host (notes de frais, dossiers personnels, gouvernance, RAG citations, …) and aligns with how APF France handicap actually delegates responsibility.
|
||
|
||
The stargate POC modelled authorization as a single linear hierarchy: `Admin ⊃ Directeur ⊃ RH ⊃ Collaborateur`, with an inclusive expansion mapper that flattened the chain to a list of strings the AI service consumed. That shape is wrong for APF for three reasons:
|
||
|
||
1. **APF roles are not a chain of strictly-more-permissive privileges.** A _Directeur_ of an établissement does not have _more_ rights than an _RH_ — they have _different_ rights on a different axis. The Directeur's authority is geographically narrow (their own site) and functionally wide (all aspects of that site); the RH's authority is functionally narrow (HR data only) but possibly geographically wide (multiple établissements, sometimes the whole organisation).
|
||
2. **The model has no notion of scope.** "Directeur" without an `etablissement_id` is meaningless — APF runs ≈ 550 établissements; the Directeur of the SAVS Bordeaux must not see the dossiers of the IME Lille. A linear-role label cannot carry this geographic boundary.
|
||
3. **`Admin` is not an APF business role.** It is a portal-side privilege (the admin app surface). Conflating it with the hierarchy of business roles means an admin's right to read the audit log gets evaluated against the same dimension as a Directeur's right to read their employees' contracts — different concerns, different sources, different lifecycles.
|
||
|
||
This ADR replaces the linear hierarchy with a richer model and locks the catalogues + Entra-side configuration the portal depends on.
|
||
|
||
## Decision Drivers
|
||
|
||
- **Audit-friendly.** Every authorization decision must be expressible as a short, deterministic check against a `Principal` whose shape is recorded here. Auditors should be able to reason about access without running the code.
|
||
- **Single source of truth per axis.** Privileges live in Entra app roles. Functional roles live in Entra security groups (mapped to a curated catalogue). Scopes live in APF's HR + governance data (Pléiades initially, with a portal-side override table for v1). No axis is read from two places.
|
||
- **Extensible without rewriting guards.** Adding a new functional role, a new scope kind, or a new privilege should land as a catalogue update + a one-line guard, not as a rewrite of the authorization stack.
|
||
- **Compatible with the AI relay contract.** [ADR-0024](0024-ai-service-relay-grpc-sse-bridge.md)'s `Principal { subject, roles[], attributes{} }` proto message expects a flat list of role strings. The internal three-axis `Principal` must project to that flat shape deterministically.
|
||
- **Match Entra's capabilities.** App roles + security groups are the standard Entra surface — no need to introduce an external policy engine for v1.
|
||
- **Survive APF's org-chart churn.** Établissements open, close, merge, change délégation. The model must let an operator move a Directeur from one site to another without code changes.
|
||
- **Defer ABAC complexity.** Full attribute-based access control (Cedar, OPA, Rego) would buy generality at the cost of operational weight. v1 does not need that. Capture the door for future ABAC inside the existing model — see "Open question — ABAC migration path".
|
||
|
||
## Considered Options
|
||
|
||
- **Linear role hierarchy with inclusive expansion** (stargate's choice).
|
||
- **Flat role list, no scope** (typical SaaS).
|
||
- **Three orthogonal axes — privileges × functional roles × scopes** _(chosen)_.
|
||
- **Full ABAC with an external policy engine** (Cedar / OPA).
|
||
|
||
## Decision Outcome
|
||
|
||
Chosen: **three orthogonal axes** — `privileges`, `functional roles`, `scopes` — composed at sign-in into a session-scoped `Principal`. Each axis has a single, declared source of truth and an explicit v1 catalogue. The portal's own guards consume the full structured `Principal`; the projection sent to `apf-ai-service` (per ADR-0024) is the inclusive-expanded flat list of role strings, derived from this richer model.
|
||
|
||
### Axis 1 — Privileges (Entra app roles)
|
||
|
||
**Definition.** A _privilege_ is a coarse-grained portal-level capability, orthogonal to APF business roles. Privileges gate **portal surfaces** (the admin app, the future audit-only viewer, the security-officer dashboard). They never carry scope.
|
||
|
||
**Source of truth.** Entra app role definitions on the BFF's app registration. Assignments emit the `roles` claim in the ID token. Read by the BFF's OIDC callback per [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md).
|
||
|
||
**v1 catalogue.**
|
||
|
||
| Privilege | Surface gated | Status |
|
||
| ------------------------ | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
||
| `Portal.Admin` | `portal-admin` app (CMS, menus, user list, audit viewer) | already in place — [ADR-0020](0020-portal-admin-app.md) |
|
||
| `Portal.Auditor` | Read-only access to the audit viewer for compliance staff who should not write to anything (surface lands later). | provisioned in test tenant; consumer surface deferred |
|
||
| `Portal.SecurityOfficer` | RSSI-specific dashboards (incident timeline, vuln scanner output, key rotation status — surface lands later). | provisioned in test tenant; consumer surface deferred |
|
||
| `Portal.DPO` | DPO-specific surface (data subject requests, retention exceptions — surface lands later). | provisioned in test tenant; consumer surface deferred |
|
||
|
||
The four privileges above are the entire v1 catalogue. The last three were provisioned in the test tenant ahead of their consuming surfaces — they ride in the catalogue now so the surfaces can be built against a stable contract. Adding a fifth privilege is an ADR amendment + a one-line Entra manifest change.
|
||
|
||
**Why these are privileges, not functional roles.** They gate **what part of the portal you can see**, not **what kind of APF work you do**. A DPO is a functional role (the person's job); `Portal.DPO` would be the portal slice they consume in that capacity. The two often align but are distinct — an `it` functional role might hold `Portal.Admin` privilege; a `dpo` functional role might hold `Portal.DPO` privilege.
|
||
|
||
### Axis 2 — Functional roles (Entra security groups → curated catalogue)
|
||
|
||
**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/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.
|
||
|
||
**Workforce (employees on payroll)**:
|
||
|
||
| Slug | Description |
|
||
| ------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||
| `collaborateur` | Base role for any employee. Inherited via Pléiades sync once it lands; v1 assigned via group membership. |
|
||
| `chef-equipe` | Team lead within an établissement. |
|
||
| `chef-service` | Service head within an établissement (multiple under a Directeur). |
|
||
| `directeur-etablissement` | Director of one or more sites — scope-bearing. |
|
||
| `directeur-territorial` | Regional director coordinating multiple établissements. |
|
||
| `rh` | HR specialist — Pléiades operators, paie, formation, etc. |
|
||
| `responsable-paie` | Payroll specialist subset of RH. |
|
||
| `comptable` | Accountant. |
|
||
| `juriste` | In-house legal. |
|
||
| `dpo` | Data Protection Officer. |
|
||
| `rssi` | Security officer (RSSI). |
|
||
| `it` | Internal IT support. |
|
||
| `formation` | Training coordinator. |
|
||
| `qualite` | Quality / compliance officer. |
|
||
| `communication` | Comms staff (national + délégation level). |
|
||
|
||
**Governance (élus and delegates)**:
|
||
|
||
| Slug | Description |
|
||
| ------------------- | -------------------------------------------------------------------- |
|
||
| `elu-ca` | Member of the national Conseil d'Administration. |
|
||
| `elu-cd` | Member of a Conseil Départemental. |
|
||
| `elu-cd-president` | President of a Conseil Départemental (carries the delegation scope). |
|
||
| `elu-cd-tresorier` | Treasurer of a Conseil Départemental. |
|
||
| `elu-cd-secretaire` | Secretary of a Conseil Départemental. |
|
||
| `delegue` | Local delegate (sub-departmental). |
|
||
|
||
**Volunteer (bénévoles with portal access)**:
|
||
|
||
| Slug | Description |
|
||
| ---------------------- | ---------------------------------------------- |
|
||
| `benevole` | Active volunteer with portal access. |
|
||
| `benevole-responsable` | Volunteer in a leadership / coordination role. |
|
||
|
||
**External**:
|
||
|
||
| Slug | Description |
|
||
| ------------ | ---------------------------------------------------------------------------------------------- |
|
||
| `partenaire` | External partner with restricted, named-access portal surfaces (placeholder; no consumer yet). |
|
||
|
||
The catalogue is **closed** in v1 — new slugs require an ADR amendment. The closed-set posture is deliberate: each guard `@RequireRole('rh')` references a slug by string, and a drift between code and catalogue would silently mis-authorize. CI gate (proposed in §"Confirmation") asserts that every slug used in code is in the catalogue.
|
||
|
||
### Axis 3 — Scopes (APF org structure → portal session)
|
||
|
||
**Definition.** A _scope_ delimits **where** a role applies. A Directeur with `etablissement:0330800013` can see employees of that one site, not others. A national RH with `unrestricted` sees everywhere. Scopes are **lists** — a Directeur of a "complexe" with two co-located sites holds two `etablissement:*` scopes.
|
||
|
||
**Source of truth (v1).** A new `user_scopes` table in the portal database, seeded manually via the admin app for the test tenant. **Future:** populated by the Pléiades sync (workforce scopes derived from current `contrat`) and the Acteurs+ sync (governance scopes derived from current `mandat`); the portal-side `user_scopes` table remains as the override layer for exceptions.
|
||
|
||
**v1 scope kinds.**
|
||
|
||
| Kind | Carrier | Example value |
|
||
| ------------------------ | -------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||
| `self` | implicit | every `collaborateur` has this by default; means "your own dossier, your own notes de frais, …". |
|
||
| `etablissement:<finess>` | FINESS code (`9` characters, stable across reorgs) | `etablissement:0330800013` |
|
||
| `delegation:<dept>` | French department code | `delegation:33` |
|
||
| `region:<insee>` | INSEE region code (2 digits) | `region:75` (Nouvelle-Aquitaine) |
|
||
| `siege` | implicit | national head office staff, no geo coordinate. |
|
||
| `unrestricted` | implicit | crosses every axis; used sparingly (DPO, RSSI, national HR). |
|
||
|
||
The kinds are **closed** in v1. Adding a new kind requires this ADR's amendment. Adding a value (a new établissement, a new délégation) is a data operation, not a code change.
|
||
|
||
**Scope expansion at check time.** Guards do not pre-expand scopes — they perform containment checks against the resource being protected:
|
||
|
||
```ts
|
||
function principalCoversEtablissement(p: Principal, etablissement: Etablissement): boolean {
|
||
return p.scopes.some(
|
||
(s) =>
|
||
s.kind === 'unrestricted' ||
|
||
(s.kind === 'etablissement' && s.value === etablissement.finess) ||
|
||
(s.kind === 'delegation' && s.value === etablissement.delegationCode) ||
|
||
(s.kind === 'region' && s.value === etablissement.regionCode) ||
|
||
(s.kind === 'siege' && etablissement.kind === 'siege'),
|
||
);
|
||
}
|
||
```
|
||
|
||
This means the _resource_ (here, the établissement row) carries the parentage chain (`delegationCode`, `regionCode`) — that is data the `Person` + facets schema will provide ([proposed ADR-0026](#)).
|
||
|
||
### Principal shape
|
||
|
||
The session-resident `Principal` is what guards read:
|
||
|
||
```ts
|
||
interface Principal {
|
||
readonly user: {
|
||
readonly id: string; // portal `User.id` UUID
|
||
readonly personId: string; // portal `Person.id` UUID (golden record)
|
||
readonly entraOid: string; // Entra `oid` — same value the audit module hashes
|
||
readonly tenantId: string; // Entra `tid`
|
||
readonly displayName: string;
|
||
};
|
||
readonly privileges: ReadonlyArray<Privilege>;
|
||
readonly roles: ReadonlyArray<FunctionalRole>;
|
||
readonly scopes: ReadonlyArray<Scope>;
|
||
readonly amr: ReadonlyArray<string>; // for MFA freshness checks per ADR-0011
|
||
readonly mfaVerifiedAt?: number;
|
||
}
|
||
```
|
||
|
||
Built once at sign-in by the OIDC callback, persisted in Redis as part of the session payload (per [ADR-0010](0010-session-management-redis.md) §"Session payload"), refreshed on every authenticated request from the session ID. Guards read it via the existing `req.session.user` pattern; the legacy `AuthenticatedUser` shape gets extended with the three new fields rather than replaced (so the audit module's `actor.oid` keeps working unchanged).
|
||
|
||
### Projection — internal Principal → AI service Principal
|
||
|
||
[ADR-0024](0024-ai-service-relay-grpc-sse-bridge.md) requires a flat `roles: string[]` for the AI service's chunk-ACL evaluator. The projection collapses the three internal axes into one list:
|
||
|
||
```ts
|
||
function projectForAiService(p: Principal): AiServicePrincipal {
|
||
return {
|
||
subject: hashUserId(p.user.entraOid), // matches audit.events.actor_id_hash
|
||
roles: [
|
||
...p.privileges, // 'Portal.Admin'
|
||
...p.roles, // 'directeur-etablissement', 'collaborateur', …
|
||
...p.scopes.map(scopeToRoleString), // 'etablissement:0330800013', 'delegation:33', …
|
||
],
|
||
attributes: {
|
||
tenantId: p.user.tenantId,
|
||
displayName: p.user.displayName,
|
||
},
|
||
};
|
||
}
|
||
|
||
function scopeToRoleString(s: Scope): string {
|
||
if (s.kind === 'unrestricted' || s.kind === 'siege' || s.kind === 'self') return s.kind;
|
||
return `${s.kind}:${s.value}`;
|
||
}
|
||
```
|
||
|
||
This is the projector referenced as `PrincipalProjector` in the stargate migration analysis. It is **lossy** — the AI service cannot tell the difference between "Directeur of établissement X" and "person tagged with `etablissement:X`-themed access" — but the AI service's chunk-ACL model is itself a string-match, so the projection is sufficient. The portal's own guards never use this flat shape; they keep the three-axis structure.
|
||
|
||
**Inclusive expansion**, the stargate-era mechanism (`Admin → [admin, directeur, rh, collaborateur]`), is **not** applied here. The flat list is a _union_ of axes, not a _hierarchy_. If a future RAG corpus needs role-based chunk visibility (e.g., a document tagged `allowed_roles: ['rh']` should be visible to RH staff), the AI service ingestor tags chunks with the precise role; the projection above puts the user's roles in the principal directly. No expansion needed.
|
||
|
||
### Sources of truth — Entra-side configuration
|
||
|
||
Two configurations live on the Entra app registration:
|
||
|
||
1. **App roles** (one per privilege):
|
||
|
||
```json
|
||
{
|
||
"allowedMemberTypes": ["User"],
|
||
"description": "Portal-wide administrative access (admin app surface).",
|
||
"displayName": "Portal Admin",
|
||
"id": "<guid>",
|
||
"isEnabled": true,
|
||
"value": "Portal.Admin"
|
||
}
|
||
```
|
||
|
||
v1 ships exactly one entry (`Portal.Admin`). The `value` field is what travels in the `roles` claim; the BFF reads from there.
|
||
|
||
2. **Security groups → functional roles**, one Entra security group per role slug, named `apf-role-<slug>` (e.g. `apf-role-rh`, `apf-role-directeur-etablissement`). The app registration's manifest sets:
|
||
|
||
```json
|
||
{
|
||
"groupMembershipClaims": "SecurityGroup",
|
||
"optionalClaims": {
|
||
"idToken": [{ "name": "groups", "essential": false }]
|
||
}
|
||
}
|
||
```
|
||
|
||
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 _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`:
|
||
|
||
```json
|
||
{
|
||
"11111111-1111-1111-1111-111111111111": "collaborateur",
|
||
"22222222-2222-2222-2222-222222222222": "rh"
|
||
}
|
||
```
|
||
|
||
```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
|
||
|
||
```prisma
|
||
model UserScope {
|
||
id String @id @default(uuid())
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id])
|
||
kind String // 'self' | 'etablissement' | 'delegation' | 'region' | 'siege' | 'unrestricted'
|
||
value String // FINESS / dept code / region code; '' for self / siege / unrestricted
|
||
source String // 'pleiades' | 'admin-ui' | 'seed'
|
||
createdAt DateTime @default(now())
|
||
expiresAt DateTime?
|
||
|
||
@@unique([userId, kind, value])
|
||
}
|
||
```
|
||
|
||
Resolution at sign-in:
|
||
|
||
1. OIDC callback receives the `User` (created lazily on first sign-in — per the proposed `Person` + `User` split).
|
||
2. Query `user_scopes WHERE userId = ? AND (expiresAt IS NULL OR expiresAt > NOW())`.
|
||
3. Each row becomes a `Scope { kind, value }` entry on the session principal.
|
||
4. If the user has zero scope rows AND no `collaborateur` role, sign-in succeeds but every guard except `Portal.Admin`'s will deny — the front-end shows an empty-permissions state with a "contact your manager" CTA.
|
||
|
||
### Guard surface
|
||
|
||
Three composable decorators on NestJS controllers, layered on top of the existing `@RequireMfa()` ([ADR-0011](0011-mfa-enforcement-entra-conditional-access.md)) and `@RequireAdmin()` ([ADR-0020](0020-portal-admin-app.md)):
|
||
|
||
| Decorator | Checks |
|
||
| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| `@RequireAdmin()` _(existing)_ | `principal.privileges.includes('Portal.Admin')`. Unchanged. |
|
||
| `@RequirePrivilege('Portal.X')` | Generic version of the above for the future privileges catalogue grows. |
|
||
| `@RequireRole('rh')` _(new)_ | `principal.roles.includes('rh')`. Multiple roles compose as **OR** when stacked. |
|
||
| `@RequireScope(extract: (req) => ResourceLike)` _(new)_ | Receives a function that extracts the protected resource from the request (typically using `@Param`); calls `principalCoversResource(principal, resource)`. |
|
||
| `@RequireMfa({ freshness })` _(existing)_ | Unchanged. Composes with the above. |
|
||
|
||
**Composition semantics.** Decorators on the same handler are **AND**-combined — `@RequireAdmin() @RequireMfa({ freshness: 300 })` requires both. Within `@RequireRole(...)` if multiple slugs are passed, they are **OR**-combined: `@RequireRole('rh', 'directeur-etablissement')` matches anyone with either.
|
||
|
||
**Error envelope.** Failed authorization produces `403 Forbidden` with the [ADR-0021](0021-phase-2-security-baseline.md) structured error envelope: `{ error: { code: 'forbidden', message: '…', traceId } }`. The `message` field is intentionally generic (no role hint) to avoid leaking the authorization surface to unauthorised callers.
|
||
|
||
## Test-tenant personas
|
||
|
||
The 19 personas below exercise every interesting combination of the three axes; together they cover 23 of the 24 functional-role groups and all four privileges. The only intentional gap is `apf-role-partenaire` — placeholder, no consuming surface yet, assignment deferred until the first partner-facing feature lands.
|
||
|
||
The matrix was provisioned in the `apfrd.onmicrosoft.com` test tenant on 2026-05-20. The operator-facing checklist (group → users, copy-paste-friendly for Entra UI) lives at `notes/entra-group-members.md`; this table is the canonical source.
|
||
|
||
| Login (`@apfrd.onmicrosoft.com`) | Privileges | Functional roles | Scopes | Purpose |
|
||
| --------------------------------- | ------------------------------ | ------------------------------------------------------ | ------------------------------------------------------ | ----------------------------------------------------------------------------- |
|
||
| `admin` | `Portal.Admin` | `collaborateur`, `rh` | `unrestricted` | Admin app + cross-cutting reads. Goes through the `portal-admin` SPA. |
|
||
| `directeur-bordeaux` | — | `collaborateur`, `directeur-etablissement` | `etablissement:0330800013` | Single-site Directeur. Cannot read other établissements' data. |
|
||
| `directeur-complexe` | — | `collaborateur`, `directeur-etablissement` | `etablissement:0330800013`, `etablissement:0330800021` | Multi-site Directeur of a "complexe". |
|
||
| `rh-aquitaine` | — | `collaborateur`, `rh`, `formation` | `delegation:33` | Cross-établissement HR + training scoped to one delegation. |
|
||
| `rh-siege` | — | `collaborateur`, `rh`, `responsable-paie`, `comptable` | `unrestricted` | National HR + payroll + accounting (siège-level admin staff). |
|
||
| `collaborateur-simple` | — | `collaborateur` | `self` | Baseline employee. Sees only their own data. |
|
||
| `tresorier-bordeaux` | — | `elu-cd`, `elu-cd-tresorier` | `delegation:33` | Departmental treasurer (governance, not workforce — no `collaborateur`). |
|
||
| `dpo` | `Portal.DPO`, `Portal.Auditor` | `collaborateur`, `dpo`, `qualite` | `unrestricted` | Cross-cutting compliance, quality, and read-only audit access. |
|
||
| `it` | — | `collaborateur`, `it` | `unrestricted` | Internal tech support. |
|
||
| `benevole-aquitaine` | — | `benevole`, `benevole-responsable`, `delegue` | `delegation:33` | Volunteer with leadership rights + local delegate role. |
|
||
| `chef-equipe-bordeaux` | — | `collaborateur`, `chef-equipe` | `etablissement:0330800013` | Team-lead granularity within an établissement. |
|
||
| `chef-service-bordeaux` | — | `collaborateur`, `chef-service` | `etablissement:0330800013` | Service-head granularity within an établissement. |
|
||
| `directeur-territorial-aquitaine` | — | `collaborateur`, `directeur-territorial` | `delegation:33` | Multi-établissement regional director, scope-by-delegation. |
|
||
| `juriste-siege` | — | `collaborateur`, `juriste` | `unrestricted` | Siège-level legal counsel. |
|
||
| `rssi` | `Portal.SecurityOfficer` | `collaborateur`, `rssi` | `unrestricted` | Security officer with the future RSSI-specific dashboard privilege. |
|
||
| `communication-siege` | — | `collaborateur`, `communication` | `unrestricted` | Siège-level communications staff. |
|
||
| `elu-ca-national` | — | `elu-ca` | `siege` | National-board member (governance, no employment). |
|
||
| `president-cd-aquitaine` | — | `elu-cd`, `elu-cd-president` | `delegation:33` | Departmental-council president (top of governance hierarchy in a délégation). |
|
||
| `secretaire-cd-aquitaine` | — | `elu-cd`, `elu-cd-secretaire` | `delegation:33` | Departmental-council secretary (governance counterpart of the treasurer). |
|
||
|
||
### Provisioned in the test tenant (2026-05-20)
|
||
|
||
The four privileges live in the `apfrd.onmicrosoft.com` app registration with the following GUIDs:
|
||
|
||
| Privilege | Entra app role GUID |
|
||
| ------------------------ | -------------------------------------- |
|
||
| `Portal.Admin` | `3e45c572-71f7-4823-92be-43d2a98b5c84` |
|
||
| `Portal.Auditor` | `753815ec-9469-4edd-ae7e-3f29da8a3f75` |
|
||
| `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/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.
|
||
|
||
### Consequences
|
||
|
||
- Good, because every authorization decision is expressible as a check against an explicit `Principal` whose three axes have separate sources of truth. Easy to audit, easy to test.
|
||
- Good, because adding a new functional role is a one-line edit to the catalogue + one Entra group creation + one role assignment for the users who need it. No code-level migration.
|
||
- Good, because the AI relay projection stays mechanical — the `PrincipalProjector` is the only place that knows about the flat shape.
|
||
- Good, because the existing `@RequireAdmin()` decorator keeps its semantics — only its implementation gets thinner (it now checks `principal.privileges` instead of inspecting the raw token).
|
||
- Bad, because the `user_scopes` table is a v1 source of truth without an authoritative upstream feed yet. Manual seeding is sustainable for the test tenant but not for production. Mitigation: the Pléiades sync lands before the production-rollout milestone (proposed ADR-0027 territory).
|
||
- Bad, because the catalogue is closed — adding a new role requires an ADR amendment. The cost is real (each change rides a small PR through review) but the benefit is the no-drift guarantee: every slug a guard mentions is one a human approved.
|
||
- Neutral, because the model does not yet model time-bound roles (e.g. _"interim director for the next two months"_). The `expiresAt` column on `user_scopes` lays the groundwork; a future ADR can extend it to functional roles if needed.
|
||
|
||
### 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/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.
|
||
|
||
## Pros and Cons of the Options
|
||
|
||
### Three orthogonal axes — privileges × functional roles × scopes (chosen)
|
||
|
||
- Good, because each axis has a single authoritative source (Entra app role, Entra group, `user_scopes` table).
|
||
- Good, because the model maps cleanly to APF's actual org structure (functional role × geographic scope) without forcing a linear hierarchy.
|
||
- Good, because the AI-service projection is a deterministic function of the principal — no hidden state.
|
||
- Good, because the catalogue lookup tables make new roles a data change, not a code change.
|
||
- Bad, because three axes is more conceptual weight than two (or one); every new contributor learns the model. Mitigated by the catalogue tables doubling as onboarding documentation.
|
||
- Bad, because the closed catalogue creates friction for ad-hoc roles. Mitigated by ADR amendments being a 10-minute task with a thin PR.
|
||
|
||
### Linear role hierarchy with inclusive expansion (stargate)
|
||
|
||
- Good, because the implementation is trivial (one array per role + a mapper function).
|
||
- Good, because the inclusive expansion plays cleanly into the AI service's chunk-ACL model.
|
||
- Bad, because it does not model scope at all — a Directeur of one site is indistinguishable from a Directeur of every site.
|
||
- Bad, because it asserts a privilege chain that does not exist in APF (an admin is not "more than" a directeur in any meaningful sense).
|
||
- Bad, because adding a new role family (`comptable`, `dpo`, `benevole`) requires shoehorning it into the chain.
|
||
|
||
### Flat role list, no scope (typical SaaS)
|
||
|
||
- Good, because trivial to implement and reason about for a small SaaS where every user has access to "their workspace" only.
|
||
- Bad, because APF has no concept of a "workspace" per user — the data is shared, with delegation- and établissement-scoped visibility. A flat role list cannot express that without conflating role and scope.
|
||
- Bad, because functional and structural roles get mashed into the same list — `rh-aquitaine` becomes a separate role from `rh-siege`, multiplying the catalogue by every delegation.
|
||
|
||
### Full ABAC with an external policy engine (Cedar, OPA, Rego)
|
||
|
||
- Good, because maximally general — any authorization decision can be expressed as a policy.
|
||
- Good, because policies are auditable artefacts external to code.
|
||
- Bad, because adding a policy engine introduces a new runtime dependency, a new policy-authoring DSL, and a new operational story (policy bundle publishing, version pinning). Disproportionate to v1's scope.
|
||
- Bad, because the team has no ABAC experience; ramp time is non-trivial.
|
||
- Neutral, because the chosen three-axis model **can evolve into ABAC** later — the `Principal` shape already encodes the attributes a Cedar/OPA policy would consume. If a v2 use case justifies ABAC, the transition is "publish policy bundles, replace guard implementation, keep `Principal` unchanged".
|
||
|
||
## Open question — ABAC migration path
|
||
|
||
When (and only when) a v2 use case demands cross-cutting policies that the three-axis model cannot express compactly — e.g. "users who managed an établissement at any point in the last 2 years can read its archived audit log" — the chosen migration is:
|
||
|
||
1. Add the missing attribute(s) to `Principal` (e.g. `historicalEtablissements: string[]`).
|
||
2. Replace the affected guard's body with a Cedar / OPA call; keep its decorator surface (`@RequireScope`, etc.) identical so call sites do not change.
|
||
3. Source the policy bundle from a new repo or sub-folder.
|
||
|
||
This door is recorded here so a future contributor does not feel they have to rewrite authorization to introduce one ABAC-shaped rule. The two models coexist behind the decorator API.
|
||
|
||
## 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/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).
|
||
- **Related ADRs:**
|
||
- [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) — identity provider choice.
|
||
- [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) — auth flow; populates the raw claims this ADR consumes.
|
||
- [ADR-0010](0010-session-management-redis.md) — session payload extended with the new `Principal` fields.
|
||
- [ADR-0011](0011-mfa-enforcement-entra-conditional-access.md) — `@RequireMfa` composes with this ADR's decorators.
|
||
- [ADR-0013](0013-audit-trail-separated-postgres-append-only.md) — `admin.access_denied` event family captures `403`s from the new guards.
|
||
- [ADR-0020](0020-portal-admin-app.md) — `Portal.Admin` privilege already in place; this ADR formalises it as one privilege in a catalogue.
|
||
- [ADR-0024](0024-ai-service-relay-grpc-sse-bridge.md) — `PrincipalProjector` produces the AI-service-facing flat shape.
|
||
- **Proposed follow-up ADR — ADR-0026 — `Person` golden record + `User` accounts + facets**. Specifies the `Person` / `User` / `Salarie` / `Adherent` / … Prisma schema. The `user_scopes` table introduced here is its companion; both should land in coordinated PRs.
|
||
- **Proposed follow-up ADR — ADR-0027 — Pléiades sync** (preliminary title). Specifies how `user_scopes` is populated from Pléiades, the sync cadence, and the conflict resolution between Pléiades data and admin-UI overrides.
|