docs(adr-0025): authorization model — privileges × roles × scopes #201

Merged
julien merged 1 commits from docs/adr-0025-authorization-model into main 2026-05-20 18:42:47 +02:00
Owner

Summary

Proposes ADR-0025 — 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.

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

  • prettier --check docs/decisions/0025-authorization-model-privileges-roles-scopes.md — passes (hook ran on commit).
  • Markdown links inside the ADR resolve (0008, 0009, 0010, 0011, 0013, 0020, 0021, 0024, plus the proposed ADR-0026 / ADR-0027 placeholders).
  • Index row in docs/decisions/README.md follows the table's existing format.
  • 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.

## 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.
julien added 1 commit 2026-05-20 18:40:32 +02:00
docs(adr-0025): authorization model — privileges × roles × scopes
CI / a11y (pull_request) Successful in 3m22s
Docs site / build (pull_request) Successful in 3m29s
CI / perf (pull_request) Successful in 6m35s
CI / scan (pull_request) Successful in 3m10s
CI / commits (pull_request) Successful in 3m26s
CI / check (pull_request) Successful in 3m37s
a7dfd83571
Proposes a three-orthogonal-axis authorization model:

- Privileges (Entra app roles): coarse portal-level capabilities.
  v1 catalogue ships one entry, Portal.Admin.
- Functional roles (Entra security groups mapped to a curated
  slug catalogue): what someone does in APF. 22 v1 entries grouped
  into workforce / governance / volunteer / external.
- Scopes (apf_portal-side user_scopes table, future Pléiades
  feed): where a role applies. 6 v1 kinds (self / etablissement /
  delegation / region / siege / unrestricted).

Replaces stargate's linear hierarchy (Admin ⊃ Directeur ⊃ RH ⊃
Collaborateur) which conflated portal privilege with business
role and ignored geographic scope entirely.

Specifies the Entra-side setup (one app role + one security
group per functional role), the Principal shape consumed by
portal guards, and the deterministic projection to ADR-0024's
flat-roles[] AI-service contract. Ten test-tenant personas
documented for the user to provision in the freshly available
test tenant.

Status: proposed. Implementation phasing recorded in §"More
Information" — types + Principal builder, guards, drift CI gate,
test-tenant seed, in that order.
julien merged commit ef5073de8a into main 2026-05-20 18:42:47 +02:00
julien deleted branch docs/adr-0025-authorization-model 2026-05-20 18:42:49 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#201