985b0cb5d3
CI / scan (pull_request) Failing after 2m31s
CI / commits (pull_request) Successful in 2m56s
CI / check (pull_request) Successful in 3m10s
CI / a11y (pull_request) Successful in 2m46s
Docs site / build (pull_request) Successful in 2m56s
CI / perf (pull_request) Successful in 7m6s
The test tenant (apfrd.onmicrosoft.com) has been provisioned with the full role / user matrix. ADR-0025 was accepted as the implementation reference; this commit aligns the document with what is actually in place. Changes: - Frontmatter status: proposed -> accepted; date unchanged (decision was already final on 2026-05-20). - Privilege catalogue: expanded from 1 to 4 entries. Portal.Auditor, Portal.SecurityOfficer and Portal.DPO were anticipated future entries; they are now provisioned in the test tenant alongside Portal.Admin and ride the catalogue while their consuming surfaces land in subsequent PRs. - Test-tenant personas: rewritten from 10 to 19 entries so every one of the 24 functional-role groups carries at least one user (apf-role-partenaire remains intentionally empty as a placeholder per the existing rationale). The four privileges each have an assigned user. - New 'Provisioned in the test tenant' subsection records the Entra app-role GUIDs (kept in-repo because they are stable IDs the implementation will reference; group GUIDs stay gitignored). - README.md index: status row flipped to accepted. - CLAUDE.md: roll-up extended from '0001 -> 0024 accepted' to '0001 -> 0025 accepted', Architecture list grows an 'Authorization model' bullet.
410 lines
39 KiB
Markdown
410 lines
39 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/feature/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 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:
|
||
|
||
```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',
|
||
// …
|
||
};
|
||
```
|
||
|
||
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.
|
||
|
||
### 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/feature/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/feature/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/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.
|
||
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.
|