Files
apf_portal/apps/portal-bff/src/audit/audit.service.ts
T
julien 7d91da691b
CI / check (push) Failing after 4m35s
CI / commits (push) Has been skipped
CI / scan (push) Failing after 3m52s
CI / a11y (push) Successful in 1m59s
CI / perf (push) Successful in 5m50s
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
2026-05-23 21:49:34 +02:00

320 lines
12 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { trace } from '@opentelemetry/api';
import { ClsService } from 'nestjs-cls';
import { PrismaService } from 'nestjs-prisma';
import type {
AdminAccessDeniedInput,
AdminAuditQueryInput,
AdminAuditStatsQueryInput,
AdminUsersQueryInput,
AuditEventInput,
AuthorizationDeniedInput,
MfaRequiredInput,
SignInActor,
SignInFailedInput,
SignOutInput,
SessionExpiredInput,
} from './audit.types';
import { HashUserIdService } from './hash-user-id.service';
/**
* AuditWriter — single entry point for ADR-0013 audit-log writes.
*
* Contract
* --------
* - **Append-only at the database level.** Every write runs inside a
* transaction whose first statement is `SET LOCAL ROLE
* audit_writer`. That role only has `INSERT` on `audit.events`
* (per the migration that created the table); `UPDATE`, `DELETE`,
* `TRUNCATE` all fail at the Postgres level even if the BFF
* connection is otherwise privileged. The role is reset
* automatically at transaction end.
*
* - **Fail loud, never swallow.** Per ADR-0013 §"Blocking writes":
* no audit ⇒ no action. Callers must propagate the rejection up
* so the requested action does not proceed when its audit trail
* cannot be written. The service throws the underlying Prisma
* error unchanged; do not wrap it in a catch-and-log block.
*
* - **trace_id and actor_id_hash are auto-resolved.** trace_id is
* read from the active OTel span context (so the audit row joins
* with the BFF request span and the Pino log lines on the same
* request). actor_id_hash is read from the CLS context populated
* by future auth guards (ADR-0009 / ADR-0010); v1 stores `null`
* when no actor is established. Callers can override either by
* passing them on `AuditEventInput`.
*/
@Injectable()
export class AuditWriter {
constructor(
private readonly prisma: PrismaService,
private readonly cls: ClsService,
private readonly hashUserId: HashUserIdService,
) {}
/**
* Typed event: successful sign-in via the OIDC callback. Per
* ADR-0013's v1 catalogue (`auth.sign_in`).
*
* Hashes the user id internally — callers pass the raw Entra
* `oid`, never the hash, so the salt stays inside the audit
* module.
*/
async signIn(input: { actor: SignInActor; sessionId: string }): Promise<void> {
await this.recordEvent({
eventType: 'auth.sign_in',
audience: 'workforce',
outcome: 'success',
actorIdHash: this.hashUserId.hash(input.actor.oid),
subject: `session:${input.sessionId}`,
payload: { amr: input.actor.amr },
});
}
/**
* Typed event: failed sign-in at the callback. The `failureKind`
* mirrors the discriminator on `AuthCodeFlowError` so the audit
* row is self-describing without joining anything.
*
* `actorIdHash` is left null on purpose: at the moment of
* failure we may not have resolved an identity yet (state
* mismatch, expired flow, token-exchange error before any user
* claim was parsed). Callers can pass an explicit hash when the
* identity *was* resolved before rejection.
*/
async signInFailed(input: SignInFailedInput): Promise<void> {
await this.recordEvent({
eventType: 'auth.sign_in.failed',
audience: 'workforce',
outcome: 'failure',
...(input.actor !== undefined ? { actorIdHash: this.hashUserId.hash(input.actor.oid) } : {}),
payload: { failureKind: input.failureKind, ...(input.payload ?? {}) },
});
}
async signOut(input: SignOutInput): Promise<void> {
await this.recordEvent({
eventType: 'auth.sign_out',
audience: 'workforce',
outcome: 'success',
actorIdHash: this.hashUserId.hash(input.actor.oid),
subject: `session:${input.sessionId}`,
});
}
/**
* Typed event: session destroyed by the absolute-timeout
* middleware (12 h hard ceiling, ADR-0010 §"TTL policy"). The
* idle-TTL expiry is *not* surfaced through this method — it
* happens silently inside Redis with no BFF observation point.
*/
async sessionExpired(input: SessionExpiredInput): Promise<void> {
await this.recordEvent({
eventType: 'auth.session.expired',
audience: 'workforce',
outcome: 'success',
actorIdHash: this.hashUserId.hash(input.actor.oid),
subject: `session:${input.sessionId}`,
payload: { reason: input.reason, ageMs: input.ageMs },
});
}
/**
* Typed event: a request was rejected by `RequireMfaGuard` because
* the session did not satisfy the route's MFA freshness gate.
* Per ADR-0011 §"Step-up MFA — designed-in, dormant", every
* step-up challenge emitted by the BFF lands here so auditors can
* track the distribution of step-up prompts (and the rate of stale
* `mfaVerifiedAt` rejections that would suggest the default
* freshness is too tight). `outcome=denied` matches the
* `AdminRoleGuard` posture: the user *is* authenticated, the
* action is just refused.
*/
async mfaRequired(input: MfaRequiredInput): Promise<void> {
await this.recordEvent({
eventType: 'auth.mfa_required',
audience: 'workforce',
outcome: 'denied',
actorIdHash: this.hashUserId.hash(input.actor.oid),
subject: input.attemptedRoute,
payload: {
reason: input.reason,
freshnessSeconds: input.freshnessSeconds,
...(input.mfaAgeMs !== undefined ? { mfaAgeMs: input.mfaAgeMs } : {}),
},
});
}
/**
* Typed event: an admin queried the audit-log viewer. Recorded at
* **coarser granularity** per ADR-0020 §"Audit log captures every
* admin write action … Read actions (audit log viewing, user list
* browsing) are also captured … to deter fishing expeditions".
*
* The filter object the admin used is serialised into the payload
* so a reviewer can see what was searched (event type, time range,
* actor hash, etc.). `resultCount` is the number of rows the SPA
* received in this trip — paged calls each emit one event; an
* auditor spotting a sequence of `admin.audit.query` with growing
* offsets is the v1 detection signal for a sweep.
*
* `outcome=success` regardless of whether the query returned any
* rows: the event captures "the admin ran a query", not "the
* query matched something". An `outcome=failure` audit row from
* this surface is reserved for the day we add per-row authZ.
*/
async adminAuditQuery(input: AdminAuditQueryInput): Promise<void> {
await this.recordEvent({
eventType: 'admin.audit.query',
audience: 'workforce',
outcome: 'success',
actorIdHash: this.hashUserId.hash(input.actor.oid),
payload: {
filters: input.filters,
resultCount: input.resultCount,
},
});
}
/**
* Typed event: an admin queried the audit-log STATISTICS endpoint
* (`GET /api/admin/audit/stats`). Same deterrent posture as
* {@link adminAuditQuery} but separates the event type so an
* auditor can spot "the admin scanned aggregations" vs "the admin
* paged through rows" — different observation signals.
*
* Payload carries `total` (the size of the aggregated dataset)
* rather than `resultCount` — stats responses don't paginate, and
* `total` is what tells a reviewer how wide the scan was.
*/
async adminAuditStatsQuery(input: AdminAuditStatsQueryInput): Promise<void> {
await this.recordEvent({
eventType: 'admin.audit.stats.query',
audience: 'workforce',
outcome: 'success',
actorIdHash: this.hashUserId.hash(input.actor.oid),
payload: {
filters: input.filters,
total: input.total,
},
});
}
/**
* Typed event: an admin queried the user-directory viewer. Same
* deterrent posture as {@link adminAuditQuery} per ADR-0020
* §"Read actions ... to deter fishing expeditions" — captures the
* filter and the result count so a reviewer can spot a sweep
* (admin paging through everyone) without joining anything.
*/
async adminUsersQuery(input: AdminUsersQueryInput): Promise<void> {
await this.recordEvent({
eventType: 'admin.users.query',
audience: 'workforce',
outcome: 'success',
actorIdHash: this.hashUserId.hash(input.actor.oid),
payload: {
filters: input.filters,
resultCount: input.resultCount,
},
});
}
/**
* Typed event: `/api/admin/*` request rejected by `AdminRoleGuard`
* because the session's `roles` claim does not include `Portal.Admin`.
* Per ADR-0020 §"Auth — same Entra ID … `Portal.Admin` role claim", every
* 403 from the admin surface is captured here with the attempted
* route and the roles the user actually held — auditors looking
* for privilege-escalation attempts pivot on `subject` (the route)
* and `outcome=denied`.
*/
async adminAccessDenied(input: AdminAccessDeniedInput): Promise<void> {
await this.recordEvent({
eventType: 'admin.access_denied',
audience: 'workforce',
outcome: 'denied',
actorIdHash: this.hashUserId.hash(input.actor.oid),
subject: input.attemptedRoute,
payload: { rolesHeld: input.rolesHeld },
});
}
/**
* 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 =
input.actorIdHash ?? this.cls.get<string | undefined>('actorIdHash') ?? null;
const payloadJson = input.payload === undefined ? null : JSON.stringify(input.payload);
await this.prisma.$transaction(async (tx) => {
// Lock the connection to audit_writer for the duration of this
// transaction. SET LOCAL is reset at COMMIT/ROLLBACK so the
// pool's next consumer sees the original role.
await tx.$executeRawUnsafe(`SET LOCAL ROLE audit_writer`);
// Deliberately NOT `tx.auditEvent.create(...)`. The Prisma ORM
// create() emits `INSERT … RETURNING *` to hydrate the entity
// it returns, and Postgres requires SELECT on every column
// listed in RETURNING. `audit_writer` is granted INSERT only
// (ADR-0013 §"Append-only by role grants"); RETURNING fails
// with the deeply misleading "permission denied for table
// events" error code 42501. Raw parameterised INSERT keeps
// the role contract strict — audit_writer never needs SELECT.
//
// `gen_random_uuid()` is built into Postgres 13+ (the dev /
// prod target is 17). The enum + jsonb casts are needed
// because the parameter values are sent as TEXT over the
// wire.
await tx.$executeRawUnsafe(
`INSERT INTO "audit"."events"
(id, event_type, audience, outcome, subject, actor_id_hash, trace_id, payload)
VALUES (
gen_random_uuid(),
$1,
$2::"audit"."AuditAudience",
$3::"audit"."AuditOutcome",
$4,
$5,
$6,
$7::jsonb
)`,
input.eventType,
input.audience,
input.outcome,
input.subject ?? null,
actorIdHash,
traceId,
payloadJson,
);
});
}
}