feat(portal-bff): audit-stats endpoint — server-side aggregations with redis cache
CI / scan (pull_request) Successful in 4m45s
CI / commits (pull_request) Successful in 4m46s
CI / check (pull_request) Successful in 5m12s
CI / a11y (pull_request) Successful in 4m19s
Docs site / build (pull_request) Successful in 5m38s
CI / perf (pull_request) Successful in 9m8s

PR 1 of the tabs + full-result charts chantier. New BFF endpoint
that powers the upcoming "Charts" tab on portal-admin's /audit page
with aggregations computed over the FULL filtered set rather than
the paginated slice that currently feeds the charts.

New route: GET /api/admin/audit/stats
  * Same query DTO shape as /audit (AdminAuditStatsQueryDto)
    minus pagination — stats endpoint always aggregates the whole
    filtered set.
  * Returns three projections:
    - dailyVolume: [{day: 'YYYY-MM-DD', count}]
    - outcomeBreakdown: [{outcome, count}]
    - eventTypeByDay: [{day, eventType, count}]
    + total (sum of dailyVolume.count) for the donut centre label.
  * Time bound: respects filters strictly (per chantier brief). No
    filter → aggregates across the full audit retention (365 days
    per ADR-0013). The Redis cache below absorbs repeated heavy
    queries.

New service: AuditStatsReader
  * Same role-locking posture as AuditReader — every query inside
    a transaction that opens with SET LOCAL ROLE audit_reader.
    SELECT-only on audit.events even if the BFF connection is
    otherwise privileged.
  * Parameterised SQL only. Filter values flow through positional
    parameters, never concatenated into the SQL string.
  * Three GROUP BY queries scoped by the same WHERE clause as the
    list endpoint's buildWhere (kept structurally in sync by
    convention, not extracted yet because the two readers'
    pagination shapes differ).

Redis cache: 5-min TTL per filters-hash
  * Cache key = `audit:stats:` + sha256(canonical-JSON of filters)
    truncated to 16 hex chars. Sorted-keys canonicalisation so the
    same filters in different argument orders map to the same
    key.
  * Cache writes are best-effort — Redis-write failure does not
    fail the response. The DB read already happened.
  * Spec covers: cache-hit path skips DB, cache-key stability across
    key ordering, write happens with EX 300, write failure tolerance.

Audit event: admin.audit.stats.query
  * New typed AuditWriter method (mirrors adminAuditQuery but
    captures `total` instead of `resultCount` — stats responses
    don't paginate, the value carries more "size of scan" signal).
  * Emitted on every stats call per ADR-0020's read-deterrence
    posture. Distinct event_type from admin.audit.query so an
    auditor can spot "scanned aggregations" vs "paged through
    rows" — different observation signals.

ADR-0013 amended (light): new "Reader endpoints" subsection
documents the two-endpoint reader surface + Redis cache caveat.
The events table grows four rows it was previously missing
(admin.access_denied, admin.audit.query, admin.audit.stats.query,
admin.users.query).

Verification:
  * 414 portal-bff specs pass (was 401; +13: 8 service-level + 5
    controller-level).
  * `pnpm nx lint test build --projects=portal-bff` — green.

PR 2 (SPA) lands next: Tabs UX (Table / Charts) + replaces the
client-side per-page computeds with consumption of this endpoint.
This commit is contained in:
Julien Gautier
2026-05-16 23:07:51 +02:00
parent 209f44d667
commit 875d61d858
9 changed files with 638 additions and 3 deletions
@@ -110,6 +110,10 @@ enum AuditOutcome {
| `auth.token.validation.failed` | a token presented mid-session fails validation (signature, `iss`, audience, claim mismatch) | `failure` |
| `auth.mfa.assertion.failed` | the BFF rejects a session for missing or weak `amr` (ADR-0011) | `failure` |
| `authz.deny` | a guard rejects an authenticated request because the user's `audience`/claims don't authorise the action | `failure` |
| `admin.access_denied` | `AdminRoleGuard` rejects an authenticated non-admin on `/api/admin/*` | `denied` |
| `admin.audit.query` | admin called `GET /api/admin/audit` (paginated rows). Payload carries `filters` + `resultCount` | `success` |
| `admin.audit.stats.query` | admin called `GET /api/admin/audit/stats` (aggregations). Payload carries `filters` + `total` | `success` |
| `admin.users.query` | admin called `GET /api/admin/users` (user-directory listing). Payload carries `filters` + `resultCount` | `success` |
The `details` JSONB field carries event-specific information (e.g. expected vs received `iss`, denied route, claim names involved). Sensitive material (full tokens, claims that should never leave the BFF) is _never_ placed in `details` — the same redaction posture as app logs applies, enforced by typed event payloads at the writer's boundary.
@@ -134,6 +138,15 @@ Hooks for **admin actions** and **sensitive data access** are designed-in: the w
**GDPR / right-of-erasure** interactions: audit events are typically retained under a "legal obligation" or "legitimate interest" basis even after a user's right-of-erasure request. The userId itself is already pseudonymised (`actor_id_hash`); the join key to user identity exists in the live DB only and disappears with account deletion, leaving the audit row scientifically pseudonymous. Whether this is legally sufficient is the legal team's call. The current implementation is compatible with both "keep" and "anonymise further" policies (we can null `actor_id_hash` for archived users without violating append-only because _zeroing_ counts as a sanctioned operation under a yet-to-define `audit_redactor` role — designed-in, not implemented in v1).
**Reader endpoints.** Two BFF routes consume `audit.events` via the `audit_reader` role, both under `/api/admin/audit` and guarded by `@RequireAdmin` per [ADR-0020](0020-portal-admin-app.md):
| Route | Purpose | Read pattern |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `GET /api/admin/audit` | Paginated raw rows for the audit-log table viewer | `SELECT … LIMIT/OFFSET` capped at 200 rows |
| `GET /api/admin/audit/stats` | Three aggregations (events per day, outcome breakdown, events per day by event-type) for the audit-log "Charts" tab | Three `GROUP BY` queries over the filtered set, Redis-cached for 300 s per filter-hash |
The stats endpoint introduces a 5-minute Redis cache: audit rows are append-only so past aggregations are stable, but new events are continuously inserted — a 5-minute TTL is the chosen compromise. Admins see at most 5-minute-stale aggregations (acceptable for "approximate dashboard" use; not appropriate for "did the last event just land" debugging, which the paginated `GET /api/admin/audit` is for). Cache keys are the SHA-256 of the canonical (sorted-keys) JSON of the filter object, scoped under the `audit:stats:` Redis key prefix. Cache writes are best-effort — a Redis-write failure does not fail the response. Each stats query emits its own audit row (`admin.audit.stats.query`) with `total` (size of the aggregated set) as the deterrent signal, distinct from `admin.audit.query`'s `resultCount`.
**Configuration (env-driven).**
| Variable | Purpose |