Files
apf_portal/apps/portal-bff/src/auth/auth-guards.persona-matrix.spec.ts
T
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

268 lines
8.9 KiB
TypeScript

/**
* Persona-matrix integration tests per ADR-0025 §"More Information"
* phasing step 2: the three new guards exercised against every
* persona provisioned in the `apfrd.onmicrosoft.com` test tenant on
* 2026-05-20. Each test instantiates the guard with the persona's
* resolved `Principal` (the shape `PrincipalBuilder` would have
* produced at sign-in) and asserts the guard either passes or
* denies — the matrix proves the contracts hold against the real
* provisioning matrix, not just synthesised toy principals.
*
* Scopes are stubbed `unrestricted` for every persona in v1 per
* ADR-0025 §331 (the per-persona scope values land with the
* Prisma `user_scopes` table in a later PR). The scope guard is
* therefore exercised here against synthesised varied principals,
* not against the persona matrix.
*/
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
import type { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import type { FunctionalRole, Principal, Privilege } from 'shared-auth';
import type { AuditWriter } from '../audit/audit.service';
import { RequirePrivilegeGuard } from './require-privilege.guard';
import { RequireRoleGuard } from './require-role.guard';
interface Persona {
readonly label: string;
readonly privileges: readonly Privilege[];
readonly roles: readonly FunctionalRole[];
}
// Verbatim copy of the persona matrix from
// `principal-builder.spec.ts`. Kept here as a local constant so a
// reviewer sees the same 19-row table both files assert against;
// the two specs cover different layers (builder, then guards).
const PERSONAS: readonly Persona[] = [
{ label: 'admin', privileges: ['Portal.Admin'], roles: ['collaborateur', 'rh'] },
{
label: 'directeur-bordeaux',
privileges: [],
roles: ['collaborateur', 'directeur-etablissement'],
},
{
label: 'directeur-complexe',
privileges: [],
roles: ['collaborateur', 'directeur-etablissement'],
},
{ label: 'rh-aquitaine', privileges: [], roles: ['collaborateur', 'rh', 'formation'] },
{
label: 'rh-siege',
privileges: [],
roles: ['collaborateur', 'rh', 'responsable-paie', 'comptable'],
},
{ label: 'collaborateur-simple', privileges: [], roles: ['collaborateur'] },
{ label: 'tresorier-bordeaux', privileges: [], roles: ['elu-cd', 'elu-cd-tresorier'] },
{
label: 'dpo',
privileges: ['Portal.DPO', 'Portal.Auditor'],
roles: ['collaborateur', 'dpo', 'qualite'],
},
{ label: 'it', privileges: [], roles: ['collaborateur', 'it'] },
{
label: 'benevole-aquitaine',
privileges: [],
roles: ['delegue', 'benevole', 'benevole-responsable'],
},
{ label: 'chef-equipe-bordeaux', privileges: [], roles: ['collaborateur', 'chef-equipe'] },
{ label: 'chef-service-bordeaux', privileges: [], roles: ['collaborateur', 'chef-service'] },
{
label: 'directeur-territorial-aquitaine',
privileges: [],
roles: ['collaborateur', 'directeur-territorial'],
},
{ label: 'juriste-siege', privileges: [], roles: ['collaborateur', 'juriste'] },
{ label: 'rssi', privileges: ['Portal.SecurityOfficer'], roles: ['collaborateur', 'rssi'] },
{ label: 'communication-siege', privileges: [], roles: ['collaborateur', 'communication'] },
{ label: 'elu-ca-national', privileges: [], roles: ['elu-ca'] },
{ label: 'president-cd-aquitaine', privileges: [], roles: ['elu-cd', 'elu-cd-president'] },
{ label: 'secretaire-cd-aquitaine', privileges: [], roles: ['elu-cd', 'elu-cd-secretaire'] },
];
function principalOf(persona: Persona): Principal {
return {
user: {
id: `oid-${persona.label}`,
personId: `oid-${persona.label}`,
entraOid: `oid-${persona.label}`,
tenantId: 'tenant-1',
displayName: persona.label,
},
privileges: persona.privileges,
roles: persona.roles,
scopes: [{ kind: 'unrestricted' }],
amr: ['pwd', 'mfa'],
};
}
function makeCtxFor(principal: Principal): ExecutionContext {
const req = {
session: { principal },
method: 'GET',
originalUrl: '/api/test',
} as unknown as Request;
return {
switchToHttp: () => ({ getRequest: <T>() => req as T }),
getHandler: () => 'handler',
getClass: () => 'TestController',
} as unknown as ExecutionContext;
}
function privilegeGuardFor(metadata: readonly Privilege[]) {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(metadata),
} as unknown as Reflector;
const audit = jest.fn().mockResolvedValue(undefined);
const writer = { authorizationDenied: audit } as unknown as AuditWriter;
return { guard: new RequirePrivilegeGuard(reflector, writer), audit };
}
function roleGuardFor(metadata: readonly FunctionalRole[]) {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(metadata),
} as unknown as Reflector;
const audit = jest.fn().mockResolvedValue(undefined);
const writer = { authorizationDenied: audit } as unknown as AuditWriter;
return { guard: new RequireRoleGuard(reflector, writer), audit };
}
/**
* Each row asserts which personas should pass a given guard. The
* complement (everyone NOT in `allowed`) must be denied — the
* `expect denied` block at the bottom of each describe catches a
* regression that would silently let extra personas through.
*/
interface Matrix {
readonly title: string;
readonly allowed: ReadonlyArray<string>;
}
const PRIVILEGE_MATRIX: ReadonlyArray<{
metadata: readonly Privilege[];
matrix: Matrix;
}> = [
{
metadata: ['Portal.Admin'],
matrix: { title: '@RequirePrivilege(Portal.Admin)', allowed: ['admin'] },
},
{
metadata: ['Portal.DPO'],
matrix: { title: '@RequirePrivilege(Portal.DPO)', allowed: ['dpo'] },
},
{
metadata: ['Portal.Auditor'],
matrix: { title: '@RequirePrivilege(Portal.Auditor)', allowed: ['dpo'] },
},
{
metadata: ['Portal.SecurityOfficer'],
matrix: { title: '@RequirePrivilege(Portal.SecurityOfficer)', allowed: ['rssi'] },
},
{
metadata: ['Portal.Admin', 'Portal.DPO'],
matrix: {
title: '@RequirePrivilege(Portal.Admin, Portal.DPO) — OR composition',
allowed: ['admin', 'dpo'],
},
},
];
const ROLE_MATRIX: ReadonlyArray<{
metadata: readonly FunctionalRole[];
matrix: Matrix;
}> = [
{
metadata: ['rh'],
matrix: { title: '@RequireRole(rh)', allowed: ['admin', 'rh-aquitaine', 'rh-siege'] },
},
{
metadata: ['directeur-etablissement'],
matrix: {
title: '@RequireRole(directeur-etablissement)',
allowed: ['directeur-bordeaux', 'directeur-complexe'],
},
},
{
metadata: ['rh', 'comptable', 'responsable-paie'],
matrix: {
title: '@RequireRole(rh, comptable, responsable-paie) — OR composition',
allowed: ['admin', 'rh-aquitaine', 'rh-siege'],
},
},
{
metadata: ['elu-cd'],
matrix: {
title: '@RequireRole(elu-cd) — every CD member',
allowed: ['tresorier-bordeaux', 'president-cd-aquitaine', 'secretaire-cd-aquitaine'],
},
},
{
metadata: ['benevole-responsable'],
matrix: {
title: '@RequireRole(benevole-responsable)',
allowed: ['benevole-aquitaine'],
},
},
{
metadata: ['collaborateur'],
matrix: {
title: '@RequireRole(collaborateur) — every workforce persona',
allowed: [
'admin',
'directeur-bordeaux',
'directeur-complexe',
'rh-aquitaine',
'rh-siege',
'collaborateur-simple',
'dpo',
'it',
'chef-equipe-bordeaux',
'chef-service-bordeaux',
'directeur-territorial-aquitaine',
'juriste-siege',
'rssi',
'communication-siege',
],
},
},
];
describe('Privilege guard — persona matrix', () => {
for (const { metadata, matrix } of PRIVILEGE_MATRIX) {
describe(matrix.title, () => {
for (const persona of PERSONAS) {
const shouldPass = matrix.allowed.includes(persona.label);
const verb = shouldPass ? 'allows' : 'denies';
it(`${verb} ${persona.label}`, async () => {
const { guard } = privilegeGuardFor(metadata);
const ctx = makeCtxFor(principalOf(persona));
if (shouldPass) {
await expect(guard.canActivate(ctx)).resolves.toBe(true);
} else {
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
}
});
}
});
}
});
describe('Role guard — persona matrix', () => {
for (const { metadata, matrix } of ROLE_MATRIX) {
describe(matrix.title, () => {
for (const persona of PERSONAS) {
const shouldPass = matrix.allowed.includes(persona.label);
const verb = shouldPass ? 'allows' : 'denies';
it(`${verb} ${persona.label}`, async () => {
const { guard } = roleGuardFor(metadata);
const ctx = makeCtxFor(principalOf(persona));
if (shouldPass) {
await expect(guard.canActivate(ctx)).resolves.toBe(true);
} else {
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
}
});
}
});
}
});