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
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user