Files
apf_portal/libs/shared/auth/src/lib/principal-matchers.ts
T
julien 7d91da691b
CI / check (push) Failing after 4m35s
CI / commits (push) Has been skipped
CI / scan (push) Failing after 3m52s
CI / a11y (push) Successful in 1m59s
CI / perf (push) Successful in 5m50s
feat(auth): @RequirePrivilege/@RequireRole/@RequireScope guards (ADR-0025) (#208)
## Summary

Phase 2 of [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md)'s implementation phasing (§"More Information"). The three new route decorators land — `@RequirePrivilege`, `@RequireRole`, `@RequireScope` — alongside the migration of the legacy `@RequireAdmin` to read from `principal.privileges`. Each guard consumes the session-resident `Principal` built by [#206](#206), evaluates its requirement, and either passes or emits a 403 + audit row.

The drift-CI gate (phase 3) is **not** in this PR — it lands next, once the catalogue is being referenced from real route handlers.

## What lands

### Shared lib (`libs/shared/auth`)

| File | Role |
| --- | --- |
| `src/lib/principal-matchers.ts` | Pure functions: `principalHasAnyPrivilege` / `principalHasAnyRole` / `principalCoversResource`. The matchers live in the shared lib so the SPA can render UI predicates with the same logic the BFF enforces server-side. |
| Same file | `ScopableResource` type — optional FINESS / delegation / region / siege parentage. Callers populate the subset their route resource exposes; the matcher iterates and returns true on the first scope that covers a present field. |
| `src/lib/principal-matchers.spec.ts` | 24 unit tests covering OR-composition, the empty-requirement degenerate case (treated as "no constraint" — pass), every scope kind, and the resource-side parentage chain. |

### BFF guards + decorators (`apps/portal-bff/src/auth`)

| File | Role |
| --- | --- |
| `require-privilege.{decorator,guard}.ts` | `@RequirePrivilege('Portal.Admin', ...)` — type-locked to the closed `Privilege` catalogue; multiple values OR-combine. |
| `require-role.{decorator,guard}.ts` | `@RequireRole('rh', ...)` — same shape, against `FunctionalRole`. |
| `require-scope.{decorator,guard}.ts` | `@RequireScope(req => extractResource(req))` — extractor can be async (Prisma lookup pattern); returning `null` is treated as denial with empty `required[]`. |
| `principal-extractor.ts` | Single read of `Principal` off the session, with a legacy-session bridge for principals minted before [#206](#206) landed — filters `user.roles` for `Portal.*` values and synthesises an `unrestricted` scope. After the 12 h absolute-TTL window post-deploy, the bridge becomes dead code. |
| `principal-extractor.spec.ts` | 7 tests covering both code paths. |
| `require-{privilege,role,scope}.guard.spec.ts` | Unit tests for each guard: 401 on anonymous, 403 + audit on denial, pass on match, generic `forbidden` response body (no role/privilege/resource hint leaks). |
| `auth-guards.persona-matrix.spec.ts` | Integration tests: each of the 19 test-tenant personas walked through 5 representative privilege guards + 6 representative role guards. The matrix proves the contracts hold against the real provisioning, not just synthesised principals. |

### Audit module

| File | Change |
| --- | --- |
| `audit.types.ts` | New `AuthorizationDeniedInput` discriminated by `kind: 'privilege' \| 'role' \| 'scope'`. Carries `required[]` and `held[]` arrays so an auditor can pivot on "tried admin while logged in as RH" patterns without joining anything. |
| `audit.service.ts` | New `authorizationDenied()` method emitting the `auth.authorization_denied` event type. Distinct from the existing `admin.access_denied` so admin-surface signals stay clean. |
| `audit.service.spec.ts` | 3 new tests covering the three `kind` values. |

### Legacy guard migration

| File | Change |
| --- | --- |
| `admin/admin-role.guard.ts` | Reads `principal.privileges` instead of `user.roles`. Public `@RequireAdmin()` API unchanged; `admin.access_denied` event type unchanged. The audit row's `rolesHeld` field now carries `Portal.*` values rather than the raw legacy `roles` claim. |
| `admin/admin-role.guard.spec.ts` | Rewritten to exercise `session.principal`-shaped sessions, with a dedicated legacy-bridge sub-suite that asserts pre-ADR-0025 sessions still work. |
| `me/me.controller.ts` | `capabilities().canAccessAdmin` reads `principal.privileges` via the same extractor. |
| `me/me.controller.spec.ts` | Rewritten against the principal shape. |

### AuthModule wiring

| File | Change |
| --- | --- |
| `auth/auth.module.ts` | Three new guards registered as providers and re-exported. |

## Notes for the reviewer

- **Composition semantics.** Within a single decorator, values OR-combine (`@RequireRole('rh', 'comptable')` matches anyone with either). Stacking decorators AND-combines at the Nest level — `@RequireRole('rh') @RequirePrivilege('Portal.Admin')` requires both, because each guard runs separately and a single denial stops the request. The empty-requirement case is rejected at the type level by the tuple `[Privilege, ...Privilege[]]` so a route can not opt out of authorization by passing zero values.

- **Why `auth.authorization_denied` and not `admin.access_denied` for the new guards.** ADR-0025 §347 originally said "writes an `admin.access_denied` row", but reusing the `admin.*` namespace would have meant the future `@RequireRole('rh')` on a non-admin HR route ships an event whose type implies an admin surface. The new event type lives in the `auth.*` namespace where the guards live; a `kind` discriminator on the payload tells privilege / role / scope denials apart. The legacy `admin.access_denied` event continues unchanged for `AdminRoleGuard`.

- **The legacy-session bridge is short-lived.** After the 12 h absolute-TTL window from this PR's deploy, every session in Redis has a `principal` field and the bridge's `else` branch becomes unreachable. The bridge keeps `@RequireAdmin` + `MeController` functional during that window without needing a forced re-auth campaign.

- **Why a `ScopableResource` shape rather than a `Resource | Resource | ...` union per kind.** Routes protect heterogeneous resource types (`Etablissement`, `Dossier`, `Person`), each exposing a different subset of the parentage chain. The optional-field shape lets each route populate what its resource carries and lets the matcher iterate over the principal's scopes once. The cost is that a route author who forgets to populate `delegationCode` on an `Etablissement` resource locks delegation-scoped users out — a typing exercise that the next round of work (concrete consumers) will surface naturally.

- **Why `principalCoversResource` deny on doubt.** When a route's extractor returns a resource that lacks the parentage `delegationCode` field, a `delegation:33` scope no longer matches — deny is the safe direction. An over-restrictive deny shows up in the audit log (operator can fix the extractor); an over-permissive pass would silently leak data. The non-transitivity of the parentage chain is an intentional contract.

- **Why migrate `MeController` in the same PR.** The `canAccessAdmin` flag reads the same axis (`Portal.Admin` privilege). Leaving it on the legacy `user.roles` shape while everything else moves to `principal.privileges` would create internal inconsistency; a future reviewer would rightfully ask "why?". The migration is three lines plus a spec rewrite.

- **The drift-CI gate is the next PR.** ADR-0025 §"More Information" step 3. ESLint custom rule (or a `pnpm run` script) grepping every `@RequirePrivilege('...')` / `@RequireRole('...')` / scope-kind literal in the codebase and asserting each one exists in the catalogue. Now there is something to grep for (the decorators and their tests reference catalogue values), so the gate is well-scoped to land standalone.

- **No real consumers of `@RequireScope` yet.** The scope guard ships with a fully exercised contract (extractor signature, async support, audit shape, generic-forbidden body) but no live route. The first consumer surface lands with the `Person` + `User` schema (proposed ADR-0026) and the resource-loading routes that follow.

## Test plan

- [x] `pnpm nx affected -t lint test build --base=main` — 3 projects green
  - `portal-bff`: 741 tests pass (was 497 in #206 — +244 new: matchers/guards/persona-matrix/audit/extractor).
  - `shared-auth`: 41 tests pass (was 17 in #206 — +24 new matcher tests).
  - `portal-bff-e2e`: lint green.
- [x] `pnpm nx format:check` — clean after `pnpm nx format:write`.
- [ ] **Review focus** — the 19-persona matrix (each persona walked through 5 privilege guards + 6 role guards); the legacy-session bridge in `principal-extractor.ts` and its tests; the `admin.access_denied` audit payload now carries `Portal.*` values rather than legacy `roles` (intentional, called out above); the `auth.authorization_denied` event-type rationale.

## What's next

Per ADR-0025 §"More Information" phasing:

1.  #206 — types + Principal builder + group-to-role mapping skeleton.
2.  **This PR** — the three new decorators + guards, legacy `@RequireAdmin` migration.
3. **Next PR** — drift CI gate. ESLint custom rule (or `pnpm run` script) that asserts every `@RequireX('...')` literal in code is in the closed catalogue.
4. **Depends on ADR-0026** — `user_scopes` Prisma table + seed + `PrismaScopeResolver` replacing `StubScopeResolver`, then the first concrete consumers of `@RequireScope`.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #208
2026-05-23 21:49:34 +02:00

154 lines
5.5 KiB
TypeScript

import type { FunctionalRole, Principal, Privilege } from './authorization.types';
/**
* Pure matchers used by the BFF route guards (and, in the future,
* by SPA-side UI predicates) to evaluate a `Principal` against an
* authorization requirement per [ADR-0025 §"Guard surface"](../../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
*
* The matchers live in the shared lib so the projection contract
* between BFF and SPA stays single-sourced — the SPA can pre-check
* a privilege/role to decide whether to render a UI element with
* the exact same logic the BFF guards apply server-side. The SPA
* check is a UX optimisation (avoid rendering a button that 403s);
* the BFF guard is the authoritative gate.
*
* **Composition.** Each matcher takes a list of required values;
* a `Principal` matches when it holds **any** of them (OR). Decorator
* stacking is AND-combined at the Nest level (separate guards run
* sequentially), so the BFF can express
* `@RequireRole('rh') @RequirePrivilege('Portal.Admin')` to mean
* "RH **and** Admin"; within a single decorator the values are OR.
*/
export function principalHasAnyPrivilege(
principal: Principal,
required: ReadonlyArray<Privilege>,
): boolean {
if (required.length === 0) {
return true;
}
for (const want of required) {
if (principal.privileges.includes(want)) {
return true;
}
}
return false;
}
export function principalHasAnyRole(
principal: Principal,
required: ReadonlyArray<FunctionalRole>,
): boolean {
if (required.length === 0) {
return true;
}
for (const want of required) {
if (principal.roles.includes(want)) {
return true;
}
}
return false;
}
/**
* Shape an `@RequireScope` handler hands to the matcher. Mirrors
* the parentage chain of a `Person`-scoped resource without
* pulling Prisma types into the shared lib — guards extract the
* subset their resource exposes (a route operating on an
* `Etablissement` populates `etablissementFiness`,
* `delegationCode`, `regionCode`; one operating on a `Dossier`
* populates the etablissement chain via the dossier's
* `etablissement` parent; one operating on a `Person` populates
* `personId` for the `self` rule).
*
* Every field is optional — the matcher iterates over the
* principal's scopes and returns true on the first scope that
* matches a present field. Missing parentage simply makes
* coarser-grained scopes fail to match, which is the safe
* direction (deny on doubt).
*/
export interface ScopableResource {
/**
* The resource's owner-`Person` id, matched against `self` scope.
* Present only on resources owned by a single Person (a `Dossier`
* the Person is the beneficiary of, a `NoteDeFrais` they
* submitted). Absent on collective resources (an `Etablissement`,
* a `Delegation`).
*/
readonly personId?: string;
/** FINESS code, matched against `etablissement:<finess>` scope. */
readonly etablissementFiness?: string;
/** French department code, matched against `delegation:<dept>` scope. */
readonly delegationCode?: string;
/** INSEE region code, matched against `region:<insee>` scope. */
readonly regionCode?: string;
/**
* True for resources that belong to the national head office.
* Matched against `siege` scope. Distinct from "lives in Paris" —
* a Paris-based délégation is `delegation:75`, not `siege`.
*/
readonly isSiege?: boolean;
}
/**
* Helper for `@RequireScope`. Returns `true` if at least one of
* the principal's scopes covers the resource per ADR-0025 §"Scope
* expansion at check time". Containment rules:
*
* - `unrestricted` covers every resource.
* - `self` covers a resource whose `personId` matches the
* principal's user — caller supplies `personId` when this
* comparison is meaningful for the route.
* - `etablissement:<finess>` covers a resource whose
* `etablissementFiness` equals the scope value.
* - `delegation:<dept>` covers a resource whose
* `delegationCode` equals the scope value.
* - `region:<insee>` covers a resource whose `regionCode`
* equals the scope value.
* - `siege` covers resources flagged `isSiege: true`.
*
* Empty scope list ⇒ no coverage (returns false). The BFF's
* `PrincipalBuilder` always populates *something* (in v1, every
* principal carries `[{ kind: 'unrestricted' }]` via the stub
* resolver), but the matcher does not assume that — a future
* Prisma-backed resolver returning an empty list for a user must
* deny safely.
*/
export function principalCoversResource(principal: Principal, resource: ScopableResource): boolean {
for (const scope of principal.scopes) {
switch (scope.kind) {
case 'unrestricted':
return true;
case 'self':
if (resource.personId !== undefined && resource.personId === principal.user.personId) {
return true;
}
break;
case 'etablissement':
if (
resource.etablissementFiness !== undefined &&
scope.value === resource.etablissementFiness
) {
return true;
}
break;
case 'delegation':
if (resource.delegationCode !== undefined && scope.value === resource.delegationCode) {
return true;
}
break;
case 'region':
if (resource.regionCode !== undefined && scope.value === resource.regionCode) {
return true;
}
break;
case 'siege':
if (resource.isSiege === true) {
return true;
}
break;
}
}
return false;
}