feat(auth): @RequirePrivilege/@RequireRole/@RequireScope guards (ADR-0025) (#208)
CI / check (push) Failing after 4m35s
CI / commits (push) Has been skipped
CI / scan (push) Failing after 3m52s
CI / a11y (push) Successful in 1m59s
CI / perf (push) Successful in 5m50s

## Summary

Phase 2 of [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md)'s implementation phasing (§"More Information"). The three new route decorators land — `@RequirePrivilege`, `@RequireRole`, `@RequireScope` — alongside the migration of the legacy `@RequireAdmin` to read from `principal.privileges`. Each guard consumes the session-resident `Principal` built by [#206](#206), evaluates its requirement, and either passes or emits a 403 + audit row.

The drift-CI gate (phase 3) is **not** in this PR — it lands next, once the catalogue is being referenced from real route handlers.

## What lands

### Shared lib (`libs/shared/auth`)

| File | Role |
| --- | --- |
| `src/lib/principal-matchers.ts` | Pure functions: `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. |
| Same file | `ScopableResource` type — optional FINESS / delegation / region / siege parentage. Callers populate the subset their route resource exposes; the matcher iterates and returns true on the first scope that covers a present field. |
| `src/lib/principal-matchers.spec.ts` | 24 unit tests covering OR-composition, the empty-requirement degenerate case (treated as "no constraint" — pass), every scope kind, and the resource-side parentage chain. |

### BFF guards + decorators (`apps/portal-bff/src/auth`)

| File | Role |
| --- | --- |
| `require-privilege.{decorator,guard}.ts` | `@RequirePrivilege('Portal.Admin', ...)` — type-locked to the closed `Privilege` catalogue; multiple values OR-combine. |
| `require-role.{decorator,guard}.ts` | `@RequireRole('rh', ...)` — same shape, against `FunctionalRole`. |
| `require-scope.{decorator,guard}.ts` | `@RequireScope(req => extractResource(req))` — extractor can be async (Prisma lookup pattern); returning `null` is treated as denial with empty `required[]`. |
| `principal-extractor.ts` | Single read of `Principal` off the session, with a legacy-session bridge for principals minted before [#206](#206) landed — filters `user.roles` for `Portal.*` values and synthesises an `unrestricted` scope. After the 12 h absolute-TTL window post-deploy, the bridge becomes dead code. |
| `principal-extractor.spec.ts` | 7 tests covering both code paths. |
| `require-{privilege,role,scope}.guard.spec.ts` | Unit tests for each guard: 401 on anonymous, 403 + audit on denial, pass on match, generic `forbidden` response body (no role/privilege/resource hint leaks). |
| `auth-guards.persona-matrix.spec.ts` | Integration tests: each of the 19 test-tenant personas walked through 5 representative privilege guards + 6 representative role guards. The matrix proves the contracts hold against the real provisioning, not just synthesised principals. |

### Audit module

| File | Change |
| --- | --- |
| `audit.types.ts` | New `AuthorizationDeniedInput` discriminated by `kind: 'privilege' \| 'role' \| 'scope'`. Carries `required[]` and `held[]` arrays so an auditor can pivot on "tried admin while logged in as RH" patterns without joining anything. |
| `audit.service.ts` | New `authorizationDenied()` method emitting the `auth.authorization_denied` event type. Distinct from the existing `admin.access_denied` so admin-surface signals stay clean. |
| `audit.service.spec.ts` | 3 new tests covering the three `kind` values. |

### Legacy guard migration

| File | Change |
| --- | --- |
| `admin/admin-role.guard.ts` | Reads `principal.privileges` instead of `user.roles`. Public `@RequireAdmin()` API unchanged; `admin.access_denied` event type unchanged. The audit row's `rolesHeld` field now carries `Portal.*` values rather than the raw legacy `roles` claim. |
| `admin/admin-role.guard.spec.ts` | Rewritten to exercise `session.principal`-shaped sessions, with a dedicated legacy-bridge sub-suite that asserts pre-ADR-0025 sessions still work. |
| `me/me.controller.ts` | `capabilities().canAccessAdmin` reads `principal.privileges` via the same extractor. |
| `me/me.controller.spec.ts` | Rewritten against the principal shape. |

### AuthModule wiring

| File | Change |
| --- | --- |
| `auth/auth.module.ts` | Three new guards registered as providers and re-exported. |

## Notes for the reviewer

- **Composition semantics.** Within a single decorator, values OR-combine (`@RequireRole('rh', 'comptable')` matches anyone with either). Stacking decorators AND-combines at the Nest level — `@RequireRole('rh') @RequirePrivilege('Portal.Admin')` requires both, because each guard runs separately and a single denial stops the request. The empty-requirement case is rejected at the type level by the tuple `[Privilege, ...Privilege[]]` so a route can not opt out of authorization by passing zero values.

- **Why `auth.authorization_denied` and not `admin.access_denied` for the new guards.** ADR-0025 §347 originally said "writes an `admin.access_denied` row", but reusing the `admin.*` namespace would have meant the future `@RequireRole('rh')` on a non-admin HR route ships an event whose type implies an admin surface. The new event type lives in the `auth.*` namespace where the guards live; a `kind` discriminator on the payload tells privilege / role / scope denials apart. The legacy `admin.access_denied` event continues unchanged for `AdminRoleGuard`.

- **The legacy-session bridge is short-lived.** After the 12 h absolute-TTL window from this PR's deploy, every session in Redis has a `principal` field and the bridge's `else` branch becomes unreachable. The bridge keeps `@RequireAdmin` + `MeController` functional during that window without needing a forced re-auth campaign.

- **Why a `ScopableResource` shape rather than a `Resource | Resource | ...` union per kind.** Routes protect heterogeneous resource types (`Etablissement`, `Dossier`, `Person`), each exposing a different subset of the parentage chain. The optional-field shape lets each route populate what its resource carries and lets the matcher iterate over the principal's scopes once. The cost is that a route author who forgets to populate `delegationCode` on an `Etablissement` resource locks delegation-scoped users out — a typing exercise that the next round of work (concrete consumers) will surface naturally.

- **Why `principalCoversResource` deny on doubt.** When a route's extractor returns a resource that lacks the parentage `delegationCode` field, a `delegation:33` scope no longer matches — deny is the safe direction. An over-restrictive deny shows up in the audit log (operator can fix the extractor); an over-permissive pass would silently leak data. The non-transitivity of the parentage chain is an intentional contract.

- **Why migrate `MeController` in the same PR.** The `canAccessAdmin` flag reads the same axis (`Portal.Admin` privilege). Leaving it on the legacy `user.roles` shape while everything else moves to `principal.privileges` would create internal inconsistency; a future reviewer would rightfully ask "why?". The migration is three lines plus a spec rewrite.

- **The drift-CI gate is the next PR.** ADR-0025 §"More Information" step 3. ESLint custom rule (or a `pnpm run` script) grepping every `@RequirePrivilege('...')` / `@RequireRole('...')` / scope-kind literal in the codebase and asserting each one exists in the catalogue. Now there is something to grep for (the decorators and their tests reference catalogue values), so the gate is well-scoped to land standalone.

- **No real consumers of `@RequireScope` yet.** The scope guard ships with a fully exercised contract (extractor signature, async support, audit shape, generic-forbidden body) but no live route. The first consumer surface lands with the `Person` + `User` schema (proposed ADR-0026) and the resource-loading routes that follow.

## Test plan

- [x] `pnpm nx affected -t lint test build --base=main` — 3 projects green
  - `portal-bff`: 741 tests pass (was 497 in #206 — +244 new: matchers/guards/persona-matrix/audit/extractor).
  - `shared-auth`: 41 tests pass (was 17 in #206 — +24 new matcher tests).
  - `portal-bff-e2e`: lint green.
- [x] `pnpm nx format:check` — clean after `pnpm nx format:write`.
- [ ] **Review focus** — the 19-persona matrix (each persona walked through 5 privilege guards + 6 role guards); the legacy-session bridge in `principal-extractor.ts` and its tests; the `admin.access_denied` audit payload now carries `Portal.*` values rather than legacy `roles` (intentional, called out above); the `auth.authorization_denied` event-type rationale.

## What's next

Per ADR-0025 §"More Information" phasing:

1.  #206 — types + Principal builder + group-to-role mapping skeleton.
2.  **This PR** — the three new decorators + guards, legacy `@RequireAdmin` migration.
3. **Next PR** — drift CI gate. ESLint custom rule (or `pnpm run` script) that asserts every `@RequireX('...')` literal in code is in the closed catalogue.
4. **Depends on ADR-0026** — `user_scopes` Prisma table + seed + `PrismaScopeResolver` replacing `StubScopeResolver`, then the first concrete consumers of `@RequireScope`.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #208
This commit was merged in pull request #208.
This commit is contained in:
2026-05-23 21:49:34 +02:00
parent a34709613f
commit 7d91da691b
23 changed files with 1883 additions and 135 deletions
@@ -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;
}