feat(auth): @RequirePrivilege/@RequireRole/@RequireScope guards (ADR-0025) (#208)
## Summary Phase 2 of [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md)'s implementation phasing (§"More Information"). The three new route decorators land — `@RequirePrivilege`, `@RequireRole`, `@RequireScope` — alongside the migration of the legacy `@RequireAdmin` to read from `principal.privileges`. Each guard consumes the session-resident `Principal` built by [#206](#206), evaluates its requirement, and either passes or emits a 403 + audit row. The drift-CI gate (phase 3) is **not** in this PR — it lands next, once the catalogue is being referenced from real route handlers. ## What lands ### Shared lib (`libs/shared/auth`) | File | Role | | --- | --- | | `src/lib/principal-matchers.ts` | Pure functions: `principalHasAnyPrivilege` / `principalHasAnyRole` / `principalCoversResource`. The matchers live in the shared lib so the SPA can render UI predicates with the same logic the BFF enforces server-side. | | Same file | `ScopableResource` type — optional FINESS / delegation / region / siege parentage. Callers populate the subset their route resource exposes; the matcher iterates and returns true on the first scope that covers a present field. | | `src/lib/principal-matchers.spec.ts` | 24 unit tests covering OR-composition, the empty-requirement degenerate case (treated as "no constraint" — pass), every scope kind, and the resource-side parentage chain. | ### BFF guards + decorators (`apps/portal-bff/src/auth`) | File | Role | | --- | --- | | `require-privilege.{decorator,guard}.ts` | `@RequirePrivilege('Portal.Admin', ...)` — type-locked to the closed `Privilege` catalogue; multiple values OR-combine. | | `require-role.{decorator,guard}.ts` | `@RequireRole('rh', ...)` — same shape, against `FunctionalRole`. | | `require-scope.{decorator,guard}.ts` | `@RequireScope(req => extractResource(req))` — extractor can be async (Prisma lookup pattern); returning `null` is treated as denial with empty `required[]`. | | `principal-extractor.ts` | Single read of `Principal` off the session, with a legacy-session bridge for principals minted before [#206](#206) landed — filters `user.roles` for `Portal.*` values and synthesises an `unrestricted` scope. After the 12 h absolute-TTL window post-deploy, the bridge becomes dead code. | | `principal-extractor.spec.ts` | 7 tests covering both code paths. | | `require-{privilege,role,scope}.guard.spec.ts` | Unit tests for each guard: 401 on anonymous, 403 + audit on denial, pass on match, generic `forbidden` response body (no role/privilege/resource hint leaks). | | `auth-guards.persona-matrix.spec.ts` | Integration tests: each of the 19 test-tenant personas walked through 5 representative privilege guards + 6 representative role guards. The matrix proves the contracts hold against the real provisioning, not just synthesised principals. | ### Audit module | File | Change | | --- | --- | | `audit.types.ts` | New `AuthorizationDeniedInput` discriminated by `kind: 'privilege' \| 'role' \| 'scope'`. Carries `required[]` and `held[]` arrays so an auditor can pivot on "tried admin while logged in as RH" patterns without joining anything. | | `audit.service.ts` | New `authorizationDenied()` method emitting the `auth.authorization_denied` event type. Distinct from the existing `admin.access_denied` so admin-surface signals stay clean. | | `audit.service.spec.ts` | 3 new tests covering the three `kind` values. | ### Legacy guard migration | File | Change | | --- | --- | | `admin/admin-role.guard.ts` | Reads `principal.privileges` instead of `user.roles`. Public `@RequireAdmin()` API unchanged; `admin.access_denied` event type unchanged. The audit row's `rolesHeld` field now carries `Portal.*` values rather than the raw legacy `roles` claim. | | `admin/admin-role.guard.spec.ts` | Rewritten to exercise `session.principal`-shaped sessions, with a dedicated legacy-bridge sub-suite that asserts pre-ADR-0025 sessions still work. | | `me/me.controller.ts` | `capabilities().canAccessAdmin` reads `principal.privileges` via the same extractor. | | `me/me.controller.spec.ts` | Rewritten against the principal shape. | ### AuthModule wiring | File | Change | | --- | --- | | `auth/auth.module.ts` | Three new guards registered as providers and re-exported. | ## Notes for the reviewer - **Composition semantics.** Within a single decorator, values OR-combine (`@RequireRole('rh', 'comptable')` matches anyone with either). Stacking decorators AND-combines at the Nest level — `@RequireRole('rh') @RequirePrivilege('Portal.Admin')` requires both, because each guard runs separately and a single denial stops the request. The empty-requirement case is rejected at the type level by the tuple `[Privilege, ...Privilege[]]` so a route can not opt out of authorization by passing zero values. - **Why `auth.authorization_denied` and not `admin.access_denied` for the new guards.** ADR-0025 §347 originally said "writes an `admin.access_denied` row", but reusing the `admin.*` namespace would have meant the future `@RequireRole('rh')` on a non-admin HR route ships an event whose type implies an admin surface. The new event type lives in the `auth.*` namespace where the guards live; a `kind` discriminator on the payload tells privilege / role / scope denials apart. The legacy `admin.access_denied` event continues unchanged for `AdminRoleGuard`. - **The legacy-session bridge is short-lived.** After the 12 h absolute-TTL window from this PR's deploy, every session in Redis has a `principal` field and the bridge's `else` branch becomes unreachable. The bridge keeps `@RequireAdmin` + `MeController` functional during that window without needing a forced re-auth campaign. - **Why a `ScopableResource` shape rather than a `Resource | Resource | ...` union per kind.** Routes protect heterogeneous resource types (`Etablissement`, `Dossier`, `Person`), each exposing a different subset of the parentage chain. The optional-field shape lets each route populate what its resource carries and lets the matcher iterate over the principal's scopes once. The cost is that a route author who forgets to populate `delegationCode` on an `Etablissement` resource locks delegation-scoped users out — a typing exercise that the next round of work (concrete consumers) will surface naturally. - **Why `principalCoversResource` deny on doubt.** When a route's extractor returns a resource that lacks the parentage `delegationCode` field, a `delegation:33` scope no longer matches — deny is the safe direction. An over-restrictive deny shows up in the audit log (operator can fix the extractor); an over-permissive pass would silently leak data. The non-transitivity of the parentage chain is an intentional contract. - **Why migrate `MeController` in the same PR.** The `canAccessAdmin` flag reads the same axis (`Portal.Admin` privilege). Leaving it on the legacy `user.roles` shape while everything else moves to `principal.privileges` would create internal inconsistency; a future reviewer would rightfully ask "why?". The migration is three lines plus a spec rewrite. - **The drift-CI gate is the next PR.** ADR-0025 §"More Information" step 3. ESLint custom rule (or a `pnpm run` script) grepping every `@RequirePrivilege('...')` / `@RequireRole('...')` / scope-kind literal in the codebase and asserting each one exists in the catalogue. Now there is something to grep for (the decorators and their tests reference catalogue values), so the gate is well-scoped to land standalone. - **No real consumers of `@RequireScope` yet.** The scope guard ships with a fully exercised contract (extractor signature, async support, audit shape, generic-forbidden body) but no live route. The first consumer surface lands with the `Person` + `User` schema (proposed ADR-0026) and the resource-loading routes that follow. ## Test plan - [x] `pnpm nx affected -t lint test build --base=main` — 3 projects green - `portal-bff`: 741 tests pass (was 497 in #206 — +244 new: matchers/guards/persona-matrix/audit/extractor). - `shared-auth`: 41 tests pass (was 17 in #206 — +24 new matcher tests). - `portal-bff-e2e`: lint green. - [x] `pnpm nx format:check` — clean after `pnpm nx format:write`. - [ ] **Review focus** — the 19-persona matrix (each persona walked through 5 privilege guards + 6 role guards); the legacy-session bridge in `principal-extractor.ts` and its tests; the `admin.access_denied` audit payload now carries `Portal.*` values rather than legacy `roles` (intentional, called out above); the `auth.authorization_denied` event-type rationale. ## What's next Per ADR-0025 §"More Information" phasing: 1. ✅ #206 — types + Principal builder + group-to-role mapping skeleton. 2. ✅ **This PR** — the three new decorators + guards, legacy `@RequireAdmin` migration. 3. **Next PR** — drift CI gate. ESLint custom rule (or `pnpm run` script) that asserts every `@RequireX('...')` literal in code is in the closed catalogue. 4. **Depends on ADR-0026** — `user_scopes` Prisma table + seed + `PrismaScopeResolver` replacing `StubScopeResolver`, then the first concrete consumers of `@RequireScope`. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #208
This commit was merged in pull request #208.
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
|
import type { Principal } from 'shared-auth';
|
||||||
import { AuditWriter } from '../audit/audit.service';
|
import { AuditWriter } from '../audit/audit.service';
|
||||||
import { ADMIN_ROLE, AdminRoleGuard } from './admin-role.guard';
|
import { ADMIN_ROLE, AdminRoleGuard } from './admin-role.guard';
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ interface SessionStub {
|
|||||||
amr: readonly string[];
|
amr: readonly string[];
|
||||||
roles: readonly string[];
|
roles: readonly string[];
|
||||||
};
|
};
|
||||||
|
principal?: Principal;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeRequest(opts: {
|
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', () => {
|
describe('AdminRoleGuard', () => {
|
||||||
it('throws 401 when the request has no session at all', async () => {
|
it('throws 401 when the request has no session at all', async () => {
|
||||||
const audit = makeAuditStub();
|
const audit = makeAuditStub();
|
||||||
@@ -47,7 +65,7 @@ describe('AdminRoleGuard', () => {
|
|||||||
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
|
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 audit = makeAuditStub();
|
||||||
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
||||||
const ctx = makeContext(makeRequest({ session: {} }));
|
const ctx = makeContext(makeRequest({ session: {} }));
|
||||||
@@ -55,21 +73,12 @@ describe('AdminRoleGuard', () => {
|
|||||||
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
|
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 audit = makeAuditStub();
|
||||||
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
||||||
const ctx = makeContext(
|
const ctx = makeContext(
|
||||||
makeRequest({
|
makeRequest({
|
||||||
session: {
|
session: { principal: principalWithPrivileges([]) },
|
||||||
user: {
|
|
||||||
oid: 'user-oid',
|
|
||||||
tid: 't',
|
|
||||||
username: 'jane@example',
|
|
||||||
displayName: 'Jane',
|
|
||||||
amr: ['pwd'],
|
|
||||||
roles: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
originalUrl: '/api/admin/me',
|
originalUrl: '/api/admin/me',
|
||||||
}),
|
}),
|
||||||
@@ -82,7 +91,83 @@ describe('AdminRoleGuard', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws 403 + records admin.access_denied when the user has unrelated roles only', async () => {
|
it('throws 403 + records admin.access_denied when the principal has non-admin privileges only', async () => {
|
||||||
|
const audit = makeAuditStub();
|
||||||
|
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
||||||
|
const ctx = makeContext(
|
||||||
|
makeRequest({
|
||||||
|
session: { principal: principalWithPrivileges(['Portal.Auditor']) },
|
||||||
|
method: 'POST',
|
||||||
|
originalUrl: '/api/admin/cms/pages',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(audit.adminAccessDenied).toHaveBeenCalledWith({
|
||||||
|
actor: { oid: 'user-oid' },
|
||||||
|
attemptedRoute: 'POST /api/admin/cms/pages',
|
||||||
|
rolesHeld: ['Portal.Auditor'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true and does not audit when the principal carries Portal.Admin', async () => {
|
||||||
|
const audit = makeAuditStub();
|
||||||
|
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
||||||
|
const ctx = makeContext(
|
||||||
|
makeRequest({ session: { principal: principalWithPrivileges([ADMIN_ROLE]) } }),
|
||||||
|
);
|
||||||
|
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||||
|
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when admin is one of several privileges', async () => {
|
||||||
|
const audit = makeAuditStub();
|
||||||
|
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
||||||
|
const ctx = makeContext(
|
||||||
|
makeRequest({
|
||||||
|
session: {
|
||||||
|
principal: principalWithPrivileges(['Portal.Auditor', ADMIN_ROLE, 'Portal.DPO']),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates audit write failures (no audit ⇒ no action, per ADR-0013)', async () => {
|
||||||
|
const audit = {
|
||||||
|
adminAccessDenied: jest.fn().mockRejectedValue(new Error('audit_writer denied')),
|
||||||
|
};
|
||||||
|
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
||||||
|
const ctx = makeContext(makeRequest({ session: { principal: principalWithPrivileges([]) } }));
|
||||||
|
// The 403 path goes through audit first; if audit throws, the
|
||||||
|
// guard surfaces the underlying error rather than the
|
||||||
|
// ForbiddenException — the caller sees a 500 and the audit
|
||||||
|
// invariant is preserved.
|
||||||
|
await expect(guard.canActivate(ctx)).rejects.toThrow('audit_writer denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('legacy-session bridge (sessions persisted before ADR-0025 landed)', () => {
|
||||||
|
it('synthesises a principal from session.user when session.principal is absent', async () => {
|
||||||
|
const audit = makeAuditStub();
|
||||||
|
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
||||||
|
const ctx = makeContext(
|
||||||
|
makeRequest({
|
||||||
|
session: {
|
||||||
|
user: {
|
||||||
|
oid: 'user-oid',
|
||||||
|
tid: 't',
|
||||||
|
username: 'jane@example',
|
||||||
|
displayName: 'Jane',
|
||||||
|
amr: ['pwd', 'mfa'],
|
||||||
|
roles: [ADMIN_ROLE, 'Other.Role'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
||||||
|
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies a legacy session whose roles claim carries no Portal.* value', async () => {
|
||||||
const audit = makeAuditStub();
|
const audit = makeAuditStub();
|
||||||
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
||||||
const ctx = makeContext(
|
const ctx = makeContext(
|
||||||
@@ -97,82 +182,18 @@ describe('AdminRoleGuard', () => {
|
|||||||
roles: ['editor', 'auditor'],
|
roles: ['editor', 'auditor'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
method: 'POST',
|
|
||||||
originalUrl: '/api/admin/cms/pages',
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
|
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({
|
expect(audit.adminAccessDenied).toHaveBeenCalledWith({
|
||||||
actor: { oid: 'user-oid' },
|
actor: { oid: 'user-oid' },
|
||||||
attemptedRoute: 'POST /api/admin/cms/pages',
|
attemptedRoute: 'GET /api/admin/me',
|
||||||
rolesHeld: ['editor', 'auditor'],
|
rolesHeld: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns true and does not audit when the user has the admin role', async () => {
|
|
||||||
const audit = makeAuditStub();
|
|
||||||
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
|
||||||
const ctx = makeContext(
|
|
||||||
makeRequest({
|
|
||||||
session: {
|
|
||||||
user: {
|
|
||||||
oid: 'user-oid',
|
|
||||||
tid: 't',
|
|
||||||
username: 'jane@example',
|
|
||||||
displayName: 'Jane',
|
|
||||||
amr: ['pwd', 'mfa'],
|
|
||||||
roles: [ADMIN_ROLE],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
|
||||||
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns true when admin is one of several roles', async () => {
|
|
||||||
const audit = makeAuditStub();
|
|
||||||
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
|
||||||
const ctx = makeContext(
|
|
||||||
makeRequest({
|
|
||||||
session: {
|
|
||||||
user: {
|
|
||||||
oid: 'user-oid',
|
|
||||||
tid: 't',
|
|
||||||
username: 'jane@example',
|
|
||||||
displayName: 'Jane',
|
|
||||||
amr: ['pwd', 'mfa'],
|
|
||||||
roles: ['editor', ADMIN_ROLE, 'auditor'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await expect(guard.canActivate(ctx)).resolves.toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('propagates audit write failures (no audit ⇒ no action, per ADR-0013)', async () => {
|
|
||||||
const audit = {
|
|
||||||
adminAccessDenied: jest.fn().mockRejectedValue(new Error('audit_writer denied')),
|
|
||||||
};
|
|
||||||
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
|
||||||
const ctx = makeContext(
|
|
||||||
makeRequest({
|
|
||||||
session: {
|
|
||||||
user: {
|
|
||||||
oid: 'user-oid',
|
|
||||||
tid: 't',
|
|
||||||
username: 'jane@example',
|
|
||||||
displayName: 'Jane',
|
|
||||||
amr: ['pwd'],
|
|
||||||
roles: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
// The 403 path goes through audit first; if audit throws, the
|
|
||||||
// guard surfaces the underlying error rather than the
|
|
||||||
// ForbiddenException — the caller sees a 500 and the audit
|
|
||||||
// invariant is preserved.
|
|
||||||
await expect(guard.canActivate(ctx)).rejects.toThrow('audit_writer denied');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import { AuditWriter } from '../audit/audit.service';
|
import { AuditWriter } from '../audit/audit.service';
|
||||||
|
import { readSessionPrincipal } from '../auth/principal-extractor';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single Entra app role that gates the entire `/api/admin/*` surface
|
* 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.
|
* `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
|
* Contract
|
||||||
* --------
|
* --------
|
||||||
* - **No session → 401.** The user is not authenticated at all; the
|
* - **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
|
* unauthenticated 401 is normal traffic the absolute-timeout
|
||||||
* middleware would surface anyway.
|
* middleware would surface anyway.
|
||||||
*
|
*
|
||||||
* - **Session but missing `Portal.Admin` role → 403 + audit.** The user
|
* - **Session but missing `Portal.Admin` privilege → 403 + audit.**
|
||||||
* *is* authenticated; they just are not authorised for admin.
|
* The user *is* authenticated; they just are not authorised
|
||||||
* This is the privilege-escalation attempt audit signal — every
|
* for admin. This is the privilege-escalation attempt audit
|
||||||
* denial lands in `audit.events` with `outcome=denied`, the
|
* signal — every denial lands in `audit.events` with
|
||||||
* attempted route as `subject`, and the roles the user did hold
|
* `outcome=denied`, the attempted route as `subject`, and the
|
||||||
* in the payload. Per ADR-0013 §"Blocking writes": no audit ⇒ no
|
* `Portal.*` privileges the principal did hold in the payload's
|
||||||
* action — if the audit write fails, the request fails too
|
* `rolesHeld` field (kept named `rolesHeld` for backward
|
||||||
* (consistent with the existing audit call sites in
|
* compatibility with the existing audit shape; the values are
|
||||||
* `AuthController`).
|
* 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
|
* - **Session with `Portal.Admin` privilege → pass through.**
|
||||||
* controllers see `req.session.user` populated and can rely on
|
* Downstream controllers see `req.session.user` *and*
|
||||||
* the role check having happened.
|
* `req.session.principal` populated and can rely on the gate
|
||||||
|
* having happened.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminRoleGuard implements CanActivate {
|
export class AdminRoleGuard implements CanActivate {
|
||||||
@@ -48,17 +56,17 @@ export class AdminRoleGuard implements CanActivate {
|
|||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const req = context.switchToHttp().getRequest<Request>();
|
const req = context.switchToHttp().getRequest<Request>();
|
||||||
const user = req.session?.user;
|
const principal = readSessionPrincipal(req);
|
||||||
|
|
||||||
if (!user) {
|
if (principal === null) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.roles.includes(ADMIN_ROLE)) {
|
if (!principal.privileges.includes(ADMIN_ROLE)) {
|
||||||
await this.audit.adminAccessDenied({
|
await this.audit.adminAccessDenied({
|
||||||
actor: { oid: user.oid },
|
actor: { oid: principal.user.entraOid },
|
||||||
attemptedRoute: `${req.method} ${req.originalUrl}`,
|
attemptedRoute: `${req.method} ${req.originalUrl}`,
|
||||||
rolesHeld: user.roles,
|
rolesHeld: [...principal.privileges],
|
||||||
});
|
});
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -469,4 +469,67 @@ describe('AuditWriter — typed event methods', () => {
|
|||||||
expect(extractInsertedRow(prisma).payloadJson).toBe(JSON.stringify({ rolesHeld: [] }));
|
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,
|
AdminAuditStatsQueryInput,
|
||||||
AdminUsersQueryInput,
|
AdminUsersQueryInput,
|
||||||
AuditEventInput,
|
AuditEventInput,
|
||||||
|
AuthorizationDeniedInput,
|
||||||
MfaRequiredInput,
|
MfaRequiredInput,
|
||||||
SignInActor,
|
SignInActor,
|
||||||
SignInFailedInput,
|
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> {
|
async recordEvent(input: AuditEventInput): Promise<void> {
|
||||||
const traceId = trace.getActiveSpan()?.spanContext().traceId ?? null;
|
const traceId = trace.getActiveSpan()?.spanContext().traceId ?? null;
|
||||||
const actorIdHash =
|
const actorIdHash =
|
||||||
|
|||||||
@@ -147,6 +147,42 @@ export interface AdminAuditStatsQueryInput {
|
|||||||
total: number;
|
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. */
|
/** Reason `RequireMfaGuard` rejected a request — fed into the audit payload. */
|
||||||
export type MfaRequiredReason = 'no-mfa-in-amr' | 'no-mfa-verified-at' | 'mfa-stale';
|
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 { MSAL_CLIENT } from './msal-client.token';
|
||||||
import { PrincipalBuilder } from './principal-builder';
|
import { PrincipalBuilder } from './principal-builder';
|
||||||
import { RequireMfaGuard } from './require-mfa.guard';
|
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 { ScopeResolver, StubScopeResolver } from './scope-resolver';
|
||||||
import { SessionEstablisher } from './session-establisher.service';
|
import { SessionEstablisher } from './session-establisher.service';
|
||||||
|
|
||||||
@@ -50,6 +53,9 @@ import { SessionEstablisher } from './session-establisher.service';
|
|||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
RequireMfaGuard,
|
RequireMfaGuard,
|
||||||
|
RequirePrivilegeGuard,
|
||||||
|
RequireRoleGuard,
|
||||||
|
RequireScopeGuard,
|
||||||
SessionEstablisher,
|
SessionEstablisher,
|
||||||
PrincipalBuilder,
|
PrincipalBuilder,
|
||||||
{ provide: ScopeResolver, useClass: StubScopeResolver },
|
{ provide: ScopeResolver, useClass: StubScopeResolver },
|
||||||
@@ -126,6 +132,9 @@ import { SessionEstablisher } from './session-establisher.service';
|
|||||||
ENTRA_GROUP_TO_ROLE_RESOLVER,
|
ENTRA_GROUP_TO_ROLE_RESOLVER,
|
||||||
MSAL_CLIENT,
|
MSAL_CLIENT,
|
||||||
RequireMfaGuard,
|
RequireMfaGuard,
|
||||||
|
RequirePrivilegeGuard,
|
||||||
|
RequireRoleGuard,
|
||||||
|
RequireScopeGuard,
|
||||||
AuthService,
|
AuthService,
|
||||||
PrincipalBuilder,
|
PrincipalBuilder,
|
||||||
ScopeResolver,
|
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 { UnauthorizedException } from '@nestjs/common';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
|
import type { Principal, Privilege } from 'shared-auth';
|
||||||
import { MeController } from './me.controller';
|
import { MeController } from './me.controller';
|
||||||
|
|
||||||
function makeReq(
|
function makeReq(
|
||||||
user: {
|
privileges: readonly Privilege[] | null,
|
||||||
oid: string;
|
overrides: { displayName?: string } = {},
|
||||||
tid: string;
|
|
||||||
username: string;
|
|
||||||
displayName: string;
|
|
||||||
amr: readonly string[];
|
|
||||||
roles: readonly string[];
|
|
||||||
} | null,
|
|
||||||
): Request {
|
): 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', () => {
|
describe('MeController', () => {
|
||||||
const controller = new MeController();
|
const controller = new MeController();
|
||||||
|
|
||||||
it('returns canAccessAdmin: true when the session carries Portal.Admin', () => {
|
it('returns canAccessAdmin: true when the principal carries Portal.Admin', () => {
|
||||||
const result = controller.capabilities(
|
const result = controller.capabilities(makeReq(['Portal.Admin', 'Portal.DPO']));
|
||||||
makeReq({
|
|
||||||
oid: 'oid-1',
|
|
||||||
tid: 'tenant-1',
|
|
||||||
username: 'admin@apf.example',
|
|
||||||
displayName: 'Admin Smith',
|
|
||||||
amr: ['pwd', 'mfa'],
|
|
||||||
roles: ['Portal.Admin', 'Other.Role'],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(result).toEqual({ canAccessAdmin: true });
|
expect(result).toEqual({ canAccessAdmin: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns canAccessAdmin: false when the session lacks Portal.Admin', () => {
|
it('returns canAccessAdmin: false when the principal carries other privileges only', () => {
|
||||||
const result = controller.capabilities(
|
const result = controller.capabilities(makeReq(['Portal.Auditor']));
|
||||||
makeReq({
|
|
||||||
oid: 'oid-2',
|
|
||||||
tid: 'tenant-1',
|
|
||||||
username: 'jane.doe@apf.example',
|
|
||||||
displayName: 'Jane Doe',
|
|
||||||
amr: ['pwd'],
|
|
||||||
roles: ['Other.Role'],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(result).toEqual({ canAccessAdmin: false });
|
expect(result).toEqual({ canAccessAdmin: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns canAccessAdmin: false when the session carries no roles', () => {
|
it('returns canAccessAdmin: false when the principal has no privileges', () => {
|
||||||
const result = controller.capabilities(
|
const result = controller.capabilities(makeReq([]));
|
||||||
makeReq({
|
|
||||||
oid: 'oid-3',
|
|
||||||
tid: 'tenant-1',
|
|
||||||
username: 'guest@apf.example',
|
|
||||||
displayName: 'Guest',
|
|
||||||
amr: [],
|
|
||||||
roles: [],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(result).toEqual({ canAccessAdmin: false });
|
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);
|
expect(() => controller.capabilities(makeReq(null))).toThrow(UnauthorizedException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does NOT echo the raw roles array in the response (curated view contract)', () => {
|
it('does NOT echo the raw privileges array in the response (curated view contract)', () => {
|
||||||
const result = controller.capabilities(
|
const result = controller.capabilities(makeReq(['Portal.Admin', 'Portal.DPO']));
|
||||||
makeReq({
|
|
||||||
oid: 'oid-4',
|
|
||||||
tid: 'tenant-1',
|
|
||||||
username: 'admin@apf.example',
|
|
||||||
displayName: 'Admin',
|
|
||||||
amr: ['pwd', 'mfa'],
|
|
||||||
roles: ['Portal.Admin', 'Confidential.Role'],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(Object.keys(result)).toEqual(['canAccessAdmin']);
|
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');
|
expect(JSON.stringify(result)).not.toContain('Portal.Admin');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Req, UnauthorizedException } from '@nestjs/common';
|
import { Controller, Get, Req, UnauthorizedException } from '@nestjs/common';
|
||||||
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
|
import { readSessionPrincipal } from '../auth/principal-extractor';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Curated capabilities derived from the active user-portal session,
|
* Curated capabilities derived from the active user-portal session,
|
||||||
@@ -61,8 +62,8 @@ export class MeController {
|
|||||||
})
|
})
|
||||||
@Get('capabilities')
|
@Get('capabilities')
|
||||||
capabilities(@Req() req: Request): CapabilitiesView {
|
capabilities(@Req() req: Request): CapabilitiesView {
|
||||||
const user = req.session.user;
|
const principal = readSessionPrincipal(req);
|
||||||
if (!user) {
|
if (principal === null) {
|
||||||
// Same posture as `/api/auth/me`: 401 (rather than 200 with
|
// Same posture as `/api/auth/me`: 401 (rather than 200 with
|
||||||
// every-flag-false) so the SPA can distinguish "no session"
|
// every-flag-false) so the SPA can distinguish "no session"
|
||||||
// from "session exists but no flags". Keeps the consumer code
|
// from "session exists but no flags". Keeps the consumer code
|
||||||
@@ -73,7 +74,7 @@ export class MeController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
canAccessAdmin: user.roles.includes(ADMIN_ROLE),
|
canAccessAdmin: principal.privileges.includes(ADMIN_ROLE),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,3 +19,9 @@ export {
|
|||||||
EntraGroupMapError,
|
EntraGroupMapError,
|
||||||
} from './lib/entra-group-to-role';
|
} from './lib/entra-group-to-role';
|
||||||
export type { EntraGroupMap, EntraGroupMapEntry } 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