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

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
This commit is contained in:
Julien Gautier
2026-05-23 21:47:01 +02:00
parent a34709613f
commit 3c0b5cab58
23 changed files with 1883 additions and 135 deletions
+6
View File
@@ -19,3 +19,9 @@ export {
EntraGroupMapError,
} from './lib/entra-group-to-role';
export type { EntraGroupMap, EntraGroupMapEntry } from './lib/entra-group-to-role';
export {
principalHasAnyPrivilege,
principalHasAnyRole,
principalCoversResource,
} from './lib/principal-matchers';
export type { ScopableResource } from './lib/principal-matchers';
@@ -0,0 +1,212 @@
import { describe, expect, it } from 'vitest';
import type { Principal, Scope } from './authorization.types';
import {
principalCoversResource,
principalHasAnyPrivilege,
principalHasAnyRole,
} from './principal-matchers';
function makePrincipal(overrides: Partial<Principal> = {}): Principal {
return {
user: {
id: 'user-1',
personId: 'person-1',
entraOid: 'oid-1',
tenantId: 'tenant-1',
displayName: 'Alice',
},
privileges: [],
roles: [],
scopes: [],
amr: ['pwd', 'mfa'],
...overrides,
};
}
describe('principalHasAnyPrivilege', () => {
it('returns true when the principal carries one of the required privileges', () => {
const p = makePrincipal({ privileges: ['Portal.Admin'] });
expect(principalHasAnyPrivilege(p, ['Portal.Admin'])).toBe(true);
});
it('OR-combines multiple required privileges', () => {
const p = makePrincipal({ privileges: ['Portal.Auditor'] });
expect(principalHasAnyPrivilege(p, ['Portal.Admin', 'Portal.Auditor'])).toBe(true);
});
it('returns false when none of the required privileges is held', () => {
const p = makePrincipal({ privileges: ['Portal.DPO'] });
expect(principalHasAnyPrivilege(p, ['Portal.Admin'])).toBe(false);
});
it('returns false when the principal has no privileges at all', () => {
const p = makePrincipal();
expect(principalHasAnyPrivilege(p, ['Portal.Admin'])).toBe(false);
});
// An empty requirement list is a degenerate case: a guard
// configured with no privilege constraint is equivalent to no
// guard at all. Document the contract so a caller cannot
// accidentally lock everyone out by passing an empty array.
it('returns true on an empty requirement list (no constraint)', () => {
const p = makePrincipal();
expect(principalHasAnyPrivilege(p, [])).toBe(true);
});
});
describe('principalHasAnyRole', () => {
it('returns true when the principal carries one of the required roles', () => {
const p = makePrincipal({ roles: ['rh'] });
expect(principalHasAnyRole(p, ['rh'])).toBe(true);
});
it('OR-combines multiple required roles', () => {
const p = makePrincipal({ roles: ['comptable'] });
expect(principalHasAnyRole(p, ['rh', 'comptable', 'responsable-paie'])).toBe(true);
});
it('returns false when none of the required roles is held', () => {
const p = makePrincipal({ roles: ['collaborateur'] });
expect(principalHasAnyRole(p, ['directeur-etablissement'])).toBe(false);
});
it('returns true on an empty requirement list (no constraint)', () => {
const p = makePrincipal({ roles: ['collaborateur'] });
expect(principalHasAnyRole(p, [])).toBe(true);
});
});
describe('principalCoversResource — unrestricted', () => {
it('covers every resource regardless of its fields', () => {
const p = makePrincipal({ scopes: [{ kind: 'unrestricted' }] });
expect(principalCoversResource(p, {})).toBe(true);
expect(principalCoversResource(p, { etablissementFiness: '0330800013' })).toBe(true);
expect(principalCoversResource(p, { isSiege: true })).toBe(true);
});
});
describe('principalCoversResource — self', () => {
it('covers a resource whose personId matches the principal user', () => {
const p = makePrincipal({ scopes: [{ kind: 'self' }] });
expect(principalCoversResource(p, { personId: 'person-1' })).toBe(true);
});
it('does not cover a resource owned by a different Person', () => {
const p = makePrincipal({ scopes: [{ kind: 'self' }] });
expect(principalCoversResource(p, { personId: 'person-2' })).toBe(false);
});
it('does not cover a resource that has no personId (collective resource)', () => {
const p = makePrincipal({ scopes: [{ kind: 'self' }] });
expect(principalCoversResource(p, { etablissementFiness: '0330800013' })).toBe(false);
});
});
describe('principalCoversResource — etablissement / delegation / region', () => {
it('etablissement scope covers a resource with the matching FINESS', () => {
const p = makePrincipal({
scopes: [{ kind: 'etablissement', value: '0330800013' }],
});
expect(principalCoversResource(p, { etablissementFiness: '0330800013' })).toBe(true);
});
it('etablissement scope does not cover a different FINESS', () => {
const p = makePrincipal({
scopes: [{ kind: 'etablissement', value: '0330800013' }],
});
expect(principalCoversResource(p, { etablissementFiness: '0330800021' })).toBe(false);
});
it('delegation scope covers a resource in the same department', () => {
const p = makePrincipal({
scopes: [{ kind: 'delegation', value: '33' }],
});
expect(principalCoversResource(p, { delegationCode: '33' })).toBe(true);
});
it('region scope covers a resource in the same region', () => {
const p = makePrincipal({
scopes: [{ kind: 'region', value: '75' }],
});
expect(principalCoversResource(p, { regionCode: '75' })).toBe(true);
});
// The matcher is intentionally non-transitive on the parentage
// chain: a `delegation:33` scope does NOT cover an etablissement
// unless the etablissement's `delegationCode` is supplied. The
// expansion lives in the caller (which knows the parentage of
// the resource it is protecting); the matcher just compares
// what the caller supplies.
it('delegation scope covers an etablissement when the etablissement carries delegationCode', () => {
const p = makePrincipal({
scopes: [{ kind: 'delegation', value: '33' }],
});
expect(
principalCoversResource(p, {
etablissementFiness: '0330800013',
delegationCode: '33',
}),
).toBe(true);
});
it('delegation scope does NOT cover an etablissement that omits delegationCode', () => {
const p = makePrincipal({
scopes: [{ kind: 'delegation', value: '33' }],
});
expect(principalCoversResource(p, { etablissementFiness: '0330800013' })).toBe(false);
});
});
describe('principalCoversResource — siege', () => {
it('covers a resource flagged isSiege: true', () => {
const p = makePrincipal({ scopes: [{ kind: 'siege' }] });
expect(principalCoversResource(p, { isSiege: true })).toBe(true);
});
it('does not cover a non-siege resource', () => {
const p = makePrincipal({ scopes: [{ kind: 'siege' }] });
expect(principalCoversResource(p, { delegationCode: '75' })).toBe(false);
});
});
describe('principalCoversResource — composition', () => {
it('returns true on the first matching scope (OR-combine)', () => {
const p = makePrincipal({
scopes: [
{ kind: 'etablissement', value: '0330800013' },
{ kind: 'delegation', value: '75' },
],
});
expect(principalCoversResource(p, { delegationCode: '75' })).toBe(true);
});
it('returns false when an empty scope list — guards must deny safely on no coverage', () => {
const p = makePrincipal({ scopes: [] });
expect(principalCoversResource(p, { etablissementFiness: '0330800013' })).toBe(false);
});
it('exhaustive switch — every scope kind is handled (compile-time check)', () => {
// Iterate every kind by constructing one scope of each, with
// matching resource data. The matcher must return true for
// each; a missing case in the switch would fail at least one.
const scopes: Scope[] = [
{ kind: 'unrestricted' },
{ kind: 'self' },
{ kind: 'etablissement', value: 'f1' },
{ kind: 'delegation', value: '33' },
{ kind: 'region', value: '75' },
{ kind: 'siege' },
];
for (const scope of scopes) {
const p = makePrincipal({ scopes: [scope] });
const resource = {
personId: 'person-1',
etablissementFiness: 'f1',
delegationCode: '33',
regionCode: '75',
isSiege: true,
};
expect(principalCoversResource(p, resource)).toBe(true);
}
});
});
@@ -0,0 +1,153 @@
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;
}