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
@@ -1,5 +1,6 @@
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import type { Request } from 'express';
import type { Principal } from 'shared-auth';
import { AuditWriter } from '../audit/audit.service';
import { ADMIN_ROLE, AdminRoleGuard } from './admin-role.guard';
@@ -12,6 +13,7 @@ interface SessionStub {
amr: readonly string[];
roles: readonly string[];
};
principal?: Principal;
}
function makeRequest(opts: {
@@ -38,6 +40,22 @@ function makeAuditStub(): jest.Mocked<Pick<AuditWriter, 'adminAccessDenied'>> {
};
}
function principalWithPrivileges(privileges: readonly string[]): Principal {
return {
user: {
id: 'user-oid',
personId: 'user-oid',
entraOid: 'user-oid',
tenantId: 't',
displayName: 'Jane',
},
privileges: privileges as Principal['privileges'],
roles: [],
scopes: [{ kind: 'unrestricted' }],
amr: ['pwd', 'mfa'],
};
}
describe('AdminRoleGuard', () => {
it('throws 401 when the request has no session at all', async () => {
const audit = makeAuditStub();
@@ -47,7 +65,7 @@ describe('AdminRoleGuard', () => {
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
});
it('throws 401 when the session exists but no user is bound to it', async () => {
it('throws 401 when the session exists but no principal nor legacy user is bound', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(makeRequest({ session: {} }));
@@ -55,21 +73,12 @@ describe('AdminRoleGuard', () => {
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
});
it('throws 403 + records admin.access_denied when the user has no roles at all', async () => {
it('throws 403 + records admin.access_denied when the principal has no privileges', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: {
user: {
oid: 'user-oid',
tid: 't',
username: 'jane@example',
displayName: 'Jane',
amr: ['pwd'],
roles: [],
},
},
session: { principal: principalWithPrivileges([]) },
method: 'GET',
originalUrl: '/api/admin/me',
}),
@@ -82,7 +91,83 @@ describe('AdminRoleGuard', () => {
});
});
it('throws 403 + records admin.access_denied when the user has unrelated roles only', async () => {
it('throws 403 + records admin.access_denied when the principal has non-admin privileges only', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: { principal: principalWithPrivileges(['Portal.Auditor']) },
method: 'POST',
originalUrl: '/api/admin/cms/pages',
}),
);
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
expect(audit.adminAccessDenied).toHaveBeenCalledWith({
actor: { oid: 'user-oid' },
attemptedRoute: 'POST /api/admin/cms/pages',
rolesHeld: ['Portal.Auditor'],
});
});
it('returns true and does not audit when the principal carries Portal.Admin', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({ session: { principal: principalWithPrivileges([ADMIN_ROLE]) } }),
);
await expect(guard.canActivate(ctx)).resolves.toBe(true);
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
});
it('returns true when admin is one of several privileges', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: {
principal: principalWithPrivileges(['Portal.Auditor', ADMIN_ROLE, 'Portal.DPO']),
},
}),
);
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('propagates audit write failures (no audit ⇒ no action, per ADR-0013)', async () => {
const audit = {
adminAccessDenied: jest.fn().mockRejectedValue(new Error('audit_writer denied')),
};
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(makeRequest({ session: { principal: principalWithPrivileges([]) } }));
// The 403 path goes through audit first; if audit throws, the
// guard surfaces the underlying error rather than the
// ForbiddenException — the caller sees a 500 and the audit
// invariant is preserved.
await expect(guard.canActivate(ctx)).rejects.toThrow('audit_writer denied');
});
describe('legacy-session bridge (sessions persisted before ADR-0025 landed)', () => {
it('synthesises a principal from session.user when session.principal is absent', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: {
user: {
oid: 'user-oid',
tid: 't',
username: 'jane@example',
displayName: 'Jane',
amr: ['pwd', 'mfa'],
roles: [ADMIN_ROLE, 'Other.Role'],
},
},
}),
);
await expect(guard.canActivate(ctx)).resolves.toBe(true);
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
});
it('denies a legacy session whose roles claim carries no Portal.* value', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
@@ -97,82 +182,18 @@ describe('AdminRoleGuard', () => {
roles: ['editor', 'auditor'],
},
},
method: 'POST',
originalUrl: '/api/admin/cms/pages',
}),
);
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
// The legacy bridge filters the raw `roles` claim down to
// `Portal.*` values for the audit row's `rolesHeld` field —
// `editor` and `auditor` are not privileges, so the held
// list is empty.
expect(audit.adminAccessDenied).toHaveBeenCalledWith({
actor: { oid: 'user-oid' },
attemptedRoute: 'POST /api/admin/cms/pages',
rolesHeld: ['editor', 'auditor'],
attemptedRoute: 'GET /api/admin/me',
rolesHeld: [],
});
});
it('returns true and does not audit when the user has the admin role', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: {
user: {
oid: 'user-oid',
tid: 't',
username: 'jane@example',
displayName: 'Jane',
amr: ['pwd', 'mfa'],
roles: [ADMIN_ROLE],
},
},
}),
);
await expect(guard.canActivate(ctx)).resolves.toBe(true);
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
});
it('returns true when admin is one of several roles', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: {
user: {
oid: 'user-oid',
tid: 't',
username: 'jane@example',
displayName: 'Jane',
amr: ['pwd', 'mfa'],
roles: ['editor', ADMIN_ROLE, 'auditor'],
},
},
}),
);
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('propagates audit write failures (no audit ⇒ no action, per ADR-0013)', async () => {
const audit = {
adminAccessDenied: jest.fn().mockRejectedValue(new Error('audit_writer denied')),
};
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: {
user: {
oid: 'user-oid',
tid: 't',
username: 'jane@example',
displayName: 'Jane',
amr: ['pwd'],
roles: [],
},
},
}),
);
// The 403 path goes through audit first; if audit throws, the
// guard surfaces the underlying error rather than the
// ForbiddenException — the caller sees a 500 and the audit
// invariant is preserved.
await expect(guard.canActivate(ctx)).rejects.toThrow('audit_writer denied');
});
});
+25 -17
View File
@@ -7,6 +7,7 @@ import {
} from '@nestjs/common';
import type { Request } from 'express';
import { AuditWriter } from '../audit/audit.service';
import { readSessionPrincipal } from '../auth/principal-extractor';
/**
* Single Entra app role that gates the entire `/api/admin/*` surface
@@ -20,6 +21,11 @@ export const ADMIN_ROLE = 'Portal.Admin';
/**
* `AdminRoleGuard` — enforces ADR-0020's role-based admin gate.
*
* Reads from `principal.privileges` per [ADR-0025 §"Guard surface"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md)
* — the migration thinned the implementation but kept the
* `@RequireAdmin()` public API and the `admin.access_denied`
* audit event type unchanged.
*
* Contract
* --------
* - **No session → 401.** The user is not authenticated at all; the
@@ -28,19 +34,21 @@ export const ADMIN_ROLE = 'Portal.Admin';
* unauthenticated 401 is normal traffic the absolute-timeout
* middleware would surface anyway.
*
* - **Session but missing `Portal.Admin` role → 403 + audit.** The user
* *is* authenticated; they just are not authorised for admin.
* This is the privilege-escalation attempt audit signal — every
* denial lands in `audit.events` with `outcome=denied`, the
* attempted route as `subject`, and the roles the user did hold
* in the payload. Per ADR-0013 §"Blocking writes": no audit ⇒ no
* action — if the audit write fails, the request fails too
* (consistent with the existing audit call sites in
* `AuthController`).
* - **Session but missing `Portal.Admin` privilege → 403 + audit.**
* The user *is* authenticated; they just are not authorised
* for admin. This is the privilege-escalation attempt audit
* signal — every denial lands in `audit.events` with
* `outcome=denied`, the attempted route as `subject`, and the
* `Portal.*` privileges the principal did hold in the payload's
* `rolesHeld` field (kept named `rolesHeld` for backward
* compatibility with the existing audit shape; the values are
* privileges per ADR-0025's renaming of the axis). Per
* ADR-0013 §"Blocking writes": no audit ⇒ no action.
*
* - **Session with `Portal.Admin` role → pass through.** Downstream
* controllers see `req.session.user` populated and can rely on
* the role check having happened.
* - **Session with `Portal.Admin` privilege → pass through.**
* Downstream controllers see `req.session.user` *and*
* `req.session.principal` populated and can rely on the gate
* having happened.
*/
@Injectable()
export class AdminRoleGuard implements CanActivate {
@@ -48,17 +56,17 @@ export class AdminRoleGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<Request>();
const user = req.session?.user;
const principal = readSessionPrincipal(req);
if (!user) {
if (principal === null) {
throw new UnauthorizedException();
}
if (!user.roles.includes(ADMIN_ROLE)) {
if (!principal.privileges.includes(ADMIN_ROLE)) {
await this.audit.adminAccessDenied({
actor: { oid: user.oid },
actor: { oid: principal.user.entraOid },
attemptedRoute: `${req.method} ${req.originalUrl}`,
rolesHeld: user.roles,
rolesHeld: [...principal.privileges],
});
throw new ForbiddenException();
}
@@ -469,4 +469,67 @@ describe('AuditWriter — typed event methods', () => {
expect(extractInsertedRow(prisma).payloadJson).toBe(JSON.stringify({ rolesHeld: [] }));
});
});
describe('authorizationDenied()', () => {
it('records auth.authorization_denied with kind=privilege payload', async () => {
const { writer, prisma, hashUserId } = await createSubject();
await writer.authorizationDenied({
actor: { oid: 'user-oid' },
attemptedRoute: 'GET /api/audit',
kind: 'privilege',
required: ['Portal.Auditor'],
held: ['Portal.Admin'],
});
expect(hashUserId.hash).toHaveBeenCalledWith('user-oid');
const row = extractInsertedRow(prisma);
expect(row.eventType).toBe('auth.authorization_denied');
expect(row.outcome).toBe('denied');
expect(row.subject).toBe('GET /api/audit');
expect(row.payloadJson).toBe(
JSON.stringify({
kind: 'privilege',
required: ['Portal.Auditor'],
held: ['Portal.Admin'],
}),
);
});
it('records kind=role with the role lists', async () => {
const { writer, prisma } = await createSubject();
await writer.authorizationDenied({
actor: { oid: 'user-oid' },
attemptedRoute: 'GET /api/hr/payroll',
kind: 'role',
required: ['rh', 'responsable-paie'],
held: ['collaborateur'],
});
const row = extractInsertedRow(prisma);
expect(row.payloadJson).toBe(
JSON.stringify({
kind: 'role',
required: ['rh', 'responsable-paie'],
held: ['collaborateur'],
}),
);
});
it('records kind=scope with the scope descriptors', async () => {
const { writer, prisma } = await createSubject();
await writer.authorizationDenied({
actor: { oid: 'user-oid' },
attemptedRoute: 'GET /api/etablissement/0330800021',
kind: 'scope',
required: ['etablissement:0330800021'],
held: ['etablissement:0330800013'],
});
const row = extractInsertedRow(prisma);
expect(row.payloadJson).toBe(
JSON.stringify({
kind: 'scope',
required: ['etablissement:0330800021'],
held: ['etablissement:0330800013'],
}),
);
});
});
});
@@ -8,6 +8,7 @@ import type {
AdminAuditStatsQueryInput,
AdminUsersQueryInput,
AuditEventInput,
AuthorizationDeniedInput,
MfaRequiredInput,
SignInActor,
SignInFailedInput,
@@ -239,6 +240,34 @@ export class AuditWriter {
});
}
/**
* Typed event: a request was rejected by one of the three new
* ADR-0025 guards (`RequirePrivilegeGuard`, `RequireRoleGuard`,
* `RequireScopeGuard`). Distinct from `admin.access_denied` so
* an auditor can keep the existing admin-surface signal clean
* while tracking the broader role/scope denial surface as it
* grows.
*
* `outcome=denied` matches the existing posture: the user *is*
* authenticated, the action is just refused. The `required` and
* `held` payload arrays let an auditor pivot on "tried admin
* while logged in as RH" without joining anything.
*/
async authorizationDenied(input: AuthorizationDeniedInput): Promise<void> {
await this.recordEvent({
eventType: 'auth.authorization_denied',
audience: 'workforce',
outcome: 'denied',
actorIdHash: this.hashUserId.hash(input.actor.oid),
subject: input.attemptedRoute,
payload: {
kind: input.kind,
required: input.required,
held: input.held,
},
});
}
async recordEvent(input: AuditEventInput): Promise<void> {
const traceId = trace.getActiveSpan()?.spanContext().traceId ?? null;
const actorIdHash =
+36
View File
@@ -147,6 +147,42 @@ export interface AdminAuditStatsQueryInput {
total: number;
}
/**
* Discriminator on `AuthorizationDeniedInput.kind` — which of the
* three new ADR-0025 guards rejected the request. Stored in the
* payload so an auditor can spot "what kind of authorization
* failed" without parsing the route. The existing
* `admin.access_denied` event keeps its own type for backwards
* compatibility with the legacy `AdminRoleGuard` audit posture.
*/
export type AuthorizationDeniedKind = 'privilege' | 'role' | 'scope';
export interface AuthorizationDeniedInput {
actor: { oid: string };
/** Canonical "{METHOD} {originalUrl}" of the rejected request. */
attemptedRoute: string;
/** Which guard fired the rejection. */
kind: AuthorizationDeniedKind;
/**
* For `kind: 'privilege' | 'role'`: the catalogue values the
* guard required. For `kind: 'scope'`: the route's resource
* descriptor serialised into a `{kind, value}` map. Stored as
* a generic `string[]` rather than the union of `Privilege` /
* `FunctionalRole` so the audit module stays decoupled from the
* shared catalogue.
*/
required: readonly string[];
/**
* What the principal actually held on the matching axis at the
* moment of denial — `privileges[]` for privilege/admin denials,
* `roles[]` for role denials, `scopes[]` (serialised as
* `kind` or `kind:value` strings) for scope denials. Lets an
* auditor spot "tried admin while logged in as RH" patterns
* without joining anything.
*/
held: readonly string[];
}
/** Reason `RequireMfaGuard` rejected a request — fed into the audit payload. */
export type MfaRequiredReason = 'no-mfa-in-amr' | 'no-mfa-verified-at' | 'mfa-stale';
@@ -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);
}
});
}
});
}
});
+9
View File
@@ -11,6 +11,9 @@ import { ENTRA_GROUP_TO_ROLE_RESOLVER } from './entra-group-to-role.token';
import { MSAL_CLIENT } from './msal-client.token';
import { PrincipalBuilder } from './principal-builder';
import { RequireMfaGuard } from './require-mfa.guard';
import { RequirePrivilegeGuard } from './require-privilege.guard';
import { RequireRoleGuard } from './require-role.guard';
import { RequireScopeGuard } from './require-scope.guard';
import { ScopeResolver, StubScopeResolver } from './scope-resolver';
import { SessionEstablisher } from './session-establisher.service';
@@ -50,6 +53,9 @@ import { SessionEstablisher } from './session-establisher.service';
providers: [
AuthService,
RequireMfaGuard,
RequirePrivilegeGuard,
RequireRoleGuard,
RequireScopeGuard,
SessionEstablisher,
PrincipalBuilder,
{ provide: ScopeResolver, useClass: StubScopeResolver },
@@ -126,6 +132,9 @@ import { SessionEstablisher } from './session-establisher.service';
ENTRA_GROUP_TO_ROLE_RESOLVER,
MSAL_CLIENT,
RequireMfaGuard,
RequirePrivilegeGuard,
RequireRoleGuard,
RequireScopeGuard,
AuthService,
PrincipalBuilder,
ScopeResolver,
@@ -0,0 +1,115 @@
import type { Request } from 'express';
import type { Principal, Scope } from 'shared-auth';
import { readSessionPrincipal, scopesToAuditStrings } from './principal-extractor';
const PRINCIPAL: Principal = {
user: {
id: 'user-1',
personId: 'person-1',
entraOid: 'oid-1',
tenantId: 'tenant-1',
displayName: 'Alice',
},
privileges: ['Portal.Admin'],
roles: ['rh'],
scopes: [{ kind: 'unrestricted' }],
amr: ['pwd', 'mfa'],
};
function makeReq(session: Record<string, unknown> | undefined): Request {
return { session } as unknown as Request;
}
describe('readSessionPrincipal', () => {
it('returns null when the request has no session at all', () => {
expect(readSessionPrincipal(makeReq(undefined))).toBeNull();
});
it('returns null when the session has neither principal nor legacy user', () => {
expect(readSessionPrincipal(makeReq({}))).toBeNull();
});
it('returns the principal verbatim when session.principal is set', () => {
const out = readSessionPrincipal(makeReq({ principal: PRINCIPAL }));
expect(out).toBe(PRINCIPAL);
});
describe('legacy bridge — session.user only (pre-ADR-0025 sessions)', () => {
it('synthesises a principal carrying the Portal.* values as privileges', () => {
const req = makeReq({
user: {
oid: 'legacy-oid',
tid: 'tenant-7',
username: 'legacy@example',
displayName: 'Legacy Joe',
amr: ['pwd', 'mfa'],
roles: ['Portal.Admin', 'Portal.DPO', 'editor', 'auditor'],
groups: [],
},
});
const out = readSessionPrincipal(req);
expect(out).not.toBeNull();
expect(out!.privileges).toEqual(['Portal.Admin', 'Portal.DPO']);
expect(out!.user.entraOid).toBe('legacy-oid');
expect(out!.user.tenantId).toBe('tenant-7');
});
it('mirrors entraOid into user.id and user.personId (ADR-0026 placeholder)', () => {
const req = makeReq({
user: {
oid: 'legacy-oid',
tid: 'tenant-7',
username: 'legacy@example',
displayName: 'Legacy Joe',
amr: [],
roles: [],
groups: [],
},
});
const out = readSessionPrincipal(req);
expect(out!.user.id).toBe('legacy-oid');
expect(out!.user.personId).toBe('legacy-oid');
});
it('legacy sessions get no functional roles and a coarse unrestricted scope', () => {
const req = makeReq({
user: {
oid: 'legacy-oid',
tid: 'tenant-7',
username: 'legacy@example',
displayName: 'Legacy Joe',
amr: [],
roles: ['Portal.Admin'],
groups: [],
},
});
const out = readSessionPrincipal(req);
expect(out!.roles).toEqual([]);
expect(out!.scopes).toEqual([{ kind: 'unrestricted' }]);
});
});
});
describe('scopesToAuditStrings', () => {
it('renders implicit scopes as the kind verbatim', () => {
const scopes: Scope[] = [{ kind: 'self' }, { kind: 'siege' }, { kind: 'unrestricted' }];
expect(scopesToAuditStrings(scopes)).toEqual(['self', 'siege', 'unrestricted']);
});
it('renders value-bearing scopes as kind:value', () => {
const scopes: Scope[] = [
{ kind: 'etablissement', value: '0330800013' },
{ kind: 'delegation', value: '33' },
{ kind: 'region', value: '75' },
];
expect(scopesToAuditStrings(scopes)).toEqual([
'etablissement:0330800013',
'delegation:33',
'region:75',
]);
});
it('returns an empty array on an empty scope list', () => {
expect(scopesToAuditStrings([])).toEqual([]);
});
});
@@ -0,0 +1,74 @@
import type { Request } from 'express';
import type { Principal, Scope } from 'shared-auth';
/**
* Reads the authorization `Principal` off the express session,
* coalescing the legacy session-shape carryover so a session that
* was minted before the ADR-0025 implementation landed still works
* for the duration of its 12 h absolute TTL.
*
* Returns `null` when no session is present at all (the guard
* should answer 401 — the user is anonymous). Returns a
* synthesised `Principal` derived from `session.user` when the
* session is authenticated but predates the `principal` field —
* the synthesised principal carries the `Portal.*` privileges
* from the legacy `roles` claim, no functional roles (the
* `groups` claim was unconfigured at the time), and an
* `unrestricted` scope. After every persisted session has cycled
* through the absolute-timeout window post-deploy, this fallback
* is dead code.
*/
export function readSessionPrincipal(req: Request): Principal | null {
const session = req.session;
if (session === undefined) {
return null;
}
if (session.principal !== undefined) {
return session.principal;
}
const user = session.user;
if (user === undefined) {
return null;
}
// Legacy session bridge. Filter `user.roles` through a generic
// `Portal.*` heuristic rather than re-importing `isPrivilege`
// here — the file's whole purpose is graceful degradation; if
// the legacy claim carries a non-catalogue Portal.X the matcher
// downstream just returns false on it.
const portalPrivileges = user.roles.filter((r) => r.startsWith('Portal.'));
const fallback: Principal = {
user: {
id: user.oid,
personId: user.oid,
entraOid: user.oid,
tenantId: user.tid,
displayName: user.displayName,
},
privileges: portalPrivileges as Principal['privileges'],
roles: [],
scopes: [{ kind: 'unrestricted' } satisfies Scope],
amr: user.amr,
};
return fallback;
}
/**
* Format a scope list as the `string[]` shape the
* `AuthorizationDeniedInput.held` audit field expects:
* `unrestricted` / `self` / `siege` ride as-is;
* `etablissement:<finess>` / `delegation:<dept>` / `region:<insee>`
* concatenate kind and value. Mirrors `PrincipalProjector` in
* spirit but is independent (the audit module must not depend on
* the AI-bridge projector — different surfaces).
*/
export function scopesToAuditStrings(scopes: ReadonlyArray<Scope>): string[] {
return scopes.map((s) => {
if (s.kind === 'etablissement' || s.kind === 'delegation' || s.kind === 'region') {
return `${s.kind}:${s.value}`;
}
return s.kind;
});
}
@@ -0,0 +1,33 @@
import { SetMetadata, UseGuards, applyDecorators } from '@nestjs/common';
import type { Privilege } from 'shared-auth';
import { RequirePrivilegeGuard } from './require-privilege.guard';
/**
* Reflector metadata key carrying the privilege list a route
* requires. Re-exported by the guard module so the two stay in
* lockstep.
*/
export const REQUIRE_PRIVILEGE_METADATA = 'auth:require-privilege';
/**
* `@RequirePrivilege('Portal.X', ...)` — gates a route on
* `principal.privileges` containing at least one of the listed
* `Portal.*` values per [ADR-0025 §"Guard surface"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
*
* Multiple slugs compose as **OR** within the decorator. Stacking
* `@RequirePrivilege` with another guard (`@RequireRole`,
* `@RequireMfa`) **AND**-combines at the Nest level — each guard
* runs separately and a single denial stops the request.
*
* Calling with an empty privilege list is rejected at the type
* level by the `[Privilege, ...Privilege[]]` tuple — a route can
* not opt out of authorization by passing zero values.
*/
export function RequirePrivilege(
...privileges: [Privilege, ...Privilege[]]
): ClassDecorator & MethodDecorator {
return applyDecorators(
SetMetadata(REQUIRE_PRIVILEGE_METADATA, privileges),
UseGuards(RequirePrivilegeGuard),
);
}
@@ -0,0 +1,133 @@
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import type { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import type { Principal, Privilege } from 'shared-auth';
import type { AuditWriter } from '../audit/audit.service';
import { RequirePrivilegeGuard } from './require-privilege.guard';
function principalFor(privileges: readonly Privilege[]): Principal {
return {
user: {
id: 'user-1',
personId: 'user-1',
entraOid: 'oid-1',
tenantId: 'tenant-1',
displayName: 'Alice',
},
privileges,
roles: [],
scopes: [{ kind: 'unrestricted' }],
amr: ['pwd', 'mfa'],
};
}
function makeContext(opts: {
metadata: readonly Privilege[] | undefined;
session?: { principal?: Principal };
method?: string;
url?: string;
}): { ctx: ExecutionContext; req: Request } {
const req = {
session: opts.session,
method: opts.method ?? 'GET',
originalUrl: opts.url ?? '/api/audit',
} as unknown as Request;
const ctx = {
switchToHttp: () => ({ getRequest: <T>() => req as T }),
getHandler: () => 'handler',
getClass: () => 'AuditController',
} as unknown as ExecutionContext;
return { ctx, req };
}
function makeGuard(opts: { metadata: readonly Privilege[] | undefined; audit?: jest.Mock }): {
guard: RequirePrivilegeGuard;
audit: jest.Mock;
} {
const audit = opts.audit ?? jest.fn().mockResolvedValue(undefined);
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(opts.metadata),
} as unknown as Reflector;
const writer = { authorizationDenied: audit } as unknown as AuditWriter;
return { guard: new RequirePrivilegeGuard(reflector, writer), audit };
}
describe('RequirePrivilegeGuard', () => {
it('passes when no metadata is set (decorator absent — guard wired but route unprotected)', async () => {
const { guard } = makeGuard({ metadata: undefined });
const { ctx } = makeContext({ metadata: undefined });
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('throws 401 when no session is present', async () => {
const { guard, audit } = makeGuard({ metadata: ['Portal.Admin'] });
const { ctx } = makeContext({ metadata: ['Portal.Admin'], session: undefined });
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(UnauthorizedException);
expect(audit).not.toHaveBeenCalled();
});
it('passes when the principal carries the required privilege', async () => {
const { guard } = makeGuard({ metadata: ['Portal.Admin'] });
const { ctx } = makeContext({
metadata: ['Portal.Admin'],
session: { principal: principalFor(['Portal.Admin']) },
});
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('OR-combines multiple required privileges (matches any one)', async () => {
const { guard } = makeGuard({
metadata: ['Portal.Auditor', 'Portal.Admin'],
});
const { ctx } = makeContext({
metadata: ['Portal.Auditor', 'Portal.Admin'],
session: { principal: principalFor(['Portal.Auditor']) },
});
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('throws 403 + audits when the principal lacks every required privilege', async () => {
const { guard, audit } = makeGuard({ metadata: ['Portal.Admin'] });
const { ctx } = makeContext({
metadata: ['Portal.Admin'],
session: { principal: principalFor(['Portal.Auditor']) },
method: 'POST',
url: '/api/admin/cms/pages',
});
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
expect(audit).toHaveBeenCalledWith({
actor: { oid: 'oid-1' },
attemptedRoute: 'POST /api/admin/cms/pages',
kind: 'privilege',
required: ['Portal.Admin'],
held: ['Portal.Auditor'],
});
});
it('audits the held privileges as the principal saw them, not the legacy roles claim', async () => {
const { guard, audit } = makeGuard({ metadata: ['Portal.Admin'] });
const { ctx } = makeContext({
metadata: ['Portal.Admin'],
session: { principal: principalFor([]) },
});
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
expect(audit).toHaveBeenCalledWith(expect.objectContaining({ held: [] }));
});
it('returns a generic forbidden body (no privilege hint leaks)', async () => {
const { guard } = makeGuard({ metadata: ['Portal.Admin'] });
const { ctx } = makeContext({
metadata: ['Portal.Admin'],
session: { principal: principalFor([]) },
});
try {
await guard.canActivate(ctx);
throw new Error('guard did not throw');
} catch (err) {
const body = (err as ForbiddenException).getResponse() as Record<string, unknown>;
expect(body['code']).toBe('forbidden');
expect(body['message']).toBe('Forbidden');
expect(JSON.stringify(body)).not.toContain('Portal.Admin');
}
});
});
@@ -0,0 +1,81 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import { type Privilege, principalHasAnyPrivilege } from 'shared-auth';
import { AuditWriter } from '../audit/audit.service';
import { readSessionPrincipal } from './principal-extractor';
import { REQUIRE_PRIVILEGE_METADATA } from './require-privilege.decorator';
/**
* `RequirePrivilegeGuard` — enforces ADR-0025 privilege gates.
*
* Contract (matches `AdminRoleGuard` for symmetry):
*
* - **No session → 401 `unauthenticated`.** Anonymous traffic is
* noise; no audit row, the SPA kicks off the login flow.
*
* - **Session but missing every required privilege → 403 + audit.**
* The user *is* authenticated; they just are not authorised.
* Audit row: `auth.authorization_denied`, `kind=privilege`,
* `required` = what the route asked for, `held` = what the
* principal actually carries.
*
* - **Session with at least one required privilege → pass.**
*
* The structured error envelope per [ADR-0021](../../../../docs/decisions/0021-phase-2-security-baseline.md):
* the body is `{ error: { code: 'forbidden', message, traceId } }`
* with a generic message — no role / privilege hint, so an
* attacker cannot use 403 responses to enumerate which privilege
* a route gates.
*/
@Injectable()
export class RequirePrivilegeGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly audit: AuditWriter,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const required = this.reflector.getAllAndOverride<readonly Privilege[]>(
REQUIRE_PRIVILEGE_METADATA,
[context.getHandler(), context.getClass()],
);
if (required === undefined || required.length === 0) {
// No metadata = guard wired but route un-annotated. Treat as
// pass-through rather than guess at the intent — the decorator
// is what carries the privilege list.
return true;
}
const req = context.switchToHttp().getRequest<Request>();
const principal = readSessionPrincipal(req);
if (principal === null) {
throw new UnauthorizedException({
code: 'unauthenticated',
message: 'Unauthenticated',
});
}
if (principalHasAnyPrivilege(principal, required)) {
return true;
}
await this.audit.authorizationDenied({
actor: { oid: principal.user.entraOid },
attemptedRoute: `${req.method} ${req.originalUrl}`,
kind: 'privilege',
required,
held: [...principal.privileges],
});
throw new ForbiddenException({
code: 'forbidden',
message: 'Forbidden',
});
}
}
@@ -0,0 +1,29 @@
import { SetMetadata, UseGuards, applyDecorators } from '@nestjs/common';
import type { FunctionalRole } from 'shared-auth';
import { RequireRoleGuard } from './require-role.guard';
/**
* Reflector metadata key carrying the functional-role list a
* route requires.
*/
export const REQUIRE_ROLE_METADATA = 'auth:require-role';
/**
* `@RequireRole('rh', ...)` — gates a route on `principal.roles`
* containing at least one of the listed `apf-role-*` slugs per
* [ADR-0025 §"Guard surface"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
*
* Multiple slugs compose as **OR** within the decorator. Stacking
* `@RequireRole` with another guard (`@RequirePrivilege`,
* `@RequireScope`, `@RequireMfa`) **AND**-combines at the Nest
* level.
*
* The empty-list call is rejected at the type level by the
* `[FunctionalRole, ...FunctionalRole[]]` tuple — a route can
* not opt out of authorization by passing zero values.
*/
export function RequireRole(
...roles: [FunctionalRole, ...FunctionalRole[]]
): ClassDecorator & MethodDecorator {
return applyDecorators(SetMetadata(REQUIRE_ROLE_METADATA, roles), UseGuards(RequireRoleGuard));
}
@@ -0,0 +1,111 @@
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import type { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import type { FunctionalRole, Principal } from 'shared-auth';
import type { AuditWriter } from '../audit/audit.service';
import { RequireRoleGuard } from './require-role.guard';
function principalFor(roles: readonly FunctionalRole[]): Principal {
return {
user: {
id: 'user-1',
personId: 'user-1',
entraOid: 'oid-1',
tenantId: 'tenant-1',
displayName: 'Alice',
},
privileges: [],
roles,
scopes: [{ kind: 'unrestricted' }],
amr: ['pwd', 'mfa'],
};
}
function makeContext(opts: {
session?: { principal?: Principal };
method?: string;
url?: string;
}): { ctx: ExecutionContext; req: Request } {
const req = {
session: opts.session,
method: opts.method ?? 'GET',
originalUrl: opts.url ?? '/api/hr/payroll',
} as unknown as Request;
const ctx = {
switchToHttp: () => ({ getRequest: <T>() => req as T }),
getHandler: () => 'handler',
getClass: () => 'HrController',
} as unknown as ExecutionContext;
return { ctx, req };
}
function makeGuard(opts: { metadata: readonly FunctionalRole[] | undefined }): {
guard: RequireRoleGuard;
audit: jest.Mock;
} {
const audit = jest.fn().mockResolvedValue(undefined);
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(opts.metadata),
} as unknown as Reflector;
const writer = { authorizationDenied: audit } as unknown as AuditWriter;
return { guard: new RequireRoleGuard(reflector, writer), audit };
}
describe('RequireRoleGuard', () => {
it('passes when no metadata is set', async () => {
const { guard } = makeGuard({ metadata: undefined });
const { ctx } = makeContext({});
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('throws 401 when no session is present', async () => {
const { guard, audit } = makeGuard({ metadata: ['rh'] });
const { ctx } = makeContext({ session: undefined });
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(UnauthorizedException);
expect(audit).not.toHaveBeenCalled();
});
it('passes when the principal carries the required role', async () => {
const { guard } = makeGuard({ metadata: ['rh'] });
const { ctx } = makeContext({ session: { principal: principalFor(['rh']) } });
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('OR-combines multiple required roles', async () => {
const { guard } = makeGuard({ metadata: ['rh', 'responsable-paie', 'comptable'] });
const { ctx } = makeContext({
session: { principal: principalFor(['comptable']) },
});
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('throws 403 + audits when the principal lacks every required role', async () => {
const { guard, audit } = makeGuard({ metadata: ['rh'] });
const { ctx } = makeContext({
session: { principal: principalFor(['collaborateur']) },
method: 'GET',
url: '/api/hr/payroll',
});
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
expect(audit).toHaveBeenCalledWith({
actor: { oid: 'oid-1' },
attemptedRoute: 'GET /api/hr/payroll',
kind: 'role',
required: ['rh'],
held: ['collaborateur'],
});
});
it('returns a generic forbidden body (no role hint leaks)', async () => {
const { guard } = makeGuard({ metadata: ['rh'] });
const { ctx } = makeContext({ session: { principal: principalFor([]) } });
try {
await guard.canActivate(ctx);
throw new Error('guard did not throw');
} catch (err) {
const body = (err as ForbiddenException).getResponse() as Record<string, unknown>;
expect(body['code']).toBe('forbidden');
expect(JSON.stringify(body)).not.toContain('rh');
}
});
});
@@ -0,0 +1,65 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import { type FunctionalRole, principalHasAnyRole } from 'shared-auth';
import { AuditWriter } from '../audit/audit.service';
import { readSessionPrincipal } from './principal-extractor';
import { REQUIRE_ROLE_METADATA } from './require-role.decorator';
/**
* `RequireRoleGuard` — enforces ADR-0025 functional-role gates.
*
* Same contract as `RequirePrivilegeGuard` with the role axis as
* the source of truth: 401 if anonymous, 403 + audit if
* authenticated but missing every required role, pass otherwise.
* Multiple roles on the decorator are OR-combined; stacking with
* other decorators is AND-combined at the Nest layer.
*/
@Injectable()
export class RequireRoleGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly audit: AuditWriter,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const required = this.reflector.getAllAndOverride<readonly FunctionalRole[]>(
REQUIRE_ROLE_METADATA,
[context.getHandler(), context.getClass()],
);
if (required === undefined || required.length === 0) {
return true;
}
const req = context.switchToHttp().getRequest<Request>();
const principal = readSessionPrincipal(req);
if (principal === null) {
throw new UnauthorizedException({
code: 'unauthenticated',
message: 'Unauthenticated',
});
}
if (principalHasAnyRole(principal, required)) {
return true;
}
await this.audit.authorizationDenied({
actor: { oid: principal.user.entraOid },
attemptedRoute: `${req.method} ${req.originalUrl}`,
kind: 'role',
required,
held: [...principal.roles],
});
throw new ForbiddenException({
code: 'forbidden',
message: 'Forbidden',
});
}
}
@@ -0,0 +1,52 @@
import { SetMetadata, UseGuards, applyDecorators } from '@nestjs/common';
import type { Request } from 'express';
import type { ScopableResource } from 'shared-auth';
import { RequireScopeGuard } from './require-scope.guard';
/**
* Reflector metadata key carrying the per-route resource
* extractor. The metadata value is the extractor function
* itself — `Reflect.metadata` happily stores callables and the
* guard invokes it on every request.
*/
export const REQUIRE_SCOPE_METADATA = 'auth:require-scope';
/**
* Extractor signature consumed by `@RequireScope`. Receives the
* incoming Express request, returns the resource descriptor the
* matcher will compare against the principal's scopes. May be
* async — a typical extractor loads the resource by
* `@Param('id')` from Prisma, denormalises its parentage chain
* (`etablissementFiness` / `delegationCode` / `regionCode`), and
* returns the descriptor.
*
* Returning `null` from the extractor means "the resource the
* route protects could not be identified" (a 404 in disguise) —
* the guard treats it as a denial and emits the audit row with
* `required: []`. The route handler still runs if the guard
* lets it through; routes that want a true 404 should surface
* it from the handler.
*/
export type RequireScopeExtractor = (
req: Request,
) => ScopableResource | null | Promise<ScopableResource | null>;
/**
* `@RequireScope(req => …)` — gates a route on
* `principalCoversResource(principal, extractor(req))` per
* [ADR-0025 §"Guard surface"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
*
* The extractor receives the Express request — typically pulls
* an id from `req.params`, loads the resource, returns its
* scope-relevant chain (FINESS / delegation / region). The guard
* does not assume the extractor is cheap — routes that load the
* resource a second time inside the handler should pass the
* Prisma instance via a request-scoped CLS or DI, not via the
* extractor.
*/
export function RequireScope(extractor: RequireScopeExtractor): ClassDecorator & MethodDecorator {
return applyDecorators(
SetMetadata(REQUIRE_SCOPE_METADATA, extractor),
UseGuards(RequireScopeGuard),
);
}
@@ -0,0 +1,156 @@
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import type { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import type { Principal, ScopableResource, Scope } from 'shared-auth';
import type { AuditWriter } from '../audit/audit.service';
import type { RequireScopeExtractor } from './require-scope.decorator';
import { RequireScopeGuard } from './require-scope.guard';
function principalFor(scopes: readonly Scope[]): Principal {
return {
user: {
id: 'user-1',
personId: 'person-1',
entraOid: 'oid-1',
tenantId: 'tenant-1',
displayName: 'Alice',
},
privileges: [],
roles: [],
scopes,
amr: ['pwd', 'mfa'],
};
}
function makeContext(opts: {
session?: { principal?: Principal };
method?: string;
url?: string;
}): { ctx: ExecutionContext; req: Request } {
const req = {
session: opts.session,
method: opts.method ?? 'GET',
originalUrl: opts.url ?? '/api/etablissements/0330800013',
} as unknown as Request;
const ctx = {
switchToHttp: () => ({ getRequest: <T>() => req as T }),
getHandler: () => 'handler',
getClass: () => 'EtablissementController',
} as unknown as ExecutionContext;
return { ctx, req };
}
function makeGuard(opts: { extractor: RequireScopeExtractor | undefined }): {
guard: RequireScopeGuard;
audit: jest.Mock;
} {
const audit = jest.fn().mockResolvedValue(undefined);
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(opts.extractor),
} as unknown as Reflector;
const writer = { authorizationDenied: audit } as unknown as AuditWriter;
return { guard: new RequireScopeGuard(reflector, writer), audit };
}
describe('RequireScopeGuard', () => {
it('passes when no extractor metadata is set', async () => {
const { guard } = makeGuard({ extractor: undefined });
const { ctx } = makeContext({});
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('throws 401 when no session is present', async () => {
const { guard, audit } = makeGuard({
extractor: () => ({ etablissementFiness: '0330800013' }),
});
const { ctx } = makeContext({ session: undefined });
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(UnauthorizedException);
expect(audit).not.toHaveBeenCalled();
});
it('passes when the principal scope covers the resource', async () => {
const { guard } = makeGuard({
extractor: () => ({ etablissementFiness: '0330800013' }),
});
const { ctx } = makeContext({
session: {
principal: principalFor([{ kind: 'etablissement', value: '0330800013' }]),
},
});
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('honours async extractors (Prisma lookup pattern)', async () => {
const { guard } = makeGuard({
extractor: async () => Promise.resolve({ delegationCode: '33' }),
});
const { ctx } = makeContext({
session: { principal: principalFor([{ kind: 'delegation', value: '33' }]) },
});
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('throws 403 + audits when the resource is outside the principal scopes', async () => {
const { guard, audit } = makeGuard({
extractor: () => ({ etablissementFiness: '0330800021' }),
});
const { ctx } = makeContext({
session: {
principal: principalFor([{ kind: 'etablissement', value: '0330800013' }]),
},
});
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
expect(audit).toHaveBeenCalledWith({
actor: { oid: 'oid-1' },
attemptedRoute: 'GET /api/etablissements/0330800013',
kind: 'scope',
required: ['etablissement:0330800021'],
held: ['etablissement:0330800013'],
});
});
it('throws 403 + audits with empty required when the extractor returns null', async () => {
const { guard, audit } = makeGuard({ extractor: () => null });
const { ctx } = makeContext({
session: { principal: principalFor([{ kind: 'unrestricted' }]) },
});
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
expect(audit).toHaveBeenCalledWith(expect.objectContaining({ kind: 'scope', required: [] }));
});
it('describes the resource correctly when it carries the full parentage chain', async () => {
const resource: ScopableResource = {
etablissementFiness: '0330800021',
delegationCode: '33',
regionCode: '75',
};
const { guard, audit } = makeGuard({ extractor: () => resource });
const { ctx } = makeContext({
session: { principal: principalFor([{ kind: 'region', value: '11' }]) },
});
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
expect(audit).toHaveBeenCalledWith(
expect.objectContaining({
required: ['etablissement:0330800021', 'delegation:33', 'region:75'],
held: ['region:11'],
}),
);
});
it('returns a generic forbidden body (no resource fingerprint leaks)', async () => {
const { guard } = makeGuard({
extractor: () => ({ etablissementFiness: '0330800021' }),
});
const { ctx } = makeContext({
session: { principal: principalFor([]) },
});
try {
await guard.canActivate(ctx);
throw new Error('guard did not throw');
} catch (err) {
const body = (err as ForbiddenException).getResponse() as Record<string, unknown>;
expect(body['code']).toBe('forbidden');
expect(JSON.stringify(body)).not.toContain('0330800021');
}
});
});
@@ -0,0 +1,119 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import { principalCoversResource, type ScopableResource } from 'shared-auth';
import { AuditWriter } from '../audit/audit.service';
import { readSessionPrincipal, scopesToAuditStrings } from './principal-extractor';
import { REQUIRE_SCOPE_METADATA, type RequireScopeExtractor } from './require-scope.decorator';
/**
* `RequireScopeGuard` — enforces ADR-0025 scope gates.
*
* Contract
* --------
* - **No session → 401.** Same posture as the privilege / role
* guards.
* - **Extractor returned `null` → 403 + audit.** The route's
* "what resource am I protecting?" question has no answer for
* this request; deny is the safe direction. Audit row carries
* `required: []` so an auditor can spot the missing-resource
* pattern.
* - **No matching scope → 403 + audit.** Standard denial; audit
* captures the resource descriptor as `required` and the
* principal's actual scope list as `held`.
* - **Matching scope → pass.**
*
* Like the other two new guards, the response body is the
* ADR-0021 structured-error envelope with a generic `forbidden`
* code — no resource fingerprint leaks back to the caller.
*/
@Injectable()
export class RequireScopeGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly audit: AuditWriter,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const extractor = this.reflector.getAllAndOverride<RequireScopeExtractor>(
REQUIRE_SCOPE_METADATA,
[context.getHandler(), context.getClass()],
);
if (extractor === undefined) {
return true;
}
const req = context.switchToHttp().getRequest<Request>();
const principal = readSessionPrincipal(req);
if (principal === null) {
throw new UnauthorizedException({
code: 'unauthenticated',
message: 'Unauthenticated',
});
}
const resource = await extractor(req);
if (resource === null) {
await this.audit.authorizationDenied({
actor: { oid: principal.user.entraOid },
attemptedRoute: `${req.method} ${req.originalUrl}`,
kind: 'scope',
required: [],
held: scopesToAuditStrings(principal.scopes),
});
throw new ForbiddenException({
code: 'forbidden',
message: 'Forbidden',
});
}
if (principalCoversResource(principal, resource)) {
return true;
}
await this.audit.authorizationDenied({
actor: { oid: principal.user.entraOid },
attemptedRoute: `${req.method} ${req.originalUrl}`,
kind: 'scope',
required: describeResource(resource),
held: scopesToAuditStrings(principal.scopes),
});
throw new ForbiddenException({
code: 'forbidden',
message: 'Forbidden',
});
}
}
/**
* Serialise the resource descriptor into the `string[]` shape the
* audit row's `required` field expects. Mirrors the `scope:value`
* convention of {@link scopesToAuditStrings} so the two sides of
* the audit row read consistently — easy `requiredVs.held` diff
* in a log dashboard.
*/
function describeResource(resource: ScopableResource): string[] {
const out: string[] = [];
if (resource.personId !== undefined) {
out.push(`self:${resource.personId}`);
}
if (resource.etablissementFiness !== undefined) {
out.push(`etablissement:${resource.etablissementFiness}`);
}
if (resource.delegationCode !== undefined) {
out.push(`delegation:${resource.delegationCode}`);
}
if (resource.regionCode !== undefined) {
out.push(`region:${resource.regionCode}`);
}
if (resource.isSiege === true) {
out.push('siege');
}
return out;
}
+30 -55
View File
@@ -1,82 +1,57 @@
import { UnauthorizedException } from '@nestjs/common';
import type { Request } from 'express';
import type { Principal, Privilege } from 'shared-auth';
import { MeController } from './me.controller';
function makeReq(
user: {
oid: string;
tid: string;
username: string;
displayName: string;
amr: readonly string[];
roles: readonly string[];
} | null,
privileges: readonly Privilege[] | null,
overrides: { displayName?: string } = {},
): Request {
return { session: { user } } as unknown as Request;
if (privileges === null) {
return { session: {} } as unknown as Request;
}
const principal: Principal = {
user: {
id: 'user-oid',
personId: 'user-oid',
entraOid: 'user-oid',
tenantId: 'tenant-1',
displayName: overrides.displayName ?? 'Jane',
},
privileges,
roles: [],
scopes: [{ kind: 'unrestricted' }],
amr: ['pwd', 'mfa'],
};
return { session: { principal } } as unknown as Request;
}
describe('MeController', () => {
const controller = new MeController();
it('returns canAccessAdmin: true when the session carries Portal.Admin', () => {
const result = controller.capabilities(
makeReq({
oid: 'oid-1',
tid: 'tenant-1',
username: 'admin@apf.example',
displayName: 'Admin Smith',
amr: ['pwd', 'mfa'],
roles: ['Portal.Admin', 'Other.Role'],
}),
);
it('returns canAccessAdmin: true when the principal carries Portal.Admin', () => {
const result = controller.capabilities(makeReq(['Portal.Admin', 'Portal.DPO']));
expect(result).toEqual({ canAccessAdmin: true });
});
it('returns canAccessAdmin: false when the session lacks Portal.Admin', () => {
const result = controller.capabilities(
makeReq({
oid: 'oid-2',
tid: 'tenant-1',
username: 'jane.doe@apf.example',
displayName: 'Jane Doe',
amr: ['pwd'],
roles: ['Other.Role'],
}),
);
it('returns canAccessAdmin: false when the principal carries other privileges only', () => {
const result = controller.capabilities(makeReq(['Portal.Auditor']));
expect(result).toEqual({ canAccessAdmin: false });
});
it('returns canAccessAdmin: false when the session carries no roles', () => {
const result = controller.capabilities(
makeReq({
oid: 'oid-3',
tid: 'tenant-1',
username: 'guest@apf.example',
displayName: 'Guest',
amr: [],
roles: [],
}),
);
it('returns canAccessAdmin: false when the principal has no privileges', () => {
const result = controller.capabilities(makeReq([]));
expect(result).toEqual({ canAccessAdmin: false });
});
it('throws Unauthorized when no session is present', () => {
it('throws Unauthorized when no session principal is present', () => {
expect(() => controller.capabilities(makeReq(null))).toThrow(UnauthorizedException);
});
it('does NOT echo the raw roles array in the response (curated view contract)', () => {
const result = controller.capabilities(
makeReq({
oid: 'oid-4',
tid: 'tenant-1',
username: 'admin@apf.example',
displayName: 'Admin',
amr: ['pwd', 'mfa'],
roles: ['Portal.Admin', 'Confidential.Role'],
}),
);
it('does NOT echo the raw privileges array in the response (curated view contract)', () => {
const result = controller.capabilities(makeReq(['Portal.Admin', 'Portal.DPO']));
expect(Object.keys(result)).toEqual(['canAccessAdmin']);
expect(JSON.stringify(result)).not.toContain('Confidential.Role');
expect(JSON.stringify(result)).not.toContain('Portal.DPO');
expect(JSON.stringify(result)).not.toContain('Portal.Admin');
});
});
+4 -3
View File
@@ -1,6 +1,7 @@
import { Controller, Get, Req, UnauthorizedException } from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { readSessionPrincipal } from '../auth/principal-extractor';
/**
* Curated capabilities derived from the active user-portal session,
@@ -61,8 +62,8 @@ export class MeController {
})
@Get('capabilities')
capabilities(@Req() req: Request): CapabilitiesView {
const user = req.session.user;
if (!user) {
const principal = readSessionPrincipal(req);
if (principal === null) {
// Same posture as `/api/auth/me`: 401 (rather than 200 with
// every-flag-false) so the SPA can distinguish "no session"
// from "session exists but no flags". Keeps the consumer code
@@ -73,7 +74,7 @@ export class MeController {
});
}
return {
canAccessAdmin: user.roles.includes(ADMIN_ROLE),
canAccessAdmin: principal.privileges.includes(ADMIN_ROLE),
};
}
}
+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;
}