docs(adr-0025): promote to accepted + sync persona matrix with test tenant (#205)
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

## 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
This commit was merged in pull request #205.
This commit is contained in:
2026-05-23 17:42:57 +02:00
parent 82d4245ab7
commit c9a1e195fe
3 changed files with 47 additions and 30 deletions
+2 -1
View File
@@ -53,13 +53,14 @@ The structural, security, observability, and quality choices are recorded as ADR
- **Documentation site:** `docs/**/*.md` rendered as a separate static site via **VitePress** (Vite-based, Node-only toolchain, Markdown-first). Mermaid diagrams via `vitepress-plugin-mermaid`. Deployed on its own hostname behind the shared reverse-proxy; CI hook on `docs/` changes rebuilds + publishes. Decoupled from the apps — content lives in `docs/`, no in-app Markdown viewer — see [ADR-0022](docs/decisions/0022-docs-site-vitepress.md).
- **Charts + dashboards:** `D3 + Observable Plot` wrapped in `libs/shared/charts/`, one Angular component per chart type (bar, donut, line, stacked-bar, …). A11y baked in by the lib (SVG `<title>`/`<desc>`, `<details>` tabular fallback, colour-blind-safe palettes, AA-contrast text, `prefers-reduced-motion` gate). Bundle stays under [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md)'s lazy-chunk cap via per-`d3-*` module tree-shaking. Future bespoke visualisations land in raw D3 inside the same lib — see [ADR-0023](docs/decisions/0023-charts-d3-observable-plot.md).
- **AI service relay:** dedicated `apf-ai-service` repo (ASP.NET Core, Microsoft Agent Framework) consumed via native gRPC HTTP/2 only — proto contract vendored under `apps/portal-bff/src/grpc/proto/apf-ai/` with `ts-proto` codegen committed alongside. BFF dials with `@grpc/grpc-js` (h2c in dev, h2 + TLS in prod), bridges `ChatService.Chat` to `text/event-stream` for the SPA, exposes `RagService.Search` and `ModelsService.ListModels` as plain JSON endpoints. Identity travels as an unsigned `Principal` (subject, roles, attributes) in the proto body for the POC, hashed via the audit module's `HashUserIdService` so portal and AI service audit trails join on the same `actor_id_hash`. Production hardening (signed envelope vs mTLS) deferred — see [ADR-0024](docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md).
- **Authorization model:** three orthogonal axes — **privileges** (Entra app roles, `Portal.*`), **functional roles** (Entra security groups → curated `apf-role-*` slug catalogue, 24 entries v1), **scopes** (portal-side `user_scopes` table, future Pléiades feed; kinds = `self / etablissement:<finess> / delegation:<dept> / region:<insee> / siege / unrestricted`). Composed at sign-in into a session-resident `Principal`; portal guards consume the structured shape, a deterministic `PrincipalProjector` flattens it to the AI-service `roles[]` contract. Replaces stargate's linear hierarchy. Catalogues are closed-set, drift gated by CI — see [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
- **Runtime:** Node.js latest LTS major.
## Repository status
The Nx workspace is **scaffolded and operational**. The three apps (`portal-shell`, `portal-admin`, `portal-bff`) and the four lib roots (`libs/feature/`, `libs/shared/state`, `libs/shared/tokens`, `libs/shared/ui`, `libs/shared/util`) are in place; CI runs `format:check / lint / test / build` on every PR.
ADRs 0001 → 0024 are accepted and cover the structural, security, observability, quality, i18n, admin-app, docs-site, charts, and AI-relay choices. **Shipped on `main`:**
ADRs 0001 → 0025 are accepted and cover the structural, security, observability, quality, i18n, admin-app, docs-site, charts, AI-relay, and authorization choices. **Shipped on `main`:**
- **Phase-1 foundation** — Nx workspace, Angular `portal-shell`, NestJS `portal-bff`, Prisma + Postgres, Pino + OpenTelemetry, Husky/lint-staged/commitlint, Gitea Actions CI.
- **Phase-2 auth + audit + security** — OIDC Auth Code + PKCE via MSAL Node, Redis sessions with AES-256-GCM at rest, idle 30 min sliding + absolute 12 h hard ceiling, RP-initiated logout, double-submit CSRF, `audit.events` append-only schema with role-based grants, helmet + env-driven CORS allowlist + rate limiting + structured error envelope (see [ADR-0021](docs/decisions/0021-phase-2-security-baseline.md)).
@@ -1,5 +1,5 @@
---
status: proposed
status: accepted
date: 2026-05-20
decision-makers: R&D Lead
tags: [security, backend, data]
@@ -49,14 +49,13 @@ Chosen: **three orthogonal axes** — `privileges`, `functional roles`, `scopes`
**v1 catalogue.**
| Privilege | Surface gated | Status |
| -------------- | -------------------------------------------------------- | ------------------------------------------------------- |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| `Portal.Admin` | `portal-admin` app (CMS, menus, user list, audit viewer) | already in place — [ADR-0020](0020-portal-admin-app.md) |
| `Portal.Auditor` | Read-only access to the audit viewer for compliance staff who should not write to anything (surface lands later). | provisioned in test tenant; consumer surface deferred |
| `Portal.SecurityOfficer` | RSSI-specific dashboards (incident timeline, vuln scanner output, key rotation status — surface lands later). | provisioned in test tenant; consumer surface deferred |
| `Portal.DPO` | DPO-specific surface (data subject requests, retention exceptions — surface lands later). | provisioned in test tenant; consumer surface deferred |
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).
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.
@@ -290,29 +289,46 @@ Three composable decorators on NestJS controllers, layered on top of the existin
## 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.
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.
| 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. |
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.
**Entra setup the user needs to run** (test tenant only):
| 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). |
1. Create one app role: `Portal.Admin` (already in place; document the GUID in `apps/portal-bff/.env.test`).
2. Create one Entra security group per functional role in the catalogue above (`apf-role-collaborateur`, `apf-role-rh`, …). Capture the GUIDs in a fresh file `infra/test-tenant.entra.json` (gitignored — sensitive) and map them in `libs/feature/auth/src/lib/entra-group-to-role.ts`.
3. Create the 10 test users above; assign group memberships per the matrix; assign `Portal.Admin` app role to the `admin@<tenant>` user only.
4. Seed `user_scopes` rows for each test user via a future `prisma/seed.ts` (lands with the `Person` + `User` schema PR; not in this ADR).
### Provisioned in the test tenant (2026-05-20)
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.
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
+1 -1
View File
@@ -68,4 +68,4 @@ ADRs are listed in numerical order. To slice by topic, filter on the `Tags` colu
| [0022](0022-docs-site-vitepress.md) | Documentation site — VitePress + Mermaid plugin, separate static deployment | accepted | `process`, `infrastructure` | 2026-05-15 |
| [0023](0023-charts-d3-observable-plot.md) | Charts + dashboards — D3 + Observable Plot wrapped in `libs/shared/charts` | accepted | `frontend`, `accessibility`, `performance` | 2026-05-16 |
| [0024](0024-ai-service-relay-grpc-sse-bridge.md) | AI service relay — vendored gRPC protos, NestJS gRPC client, SSE bridge to the SPA, POC unsigned principal | accepted | `backend`, `security`, `observability` | 2026-05-19 |
| [0025](0025-authorization-model-privileges-roles-scopes.md) | Authorization model — three orthogonal axes (privileges × functional roles × scopes), Entra-backed | proposed | `security`, `backend`, `data` | 2026-05-20 |
| [0025](0025-authorization-model-privileges-roles-scopes.md) | Authorization model — three orthogonal axes (privileges × functional roles × scopes), Entra-backed | accepted | `security`, `backend`, `data` | 2026-05-20 |