Files
apf_portal/apps/portal-bff/src/audit/audit.service.ts
T
julien 1df99cd800
CI / check (push) Successful in 3m1s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m34s
CI / a11y (push) Successful in 2m35s
CI / perf (push) Successful in 5m7s
feat(portal-bff): admin user-directory read endpoint (#141)
## Summary

Second PR of the **portal-admin User-list chantier** per [ADR-0020](docs/decisions/0020-portal-admin-app.md) §"v1 scope — User list (read-only)". Ships the **read side**: paginated, filterable HTTP endpoint that queries the `public.users` directory populated at sign-in by PR #140. The SPA viewer screen lands in the final PR of the chantier.

## What lands

### [`AdminUsersQueryDto`](apps/portal-bff/src/admin/users-query.dto.ts)

Mirrors `AdminAuditQueryDto`'s posture — filters all optional, every unknown query key rejected by `forbidNonWhitelisted`, limit capped at **200** / default **50**.

| Filter | Type | Notes |
| --- | --- | --- |
| `username` | string ≤128 | Exact-prefix match (Prisma `startsWith`). |
| `displayName` | string ≤128 | Case-insensitive `contains` (display names vary in casing). |
| `audience` | enum | `workforce` \| `customer`. |
| `lastSeenAtFrom` | ISO-8601 | Inclusive lower bound. |
| `lastSeenAtTo` | ISO-8601 | Exclusive upper bound. |
| `limit` | int 1..200 | Default **50**. |
| `offset` | int ≥0 | Default **0**. |

### [`AdminUsersReader`](apps/portal-bff/src/admin/admin-users-reader.service.ts)

Prisma typed client against `public.users` — **no `SET LOCAL ROLE`** dance because `public.users` has no role-based privilege gate. The trust boundary is the controller's `@RequireAdmin` guard.

- **Order**: `last_seen_at DESC, oid ASC`. The second clause is a deterministic tie-breaker for pagination during sign-in bursts that share a timestamp.
- **COUNT + SELECT in one Prisma transaction** so the `total` reported to the SPA matches what's on the page even under a concurrent sign-in landing between the two queries.
- **Hard cap on limit** at 200 even when a caller bypasses the DTO — defense in depth on the BFF's event loop.

### [`AdminUsersController`](apps/portal-bff/src/admin/admin-users.controller.ts)

`GET /api/admin/users`, `@RequireAdmin()` at the class level. Forwards the validated DTO to `AdminUsersReader`, then emits `admin.users.query` with `{ filters, resultCount }` — the fishing-expedition deterrent (mirror of `admin.audit.query` from PR #132).

### [`AuditWriter.adminUsersQuery()`](apps/portal-bff/src/audit/audit.service.ts)

New typed method + `AdminUsersQueryInput` type. Same `outcome=success` / payload shape as `adminAuditQuery` — two distinct event types so a reviewer can pivot directly on `eventType` without parsing payload.

## Notes for the reviewer

- The shape mirrors `AdminAuditController` (#132) deliberately. Future admin read endpoints (CMS pages, menu items if they grow read filters) should adopt the same shape — keeps the SPA's data-flow pattern consistent across modules.
- `public.users` queries don't need `SET LOCAL ROLE` because there's no append-only / read-only role split on the table. That's a deliberate v1 simplification; if the security review later asks for stricter isolation we'd add a `users_reader` role + the same SET-LOCAL pattern AuditReader uses.
- The future "sign-in counts" join from `audit.events` on `actor_id_hash` is **deferred**. The salted hash is computable on the fly via `HashUserIdService`, so adding it later is a service-level change — no schema migration required.
- The `actor_id_hash` is deliberately **NOT** stored on `public.users` (per ADR-0013's invariant — the salt stays inside the audit module).

## Test plan

- [x] `pnpm nx test portal-bff` — **391 specs pass** (was 365; +26 covering DTO validation, reader transaction shape + filter forwarding + pagination defaults + cap, controller path, audit typed method).
- [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean.
- [x] Prisma transaction shape verified: COUNT + SELECT both fire exactly once per call; tuple-return contract honoured.
- [x] Filter projections verified per filter: username `startsWith`, displayName case-insensitive `contains`, audience exact match, lastSeenAt `gte/lt` composition.
- [ ] e2e — pending the admin SPA viewer screen + a real admin session. Once both exist: `curl --cookie 'portal_admin_session=...' /api/admin/users?username=jane&limit=10` returns the matching subset and a new `admin.users.query` audit row lands.

## What's next

The chantier's final PR:

- **portal-admin `/users` screen** — SPA viewer with filter form + table + pagination. Same shape as the `/audit` page (PR #136): signal-driven state, color-coded badges for audience, ISO timestamps formatted locale-side, status states. Will graduate the sidebar entry from `aria-disabled` "Soon" badge to a live link.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #141
2026-05-14 20:01:38 +02:00

266 lines
10 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,
AdminUsersQueryInput,
AuditEventInput,
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 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 `admin`.
* Per ADR-0020 §"Auth — same Entra ID … `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 },
});
}
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,
);
});
}
}