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:
@@ -469,4 +469,67 @@ describe('AuditWriter — typed event methods', () => {
|
||||
expect(extractInsertedRow(prisma).payloadJson).toBe(JSON.stringify({ rolesHeld: [] }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('authorizationDenied()', () => {
|
||||
it('records auth.authorization_denied with kind=privilege payload', async () => {
|
||||
const { writer, prisma, hashUserId } = await createSubject();
|
||||
await writer.authorizationDenied({
|
||||
actor: { oid: 'user-oid' },
|
||||
attemptedRoute: 'GET /api/audit',
|
||||
kind: 'privilege',
|
||||
required: ['Portal.Auditor'],
|
||||
held: ['Portal.Admin'],
|
||||
});
|
||||
expect(hashUserId.hash).toHaveBeenCalledWith('user-oid');
|
||||
const row = extractInsertedRow(prisma);
|
||||
expect(row.eventType).toBe('auth.authorization_denied');
|
||||
expect(row.outcome).toBe('denied');
|
||||
expect(row.subject).toBe('GET /api/audit');
|
||||
expect(row.payloadJson).toBe(
|
||||
JSON.stringify({
|
||||
kind: 'privilege',
|
||||
required: ['Portal.Auditor'],
|
||||
held: ['Portal.Admin'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('records kind=role with the role lists', async () => {
|
||||
const { writer, prisma } = await createSubject();
|
||||
await writer.authorizationDenied({
|
||||
actor: { oid: 'user-oid' },
|
||||
attemptedRoute: 'GET /api/hr/payroll',
|
||||
kind: 'role',
|
||||
required: ['rh', 'responsable-paie'],
|
||||
held: ['collaborateur'],
|
||||
});
|
||||
const row = extractInsertedRow(prisma);
|
||||
expect(row.payloadJson).toBe(
|
||||
JSON.stringify({
|
||||
kind: 'role',
|
||||
required: ['rh', 'responsable-paie'],
|
||||
held: ['collaborateur'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('records kind=scope with the scope descriptors', async () => {
|
||||
const { writer, prisma } = await createSubject();
|
||||
await writer.authorizationDenied({
|
||||
actor: { oid: 'user-oid' },
|
||||
attemptedRoute: 'GET /api/etablissement/0330800021',
|
||||
kind: 'scope',
|
||||
required: ['etablissement:0330800021'],
|
||||
held: ['etablissement:0330800013'],
|
||||
});
|
||||
const row = extractInsertedRow(prisma);
|
||||
expect(row.payloadJson).toBe(
|
||||
JSON.stringify({
|
||||
kind: 'scope',
|
||||
required: ['etablissement:0330800021'],
|
||||
held: ['etablissement:0330800013'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
AdminAuditStatsQueryInput,
|
||||
AdminUsersQueryInput,
|
||||
AuditEventInput,
|
||||
AuthorizationDeniedInput,
|
||||
MfaRequiredInput,
|
||||
SignInActor,
|
||||
SignInFailedInput,
|
||||
@@ -239,6 +240,34 @@ export class AuditWriter {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed event: a request was rejected by one of the three new
|
||||
* ADR-0025 guards (`RequirePrivilegeGuard`, `RequireRoleGuard`,
|
||||
* `RequireScopeGuard`). Distinct from `admin.access_denied` so
|
||||
* an auditor can keep the existing admin-surface signal clean
|
||||
* while tracking the broader role/scope denial surface as it
|
||||
* grows.
|
||||
*
|
||||
* `outcome=denied` matches the existing posture: the user *is*
|
||||
* authenticated, the action is just refused. The `required` and
|
||||
* `held` payload arrays let an auditor pivot on "tried admin
|
||||
* while logged in as RH" without joining anything.
|
||||
*/
|
||||
async authorizationDenied(input: AuthorizationDeniedInput): Promise<void> {
|
||||
await this.recordEvent({
|
||||
eventType: 'auth.authorization_denied',
|
||||
audience: 'workforce',
|
||||
outcome: 'denied',
|
||||
actorIdHash: this.hashUserId.hash(input.actor.oid),
|
||||
subject: input.attemptedRoute,
|
||||
payload: {
|
||||
kind: input.kind,
|
||||
required: input.required,
|
||||
held: input.held,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async recordEvent(input: AuditEventInput): Promise<void> {
|
||||
const traceId = trace.getActiveSpan()?.spanContext().traceId ?? null;
|
||||
const actorIdHash =
|
||||
|
||||
@@ -147,6 +147,42 @@ export interface AdminAuditStatsQueryInput {
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminator on `AuthorizationDeniedInput.kind` — which of the
|
||||
* three new ADR-0025 guards rejected the request. Stored in the
|
||||
* payload so an auditor can spot "what kind of authorization
|
||||
* failed" without parsing the route. The existing
|
||||
* `admin.access_denied` event keeps its own type for backwards
|
||||
* compatibility with the legacy `AdminRoleGuard` audit posture.
|
||||
*/
|
||||
export type AuthorizationDeniedKind = 'privilege' | 'role' | 'scope';
|
||||
|
||||
export interface AuthorizationDeniedInput {
|
||||
actor: { oid: string };
|
||||
/** Canonical "{METHOD} {originalUrl}" of the rejected request. */
|
||||
attemptedRoute: string;
|
||||
/** Which guard fired the rejection. */
|
||||
kind: AuthorizationDeniedKind;
|
||||
/**
|
||||
* For `kind: 'privilege' | 'role'`: the catalogue values the
|
||||
* guard required. For `kind: 'scope'`: the route's resource
|
||||
* descriptor serialised into a `{kind, value}` map. Stored as
|
||||
* a generic `string[]` rather than the union of `Privilege` /
|
||||
* `FunctionalRole` so the audit module stays decoupled from the
|
||||
* shared catalogue.
|
||||
*/
|
||||
required: readonly string[];
|
||||
/**
|
||||
* What the principal actually held on the matching axis at the
|
||||
* moment of denial — `privileges[]` for privilege/admin denials,
|
||||
* `roles[]` for role denials, `scopes[]` (serialised as
|
||||
* `kind` or `kind:value` strings) for scope denials. Lets an
|
||||
* auditor spot "tried admin while logged in as RH" patterns
|
||||
* without joining anything.
|
||||
*/
|
||||
held: readonly string[];
|
||||
}
|
||||
|
||||
/** Reason `RequireMfaGuard` rejected a request — fed into the audit payload. */
|
||||
export type MfaRequiredReason = 'no-mfa-in-amr' | 'no-mfa-verified-at' | 'mfa-stale';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user