From 3c0b5cab58c322b88b20e34f412a551463218619 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Sat, 23 May 2026 21:47:01 +0200 Subject: [PATCH] feat(auth): @RequirePrivilege/@RequireRole/@RequireScope guards (ADR-0025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/admin/admin-role.guard.spec.ts | 141 +++++---- apps/portal-bff/src/admin/admin-role.guard.ts | 42 +-- .../src/audit/audit.service.spec.ts | 63 +++++ apps/portal-bff/src/audit/audit.service.ts | 29 ++ apps/portal-bff/src/audit/audit.types.ts | 36 +++ .../auth/auth-guards.persona-matrix.spec.ts | 267 ++++++++++++++++++ apps/portal-bff/src/auth/auth.module.ts | 9 + .../src/auth/principal-extractor.spec.ts | 115 ++++++++ .../src/auth/principal-extractor.ts | 74 +++++ .../src/auth/require-privilege.decorator.ts | 33 +++ .../src/auth/require-privilege.guard.spec.ts | 133 +++++++++ .../src/auth/require-privilege.guard.ts | 81 ++++++ .../src/auth/require-role.decorator.ts | 29 ++ .../src/auth/require-role.guard.spec.ts | 111 ++++++++ .../portal-bff/src/auth/require-role.guard.ts | 65 +++++ .../src/auth/require-scope.decorator.ts | 52 ++++ .../src/auth/require-scope.guard.spec.ts | 156 ++++++++++ .../src/auth/require-scope.guard.ts | 119 ++++++++ apps/portal-bff/src/me/me.controller.spec.ts | 85 ++---- apps/portal-bff/src/me/me.controller.ts | 7 +- libs/shared/auth/src/index.ts | 6 + .../auth/src/lib/principal-matchers.spec.ts | 212 ++++++++++++++ .../shared/auth/src/lib/principal-matchers.ts | 153 ++++++++++ 23 files changed, 1883 insertions(+), 135 deletions(-) create mode 100644 apps/portal-bff/src/auth/auth-guards.persona-matrix.spec.ts create mode 100644 apps/portal-bff/src/auth/principal-extractor.spec.ts create mode 100644 apps/portal-bff/src/auth/principal-extractor.ts create mode 100644 apps/portal-bff/src/auth/require-privilege.decorator.ts create mode 100644 apps/portal-bff/src/auth/require-privilege.guard.spec.ts create mode 100644 apps/portal-bff/src/auth/require-privilege.guard.ts create mode 100644 apps/portal-bff/src/auth/require-role.decorator.ts create mode 100644 apps/portal-bff/src/auth/require-role.guard.spec.ts create mode 100644 apps/portal-bff/src/auth/require-role.guard.ts create mode 100644 apps/portal-bff/src/auth/require-scope.decorator.ts create mode 100644 apps/portal-bff/src/auth/require-scope.guard.spec.ts create mode 100644 apps/portal-bff/src/auth/require-scope.guard.ts create mode 100644 libs/shared/auth/src/lib/principal-matchers.spec.ts create mode 100644 libs/shared/auth/src/lib/principal-matchers.ts diff --git a/apps/portal-bff/src/admin/admin-role.guard.spec.ts b/apps/portal-bff/src/admin/admin-role.guard.spec.ts index fd1880d..f8430ec 100644 --- a/apps/portal-bff/src/admin/admin-role.guard.spec.ts +++ b/apps/portal-bff/src/admin/admin-role.guard.spec.ts @@ -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> { }; } +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: [], + }); + }); + }); }); diff --git a/apps/portal-bff/src/admin/admin-role.guard.ts b/apps/portal-bff/src/admin/admin-role.guard.ts index c77d096..4d77f1e 100644 --- a/apps/portal-bff/src/admin/admin-role.guard.ts +++ b/apps/portal-bff/src/admin/admin-role.guard.ts @@ -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 { const req = context.switchToHttp().getRequest(); - 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(); } diff --git a/apps/portal-bff/src/audit/audit.service.spec.ts b/apps/portal-bff/src/audit/audit.service.spec.ts index 7b495a8..3f3fa5a 100644 --- a/apps/portal-bff/src/audit/audit.service.spec.ts +++ b/apps/portal-bff/src/audit/audit.service.spec.ts @@ -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'], + }), + ); + }); + }); }); diff --git a/apps/portal-bff/src/audit/audit.service.ts b/apps/portal-bff/src/audit/audit.service.ts index b8af3e3..c0a80fb 100644 --- a/apps/portal-bff/src/audit/audit.service.ts +++ b/apps/portal-bff/src/audit/audit.service.ts @@ -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 { + 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 { const traceId = trace.getActiveSpan()?.spanContext().traceId ?? null; const actorIdHash = diff --git a/apps/portal-bff/src/audit/audit.types.ts b/apps/portal-bff/src/audit/audit.types.ts index 4107046..1aecc62 100644 --- a/apps/portal-bff/src/audit/audit.types.ts +++ b/apps/portal-bff/src/audit/audit.types.ts @@ -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'; diff --git a/apps/portal-bff/src/auth/auth-guards.persona-matrix.spec.ts b/apps/portal-bff/src/auth/auth-guards.persona-matrix.spec.ts new file mode 100644 index 0000000..284a950 --- /dev/null +++ b/apps/portal-bff/src/auth/auth-guards.persona-matrix.spec.ts @@ -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: () => 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; +} + +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); + } + }); + } + }); + } +}); diff --git a/apps/portal-bff/src/auth/auth.module.ts b/apps/portal-bff/src/auth/auth.module.ts index 3071c51..378055e 100644 --- a/apps/portal-bff/src/auth/auth.module.ts +++ b/apps/portal-bff/src/auth/auth.module.ts @@ -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, diff --git a/apps/portal-bff/src/auth/principal-extractor.spec.ts b/apps/portal-bff/src/auth/principal-extractor.spec.ts new file mode 100644 index 0000000..c286aea --- /dev/null +++ b/apps/portal-bff/src/auth/principal-extractor.spec.ts @@ -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 | 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([]); + }); +}); diff --git a/apps/portal-bff/src/auth/principal-extractor.ts b/apps/portal-bff/src/auth/principal-extractor.ts new file mode 100644 index 0000000..dbea126 --- /dev/null +++ b/apps/portal-bff/src/auth/principal-extractor.ts @@ -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:` / `delegation:` / `region:` + * 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): string[] { + return scopes.map((s) => { + if (s.kind === 'etablissement' || s.kind === 'delegation' || s.kind === 'region') { + return `${s.kind}:${s.value}`; + } + return s.kind; + }); +} diff --git a/apps/portal-bff/src/auth/require-privilege.decorator.ts b/apps/portal-bff/src/auth/require-privilege.decorator.ts new file mode 100644 index 0000000..bf433ad --- /dev/null +++ b/apps/portal-bff/src/auth/require-privilege.decorator.ts @@ -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), + ); +} diff --git a/apps/portal-bff/src/auth/require-privilege.guard.spec.ts b/apps/portal-bff/src/auth/require-privilege.guard.spec.ts new file mode 100644 index 0000000..0647dd3 --- /dev/null +++ b/apps/portal-bff/src/auth/require-privilege.guard.spec.ts @@ -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: () => 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; + expect(body['code']).toBe('forbidden'); + expect(body['message']).toBe('Forbidden'); + expect(JSON.stringify(body)).not.toContain('Portal.Admin'); + } + }); +}); diff --git a/apps/portal-bff/src/auth/require-privilege.guard.ts b/apps/portal-bff/src/auth/require-privilege.guard.ts new file mode 100644 index 0000000..2ac3b77 --- /dev/null +++ b/apps/portal-bff/src/auth/require-privilege.guard.ts @@ -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 { + const required = this.reflector.getAllAndOverride( + 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(); + 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', + }); + } +} diff --git a/apps/portal-bff/src/auth/require-role.decorator.ts b/apps/portal-bff/src/auth/require-role.decorator.ts new file mode 100644 index 0000000..3f5bd67 --- /dev/null +++ b/apps/portal-bff/src/auth/require-role.decorator.ts @@ -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)); +} diff --git a/apps/portal-bff/src/auth/require-role.guard.spec.ts b/apps/portal-bff/src/auth/require-role.guard.spec.ts new file mode 100644 index 0000000..01ec797 --- /dev/null +++ b/apps/portal-bff/src/auth/require-role.guard.spec.ts @@ -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: () => 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; + expect(body['code']).toBe('forbidden'); + expect(JSON.stringify(body)).not.toContain('rh'); + } + }); +}); diff --git a/apps/portal-bff/src/auth/require-role.guard.ts b/apps/portal-bff/src/auth/require-role.guard.ts new file mode 100644 index 0000000..179bbd2 --- /dev/null +++ b/apps/portal-bff/src/auth/require-role.guard.ts @@ -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 { + const required = this.reflector.getAllAndOverride( + REQUIRE_ROLE_METADATA, + [context.getHandler(), context.getClass()], + ); + if (required === undefined || required.length === 0) { + return true; + } + + const req = context.switchToHttp().getRequest(); + 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', + }); + } +} diff --git a/apps/portal-bff/src/auth/require-scope.decorator.ts b/apps/portal-bff/src/auth/require-scope.decorator.ts new file mode 100644 index 0000000..b350c37 --- /dev/null +++ b/apps/portal-bff/src/auth/require-scope.decorator.ts @@ -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; + +/** + * `@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), + ); +} diff --git a/apps/portal-bff/src/auth/require-scope.guard.spec.ts b/apps/portal-bff/src/auth/require-scope.guard.spec.ts new file mode 100644 index 0000000..0537b0d --- /dev/null +++ b/apps/portal-bff/src/auth/require-scope.guard.spec.ts @@ -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: () => 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; + expect(body['code']).toBe('forbidden'); + expect(JSON.stringify(body)).not.toContain('0330800021'); + } + }); +}); diff --git a/apps/portal-bff/src/auth/require-scope.guard.ts b/apps/portal-bff/src/auth/require-scope.guard.ts new file mode 100644 index 0000000..1f69e3a --- /dev/null +++ b/apps/portal-bff/src/auth/require-scope.guard.ts @@ -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 { + const extractor = this.reflector.getAllAndOverride( + REQUIRE_SCOPE_METADATA, + [context.getHandler(), context.getClass()], + ); + if (extractor === undefined) { + return true; + } + + const req = context.switchToHttp().getRequest(); + 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; +} diff --git a/apps/portal-bff/src/me/me.controller.spec.ts b/apps/portal-bff/src/me/me.controller.spec.ts index df61731..32af0f9 100644 --- a/apps/portal-bff/src/me/me.controller.spec.ts +++ b/apps/portal-bff/src/me/me.controller.spec.ts @@ -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'); }); }); diff --git a/apps/portal-bff/src/me/me.controller.ts b/apps/portal-bff/src/me/me.controller.ts index de9f822..5a0cf5a 100644 --- a/apps/portal-bff/src/me/me.controller.ts +++ b/apps/portal-bff/src/me/me.controller.ts @@ -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), }; } } diff --git a/libs/shared/auth/src/index.ts b/libs/shared/auth/src/index.ts index 8ebd404..1381d10 100644 --- a/libs/shared/auth/src/index.ts +++ b/libs/shared/auth/src/index.ts @@ -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'; diff --git a/libs/shared/auth/src/lib/principal-matchers.spec.ts b/libs/shared/auth/src/lib/principal-matchers.spec.ts new file mode 100644 index 0000000..9ba8b59 --- /dev/null +++ b/libs/shared/auth/src/lib/principal-matchers.spec.ts @@ -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 { + 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); + } + }); +}); diff --git a/libs/shared/auth/src/lib/principal-matchers.ts b/libs/shared/auth/src/lib/principal-matchers.ts new file mode 100644 index 0000000..4db6a9a --- /dev/null +++ b/libs/shared/auth/src/lib/principal-matchers.ts @@ -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, +): 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, +): 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:` scope. */ + readonly etablissementFiness?: string; + /** French department code, matched against `delegation:` scope. */ + readonly delegationCode?: string; + /** INSEE region code, matched against `region:` 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:` covers a resource whose + * `etablissementFiness` equals the scope value. + * - `delegation:` covers a resource whose + * `delegationCode` equals the scope value. + * - `region:` 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; +}