feat(auth): @RequirePrivilege/@RequireRole/@RequireScope guards (ADR-0025)
Phase 2 of the ADR-0025 phasing per its §"More Information": the
three new route decorators + guards land alongside the legacy
@RequireAdmin migration. Each guard reads the session-resident
Principal built by the previous PR's PrincipalBuilder, evaluates
the route's requirement, and either passes or emits 403 + audit.
Shared lib (libs/shared/auth):
- principal-matchers.ts: pure principalHasAnyPrivilege /
principalHasAnyRole / principalCoversResource. The matchers
live in the shared lib so the SPA can render UI predicates
with the same logic the BFF enforces server-side. 24 unit
tests covering OR-composition, empty-requirement degenerate
case, every scope kind, and the resource-side parentage chain.
- ScopableResource type carrying optional FINESS / delegation /
region / siege parentage that callers populate from the
route's protected resource.
BFF guards + decorators (apps/portal-bff/src/auth):
- @RequirePrivilege('Portal.X', ...) + RequirePrivilegeGuard.
- @RequireRole('rh', ...) + RequireRoleGuard.
- @RequireScope(req => extract(req)) + RequireScopeGuard. The
extractor can be async (Prisma lookup pattern); returning null
is treated as a denial with empty required[].
- Multiple values within a single decorator are OR-combined;
stacking decorators is AND-combined at the Nest layer.
- All three guards 401 on anonymous, 403 + audit on
authenticated denial, pass on match. The 403 body is the
ADR-0021 structured envelope with a generic 'forbidden' code
so an attacker cannot enumerate which privilege/role/resource
a route gates.
- principal-extractor.ts factors the session->Principal read
with a legacy-session bridge for principals minted before the
previous PR landed (filters user.roles for Portal.* values
and synthesises an unrestricted scope).
Audit module:
- New AuthorizationDeniedInput type + audit.authorizationDenied()
method emitting auth.authorization_denied with a kind
discriminator (privilege / role / scope) plus required[] and
held[] arrays. Distinct from the existing admin.access_denied
event so admin-surface signals stay clean.
Legacy guard migration:
- AdminRoleGuard now reads principal.privileges instead of
user.roles. The public @RequireAdmin() API and the
admin.access_denied event type are unchanged; only the audit
row's rolesHeld field now carries Portal.* values rather than
the raw legacy roles claim.
- MeController.capabilities() likewise reads
principal.privileges for canAccessAdmin.
Tests:
- 41 shared-auth tests (24 new for matchers).
- 244 new BFF tests covering the 3 new guards, the
principal-extractor (incl. legacy bridge), the audit method,
and the 19-persona matrix.
- Total: 741 portal-bff tests, 41 shared-auth tests; full lint +
build green on nx affected.
Test plan:
- pnpm nx affected -t lint test build --base=main: 3 projects green
- pnpm nx format:check: clean
This commit is contained in:
@@ -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,21 +91,12 @@ 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: {
|
||||
user: {
|
||||
oid: 'user-oid',
|
||||
tid: 't',
|
||||
username: 'jane@example',
|
||||
displayName: 'Jane',
|
||||
amr: ['pwd'],
|
||||
roles: ['editor', 'auditor'],
|
||||
},
|
||||
},
|
||||
session: { principal: principalWithPrivileges(['Portal.Auditor']) },
|
||||
method: 'POST',
|
||||
originalUrl: '/api/admin/cms/pages',
|
||||
}),
|
||||
@@ -105,45 +105,27 @@ describe('AdminRoleGuard', () => {
|
||||
expect(audit.adminAccessDenied).toHaveBeenCalledWith({
|
||||
actor: { oid: 'user-oid' },
|
||||
attemptedRoute: 'POST /api/admin/cms/pages',
|
||||
rolesHeld: ['editor', 'auditor'],
|
||||
rolesHeld: ['Portal.Auditor'],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns true and does not audit when the user has the admin role', async () => {
|
||||
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: {
|
||||
user: {
|
||||
oid: 'user-oid',
|
||||
tid: 't',
|
||||
username: 'jane@example',
|
||||
displayName: 'Jane',
|
||||
amr: ['pwd', 'mfa'],
|
||||
roles: [ADMIN_ROLE],
|
||||
},
|
||||
},
|
||||
}),
|
||||
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 roles', async () => {
|
||||
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: {
|
||||
user: {
|
||||
oid: 'user-oid',
|
||||
tid: 't',
|
||||
username: 'jane@example',
|
||||
displayName: 'Jane',
|
||||
amr: ['pwd', 'mfa'],
|
||||
roles: ['editor', ADMIN_ROLE, 'auditor'],
|
||||
},
|
||||
principal: principalWithPrivileges(['Portal.Auditor', ADMIN_ROLE, 'Portal.DPO']),
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -155,24 +137,63 @@ describe('AdminRoleGuard', () => {
|
||||
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: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
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(
|
||||
makeRequest({
|
||||
session: {
|
||||
user: {
|
||||
oid: 'user-oid',
|
||||
tid: 't',
|
||||
username: 'jane@example',
|
||||
displayName: 'Jane',
|
||||
amr: ['pwd'],
|
||||
roles: ['editor', 'auditor'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
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: 'GET /api/admin/me',
|
||||
rolesHeld: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user