feat(auth): @RequirePrivilege/@RequireRole/@RequireScope guards (ADR-0025) #208

Merged
julien merged 1 commits from feat/auth-guards-decorators into main 2026-05-23 21:49:36 +02:00
Owner

Summary

Phase 2 of ADR-0025'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, 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 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

  • 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.
  • 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-0026user_scopes Prisma table + seed + PrismaScopeResolver replacing StubScopeResolver, then the first concrete consumers of @RequireScope.
## 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](https://git.unespace.com/julien/apf_portal/pulls/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](https://git.unespace.com/julien/apf_portal/pulls/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`.
julien added 1 commit 2026-05-23 21:49:20 +02:00
feat(auth): @RequirePrivilege/@RequireRole/@RequireScope guards (ADR-0025)
CI / commits (pull_request) Successful in 3m22s
CI / check (pull_request) Failing after 3m51s
CI / scan (pull_request) Failing after 3m59s
CI / a11y (pull_request) Successful in 4m23s
CI / perf (pull_request) Successful in 9m14s
3c0b5cab58
Phase 2 of the ADR-0025 phasing per its §"More Information": the
three new route decorators + guards land alongside the legacy
@RequireAdmin migration. Each guard reads the session-resident
Principal built by the previous PR's PrincipalBuilder, evaluates
the route's requirement, and either passes or emits 403 + audit.

Shared lib (libs/shared/auth):
- principal-matchers.ts: pure 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. 24 unit
  tests covering OR-composition, empty-requirement degenerate
  case, every scope kind, and the resource-side parentage chain.
- ScopableResource type carrying optional FINESS / delegation /
  region / siege parentage that callers populate from the
  route's protected resource.

BFF guards + decorators (apps/portal-bff/src/auth):
- @RequirePrivilege('Portal.X', ...) + RequirePrivilegeGuard.
- @RequireRole('rh', ...) + RequireRoleGuard.
- @RequireScope(req => extract(req)) + RequireScopeGuard. The
  extractor can be async (Prisma lookup pattern); returning null
  is treated as a denial with empty required[].
- Multiple values within a single decorator are OR-combined;
  stacking decorators is AND-combined at the Nest layer.
- All three guards 401 on anonymous, 403 + audit on
  authenticated denial, pass on match. The 403 body is the
  ADR-0021 structured envelope with a generic 'forbidden' code
  so an attacker cannot enumerate which privilege/role/resource
  a route gates.
- principal-extractor.ts factors the session->Principal read
  with a legacy-session bridge for principals minted before the
  previous PR landed (filters user.roles for Portal.* values
  and synthesises an unrestricted scope).

Audit module:
- New AuthorizationDeniedInput type + audit.authorizationDenied()
  method emitting auth.authorization_denied with a kind
  discriminator (privilege / role / scope) plus required[] and
  held[] arrays. Distinct from the existing admin.access_denied
  event so admin-surface signals stay clean.

Legacy guard migration:
- AdminRoleGuard now reads principal.privileges instead of
  user.roles. The public @RequireAdmin() API and the
  admin.access_denied event type are unchanged; only the audit
  row's rolesHeld field now carries Portal.* values rather than
  the raw legacy roles claim.
- MeController.capabilities() likewise reads
  principal.privileges for canAccessAdmin.

Tests:
- 41 shared-auth tests (24 new for matchers).
- 244 new BFF tests covering the 3 new guards, the
  principal-extractor (incl. legacy bridge), the audit method,
  and the 19-persona matrix.
- Total: 741 portal-bff tests, 41 shared-auth tests; full lint +
  build green on nx affected.

Test plan:
- pnpm nx affected -t lint test build --base=main: 3 projects green
- pnpm nx format:check: clean
julien merged commit 7d91da691b into main 2026-05-23 21:49:36 +02:00
julien deleted branch feat/auth-guards-decorators 2026-05-23 21:49:38 +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#208