Files
apf_portal/docs/decisions/0025-authorization-model-privileges-roles-scopes.md
T
julien c9a1e195fe
CI / check (push) Successful in 2m0s
CI / commits (push) Has been skipped
CI / scan (push) Failing after 1m54s
CI / a11y (push) Successful in 1m44s
CI / perf (push) Successful in 3m49s
Docs site / build (push) Successful in 2m45s
docs(adr-0025): promote to accepted + sync persona matrix with test tenant (#205)
## Summary

ADR-0025 was merged as `proposed` in #201. The test tenant (`apfrd.onmicrosoft.com`) has since been provisioned with the full role / user matrix — 4 privileges + 24 security groups + 19 users with all assignments per the persona table. The ADR is now the implementation reference, so this PR:

1. Promotes the ADR from `proposed` to `accepted`.
2. Syncs the document with what is actually in place — the privilege catalogue grows from 1 to 4 entries (the previously "anticipated future" privileges that the test tenant already has), and the persona matrix grows from 10 to 19 entries (so every one of the 24 functional-role groups has at least one member, closing the gap that prompted `notes/test-tenant-role-assignments.md` and `notes/entra-group-members.md`).
3. Records the Entra app-role GUIDs in a new "Provisioned in the test tenant" subsection for traceability — the GUIDs are stable IDs the implementation will need.
4. Updates the index + the `CLAUDE.md` roll-up.

No code changes. No implementation skeleton — that lands in the next PR (proposed: `feat(libs/feature/auth): authorization types + Principal builder skeleton`).

## What lands

| File | Change |
|---|---|
| `docs/decisions/0025-authorization-model-privileges-roles-scopes.md` | Frontmatter `status: proposed → accepted`; privilege catalogue extended from 1 to 4 entries; persona matrix rewritten from 10 to 19 entries; new `Provisioned in the test tenant (2026-05-20)` subsection capturing the four app-role GUIDs. |
| `docs/decisions/README.md` | 0025 row: `proposed → accepted`. |
| `CLAUDE.md` | Roll-up `0001 → 0024 accepted` → `0001 → 0025 accepted`; Architecture list grows an "Authorization model" bullet. |

The two operator-facing notes files (`notes/test-tenant-role-assignments.md`, `notes/entra-group-members.md`) are gitignored and unchanged — they served their purpose during tenant provisioning and remain as runbooks.

## Notes for the reviewer

- **Why promote now rather than couple with the first implementation PR (the pattern from #194#195#196).** The Entra-side provisioning *is* the implementation for this ADR — the next PR is a portal-side reflection of decisions that are already concrete in the tenant. Promoting now keeps the document honest about what the test tenant runs against.
- **Why all 4 privileges enter the v1 catalogue.** The ADR originally shipped `Portal.Admin` as the sole v1 entry and listed the other three under "anticipated near-future entries". The test tenant has all four; the catalogue should match. The three new entries are explicitly marked "provisioned; consumer surface deferred" so a reader does not look for non-existent surfaces.
- **Why 19 personas, not the cleaner 24 (one per role).** Several APF jobs genuinely combine multiple roles (RH siège often handles paie + compta; DPO often wears the quality officer hat; local delegates often grow out of volunteer roles). Densifying these existing personas is more faithful to real APF org structure than inventing 14 single-role test users. Distinct personas were created where the *scenario* is distinct (scope variations, governance positions) — see the matrix in the ADR for the breakdown.
- **Why `apf-role-partenaire` stays empty in v1.** Placeholder per the original ADR; no consuming surface to test against. The group exists in Entra so the schema is locked, but a user assignment without a guard to exercise would be theatre. The first partner-facing feature adds the user.
- **GUIDs in the ADR.** The four app-role GUIDs are repo-stable identifiers; recording them in the ADR keeps the document self-sufficient when a future contributor opens it without access to the Entra portal. The 24 functional-role group GUIDs are tenant-specific and stay in a gitignored `infra/test-tenant.entra.json` once the implementation PR creates it — referenced by name only in `libs/feature/auth/src/lib/entra-group-to-role.ts`.
- **No `prettier --write` damage.** The persona matrix is a wide table; Prettier sometimes reflows wide markdown tables. The diff is clean — Prettier left the table intact on this run.

## 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 still resolve (`0020`, references to other ADRs unchanged).
- [x] Status row in `docs/decisions/README.md` reflects `accepted`.
- [x] `CLAUDE.md` roll-up line + Architecture list updated; no other instances of "0024" needed bumping.
- [ ] **Review focus** — the expanded privilege catalogue (4 entries, one of them already had a guard, three new ones documented), the 19-entry persona matrix, the "Provisioned in the test tenant" subsection (especially the GUIDs — make sure none was mistyped from `notes/role-user.txt`).

## What's next

With ADR-0025 accepted, the implementation phasing recorded in its `§More Information` opens:

1. **PR — `libs/feature/auth` extension** : `authorization.types.ts` (catalogue constants for the 4 privileges + 24 functional roles + 6 scope kinds), `entra-group-to-role.ts` (slug map skeleton with placeholder GUIDs ; the operator drops real GUIDs into `infra/test-tenant.entra.json` separately), `Principal` builder hook on the OIDC callback, no new guards yet.
2. **PR — `@RequireRole` + `@RequireScope` decorators + guard tests** : real composition tests against the 19 personas.
3. **PR — drift CI gate** : ESLint custom rule asserting every `@RequireRole('...')` literal in code is in the catalogue.
4. **PR — `prisma/seed.ts` for `user_scopes`** : depends on the `Person` + `User` schema (proposed ADR-0026).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #205
2026-05-23 17:42:57 +02:00

39 KiB
Raw Blame History

status, date, decision-makers, tags
status date decision-makers tags
accepted 2026-05-20 R&D Lead
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, 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:

  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'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 axesprivileges, 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
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:

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:

  1. 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). 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:

    {
      "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:

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:

  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) 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 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 §"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 — identity provider choice.
    • ADR-0009 — auth flow; populates the raw claims this ADR consumes.
    • ADR-0010 — session payload extended with the new Principal fields.
    • ADR-0011@RequireMfa composes with this ADR's decorators.
    • ADR-0013admin.access_denied event family captures 403s from the new guards.
    • ADR-0020Portal.Admin privilege already in place; this ADR formalises it as one privilege in a catalogue.
    • ADR-0024PrincipalProjector 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.