Files
apf_portal/apps/portal-bff
Julien Gautier 3c0b5cab58
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
feat(auth): @RequirePrivilege/@RequireRole/@RequireScope guards (ADR-0025)
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
2026-05-23 21:47:01 +02:00
..