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
268 lines
8.9 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
});
|