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