## Summary
Proposes [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md) — the portal's general-purpose authorization model. **Status: `proposed`.** No code in this PR; the goal is to lock the model and the v1 catalogues before the implementation chantier opens.
The model rejects stargate's linear hierarchy (`Admin ⊃ Directeur ⊃ RH ⊃ Collaborateur`) and adopts **three orthogonal axes**:
| Axis | What it carries | Source of truth |
|---|---|---|
| **Privileges** | Portal-level capabilities (`Portal.Admin`, future `Portal.Auditor`, …) | Entra app roles, `roles` claim |
| **Functional roles** | What someone does in APF (`rh`, `directeur-etablissement`, `elu-cd-tresorier`, …) | Entra security groups, `groups` claim → curated slug catalogue |
| **Scopes** | Where a role applies (`etablissement:0330800013`, `delegation:33`, `unrestricted`, …) | apf_portal-side `user_scopes` table (v1) ; future Pléiades feed |
The three axes compose at sign-in into a session-resident `Principal`. The portal's guards consume the structured shape; a deterministic projector flattens it to the `roles[]` list that `apf-ai-service` expects per [ADR-0024](docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md).
## What lands
- `docs/decisions/0025-authorization-model-privileges-roles-scopes.md` — full MADR with context, decision drivers, four considered options, exhaustive v1 catalogues, Entra-side configuration, `Principal` shape, AI-service projection, guard surface, ten test-tenant personas, consequences, confirmation criteria, pros/cons per option, ABAC migration path, related ADRs, and proposed follow-up ADRs.
- `docs/decisions/README.md` — index row for ADR-0025 (`proposed`, tags `security, backend, data`, 2026-05-20).
No `CLAUDE.md` update — ADR stays in `proposed` until reviewed; the accepted-ADRs roll-up at the top of `CLAUDE.md` stays at 0001 → 0024. Promotion to `accepted` lands in the same PR that ships the implementation skeleton (`libs/feature/auth` extension with `Principal` builder + catalogues).
## Highlights worth review focus
- **Privilege catalogue is intentionally minimal**: only `Portal.Admin` in v1. Anticipated future entries (`Portal.Auditor`, `Portal.SecurityOfficer`, `Portal.DPO`) are mentioned in the ADR but not formalised — each one rides an amendment ADR.
- **Functional-role catalogue is closed-set, 22 entries** grouped into workforce (15), governance (6), volunteer (2), external (1, placeholder). Adding a new slug requires an ADR amendment. The CI drift gate (proposed in §"Confirmation") asserts no orphan `@RequireRole('x')` literal in code.
- **Scope kinds are also closed-set**: `self`, `etablissement:<finess>`, `delegation:<dept>`, `region:<insee>`, `siege`, `unrestricted`. The `value` carriers are documented (FINESS code rather than internal `etablissement.id` because FINESS is stable across reorgs).
- **`Principal` shape** is the contract for everything downstream. Documented field-by-field. Built once at sign-in, persisted in the Redis session, refreshed on every authenticated request.
- **`PrincipalProjector` for the AI service** is mechanical: union of privileges + roles + scope-strings, no inclusive expansion. The projector is the only seam that knows about the flat shape; the rest of the portal never touches it.
- **Closed-vs-open catalogue trade-off** spelled out: the friction of "every new role rides an ADR amendment" is the price of "every slug in code is one a human approved". The drift CI gate enforces the discipline.
- **ABAC migration path** documented so a future contributor does not feel they must rewrite authorization to introduce a single Cedar/OPA-shaped rule.
## Test-tenant personas
The ADR proposes ten test users covering every interesting combination of the three axes. Table in §"Test-tenant personas" of the ADR; here's the summary the user can act on:
| Login | Privileges | Functional roles | Scopes |
|---|---|---|---|
| `admin@<tenant>` | `Portal.Admin` | `collaborateur`, `rh` | `unrestricted` |
| `directeur-bordeaux@<tenant>` | — | `directeur-etablissement`, `collaborateur` | `etablissement:0330800013` |
| `directeur-complexe@<tenant>` | — | `directeur-etablissement`, `collaborateur` | two `etablissement:*` scopes |
| `rh-aquitaine@<tenant>` | — | `rh`, `collaborateur` | `delegation:33` |
| `rh-siege@<tenant>` | — | `rh`, `responsable-paie`, `collaborateur` | `unrestricted` |
| `collab-simple@<tenant>` | — | `collaborateur` | `self` |
| `tresorier-bordeaux@<tenant>` | — | `elu-cd-tresorier`, `elu-cd` | `delegation:33` |
| `dpo@<tenant>` | — | `dpo`, `collaborateur` | `unrestricted` |
| `it@<tenant>` | — | `it`, `collaborateur` | `unrestricted` |
| `benevole-aquitaine@<tenant>` | — | `benevole`, `benevole-responsable` | `delegation:33` |
**Entra test-tenant setup the user needs to provision after acceptance**:
1. One app role: `Portal.Admin` (likely already there from the existing dev tenant; document the GUID in `apps/portal-bff/.env.test`).
2. **22 security groups**, one per functional-role slug in the catalogue: `apf-role-collaborateur`, `apf-role-chef-equipe`, `apf-role-chef-service`, `apf-role-directeur-etablissement`, `apf-role-directeur-territorial`, `apf-role-rh`, `apf-role-responsable-paie`, `apf-role-comptable`, `apf-role-juriste`, `apf-role-dpo`, `apf-role-rssi`, `apf-role-it`, `apf-role-formation`, `apf-role-qualite`, `apf-role-communication`, `apf-role-elu-ca`, `apf-role-elu-cd`, `apf-role-elu-cd-president`, `apf-role-elu-cd-tresorier`, `apf-role-elu-cd-secretaire`, `apf-role-delegue`, `apf-role-benevole`, `apf-role-benevole-responsable`, `apf-role-partenaire`.
3. The ten test users with the membership matrix above.
4. App registration manifest tweak: `groupMembershipClaims: 'SecurityGroup'` + `optionalClaims.idToken: [{ name: 'groups' }]` so the BFF sees the memberships in the ID token.
GUIDs and credentials stay in the operator's hands (out of git). When the user has provisioned the tenant, drop the GUIDs into `infra/test-tenant.entra.json` (gitignored) and the implementation PR wires `libs/feature/auth/src/lib/entra-group-to-role.ts` against them.
## Notes for the reviewer
- **Why not just extend stargate's `RoleMapper`.** The mapper's inclusive expansion (`Admin → [admin, directeur, rh, collaborateur]`) bakes in the wrong assumption — that roles form a chain. Reusing it would force every new role into the chain too. The three-axis model has no such forcing function.
- **Why `Person` is conspicuously absent here.** Authorization is keyed on the portal-side `User` account (Entra OID, session). The proposed `Person` + `User` split lands in a sibling ADR (proposed: ADR-0026) because the two decisions have different audiences — auth model is a backend/security concern; golden record is a data/domain concern. They will land in coordinated PRs.
- **Why FINESS rather than internal UUID for `etablissement:*` scopes.** FINESS codes are the canonical APF identifier for an établissement, stable across the internal-database churn (etablissement merges, reorgs, system migrations). Using the FINESS as the scope value means scope strings stay readable, debuggable, and stable when an établissement gets a new internal `id` after a Prisma migration.
- **Why no time-bound roles in v1.** APF does have interim assignments (acting Directeur for two months while the permanent one is on leave). The `user_scopes` table already has `expiresAt` to lay the groundwork; extending the *role* axis with time bounds is a future ADR amendment when a concrete use case lands.
- **Coordination with apf-ai-service.** The PrincipalProjector spec here matches exactly what `apf-ai-service`'s RBAC matrix tests expect (each chunk's ACL is a string-match against `Principal.roles[]`). The ADR explicitly notes that the projector is the only place that knows about the flat shape — keeping the AI-side contract honoured without polluting the portal-side guards.
## Test plan
- [x] `prettier --check docs/decisions/0025-authorization-model-privileges-roles-scopes.md` — passes (hook ran on commit).
- [x] Markdown links inside the ADR resolve (`0008`, `0009`, `0010`, `0011`, `0013`, `0020`, `0021`, `0024`, plus the proposed ADR-0026 / ADR-0027 placeholders).
- [x] Index row in `docs/decisions/README.md` follows the table's existing format.
- [x] No tag-vocabulary additions required — `security`, `backend`, `data` are all in the existing vocab.
- [ ] **Review focus** — the v1 catalogues (privileges + 22 functional roles + 6 scope kinds), the `Principal` shape, the projection contract for the AI service, and the ten test personas. Catalogue closures are deliberate; raising the lid requires an amendment so the v1 list deserves a careful pass.
## What's next (once accepted)
The implementation phasing recorded in the ADR's §"More Information":
1. **PR — types + Principal builder + Entra mapping skeleton**. Lands `libs/feature/auth/src/lib/authorization.types.ts` (catalogue constants), `entra-group-to-role.ts` (slug map), and the OIDC callback hook that extends `req.session.user` with `privileges` / `roles` / `scopes`. No new guards yet.
2. **PR — `@RequireRole` + `@RequireScope` decorators + guard tests**. Stub principal in unit tests; real session in e2e.
3. **PR — drift CI gate**. ESLint custom rule or `pnpm run` script: every `@RequireRole('...')` / `@RequirePrivilege('...')` / scope literal must exist in the catalogue constants.
4. **PR — test-tenant seed**. `prisma/seed.ts` populating the ten personas' `user_scopes` rows. Depends on the `Person` + `User` schema PR landing first.
In parallel, the user provisions the test tenant per the §"Test-tenant personas" instructions above.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #201
36 KiB
status, date, decision-makers, tags
| status | date | decision-makers | tags | |||
|---|---|---|---|---|---|---|
| proposed | 2026-05-20 | R&D Lead |
|
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, session storage in Redis per ADR-0010, the workforce identity model per ADR-0008. The first authorization gate exists too: Portal.Admin Entra app role + AdminRoleGuard per ADR-0020. 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:
- 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).
- The model has no notion of scope. "Directeur" without an
etablissement_idis 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. Adminis 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
Principalwhose 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's
Principal { subject, roles[], attributes{} }proto message expects a flat list of role strings. The internal three-axisPrincipalmust 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.
v1 catalogue.
| Privilege | Surface gated | Status |
|---|---|---|
Portal.Admin |
portal-admin app (CMS, menus, user list, audit viewer) |
already in place — ADR-0020 |
That is the entire v1 catalogue. New privileges are added by ADR amendment + a one-line Entra manifest change. Anticipated near-future entries (recorded for context, not yet defined):
Portal.Auditor— read-only access to the audit viewer for compliance staff who should not write to anything.Portal.SecurityOfficer— RSSI-specific dashboards (incident timeline, vuln scanner output, key rotation status).Portal.DPO— DPO-specific surface (data subject requests, retention exceptions).
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:
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:
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 §"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 requires a flat roles: string[] for the AI service's chunk-ACL evaluator. The projection collapses the three internal axes into one list:
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:
-
App roles (one per privilege):
{ "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). Thevaluefield is what travels in therolesclaim; the BFF reads from there. -
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:{ "groupMembershipClaims": "SecurityGroup", "optionalClaims": { "idToken": [{ "name": "groups", "essential": false }] } }so the ID token includes the user's group GUIDs in the
groupsclaim. 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:
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
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:
- OIDC callback receives the
User(created lazily on first sign-in — per the proposedPerson+Usersplit). - Query
user_scopes WHERE userId = ? AND (expiresAt IS NULL OR expiresAt > NOW()). - Each row becomes a
Scope { kind, value }entry on the session principal. - If the user has zero scope rows AND no
collaborateurrole, sign-in succeeds but every guard exceptPortal.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) and @RequireAdmin() (ADR-0020):
| 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 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 user has a fresh test tenant they can configure freely. Below is the minimal set of test users that exercises every interesting combination of the three axes. The user can extend the list later — these ten cover the v1 guard surface.
| Login | Privileges | Functional roles | Scopes | Purpose |
|---|---|---|---|---|
admin@<tenant> |
Portal.Admin |
collaborateur, rh |
unrestricted |
Admin app + cross-cutting reads. Goes through the portal-admin SPA. |
directeur-bordeaux@<tenant> |
— | directeur-etablissement, collaborateur |
etablissement:0330800013 |
Single-site Directeur. Cannot read other établissements' data. |
directeur-complexe@<tenant> |
— | directeur-etablissement, collaborateur |
etablissement:0330800013, etablissement:0330800021 |
Multi-site Directeur of a "complexe". |
rh-aquitaine@<tenant> |
— | rh, collaborateur |
delegation:33 |
Cross-établissement HR scoped to one delegation. |
rh-siege@<tenant> |
— | rh, responsable-paie, collaborateur |
unrestricted |
National HR. |
collab-simple@<tenant> |
— | collaborateur |
self |
Baseline employee. Sees only their own data. |
tresorier-bordeaux@<tenant> |
— | elu-cd-tresorier, elu-cd |
delegation:33 |
Departmental treasurer (governance, not workforce). |
dpo@<tenant> |
— | dpo, collaborateur |
unrestricted |
Cross-cutting compliance. v1 just collaborateur + dpo — Portal.DPO privilege when the surface exists. |
it@<tenant> |
— | it, collaborateur |
unrestricted |
Internal tech support. |
benevole-aquitaine@<tenant> |
— | benevole, benevole-responsable |
delegation:33 |
Volunteer with leadership rights inside one delegation. |
Entra setup the user needs to run (test tenant only):
- Create one app role:
Portal.Admin(already in place; document the GUID inapps/portal-bff/.env.test). - Create one Entra security group per functional role in the catalogue above (
apf-role-collaborateur,apf-role-rh, …). Capture the GUIDs in a fresh fileinfra/test-tenant.entra.json(gitignored — sensitive) and map them inlibs/feature/auth/src/lib/entra-group-to-role.ts. - Create the 10 test users above; assign group memberships per the matrix; assign
Portal.Adminapp role to theadmin@<tenant>user only. - Seed
user_scopesrows for each test user via a futureprisma/seed.ts(lands with thePerson+Userschema PR; not in this ADR).
The Entra group GUIDs and the user passwords stay in the operator's hands (out of git). The mapping file references them by name only.
Consequences
- Good, because every authorization decision is expressible as a check against an explicit
Principalwhose 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
PrincipalProjectoris 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 checksprincipal.privilegesinstead of inspecting the raw token). - Bad, because the
user_scopestable 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
expiresAtcolumn onuser_scopeslays 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 mirrorsapf-ai-service/tests/Apf.Ai.Tests/Rbac/RbacMatrix.cs. - Catalogue-vs-code drift check in CI: a small ESLint custom rule (or a
pnpm runscript) greps every@RequireRole('...')/@RequirePrivilege('...')/ scope literal in the codebase and asserts each one exists in the catalogue constants exported fromlibs/feature/auth/src/lib/authorization.types.ts. Fails the build on drift. - Audit-event linkage: every
403 Forbiddenfrom@RequireRole/@RequireScopewrites anadmin.access_deniedrow (per ADR-0013 §"v1 events"), with the missing role/scope in the payload. Auditors can spot privilege-escalation attempts by pivoting onoutcome=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_scopestable). - 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-aquitainebecomes a separate role fromrh-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
Principalshape 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, keepPrincipalunchanged".
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:
- Add the missing attribute(s) to
Principal(e.g.historicalEtablissements: string[]). - Replace the affected guard's body with a Cedar / OPA call; keep its decorator surface (
@RequireScope, etc.) identical so call sites do not change. - 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:
- PR —
Authorizationtypes + Principal builder +entra-group-to-rolemapping skeleton. Landslibs/feature/auth/src/lib/authorization.types.ts(the catalogues) and the OIDC callback hook that populates the newprivileges/roles/scopesfields on the session principal. No new guards yet. - PR —
@RequireRole+@RequireScopedecorators + guard tests. Adds the guards against a stubbed principal; integration with the actual session lands in the same PR. - PR — Drift CI gate. ESLint rule (or
pnpm runscript) that asserts every role / privilege / scope literal in the codebase is in the catalogue. - PR — Test-tenant seed.
prisma/seed.tspopulating the 10 test personas'user_scopesrows. Depends on thePerson+Userschema landing first (proposed ADR-0026).
- PR —
- Related ADRs:
- ADR-0008 — identity provider choice.
- ADR-0009 — auth flow; populates the raw claims this ADR consumes.
- ADR-0010 — session payload extended with the new
Principalfields. - ADR-0011 —
@RequireMfacomposes with this ADR's decorators. - ADR-0013 —
admin.access_deniedevent family captures403s from the new guards. - ADR-0020 —
Portal.Adminprivilege already in place; this ADR formalises it as one privilege in a catalogue. - ADR-0024 —
PrincipalProjectorproduces the AI-service-facing flat shape.
- Proposed follow-up ADR — ADR-0026 —
Persongolden record +Useraccounts + facets. Specifies thePerson/User/Salarie/Adherent/ … Prisma schema. Theuser_scopestable 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_scopesis populated from Pléiades, the sync cadence, and the conflict resolution between Pléiades data and admin-UI overrides.