feat(portal-bff): audit-stats endpoint — server-side aggregations with redis cache #173

Merged
julien merged 1 commits from feat/portal-bff-audit-stats-endpoint into main 2026-05-16 23:11:53 +02:00
Owner

Summary

PR 1 of the tabs + full-result-charts chantier. New BFF endpoint GET /api/admin/audit/stats that computes the three chart aggregations server-side over the full filtered set (not the paginated slice the SPA currently feeds the charts with).

PR 1 (this one) — BFF endpoint + Redis cache + audit event + ADR-0013 amendment.
PR 2            — SPA: Tabs UX (Table / Charts) + replace the per-page computeds
                  with calls to this endpoint.

What lands

New route — GET /api/admin/audit/stats

GET /api/admin/audit/stats?eventType=...&audience=...&outcome=...
                          &subjectPrefix=...&createdAtFrom=...&createdAtTo=...
                          &actorIdHash=...
 {
    dailyVolume:       [{ day: 'YYYY-MM-DD', count }],
    outcomeBreakdown:  [{ outcome, count }],
    eventTypeByDay:    [{ day, eventType, count }],
    total              // sum of dailyVolume.count, drives the donut centre
  }

Same filter shape as the existing GET /api/admin/audit minus pagination — the stats endpoint always aggregates the whole filtered set. @RequireAdmin gated (per ADR-0020). Time bound respects the filters strictly per the 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

Mirrors AuditReader's posture:

  • Every query inside a transaction that opens with SET LOCAL ROLE audit_reader. SELECT-only on audit.events even if the BFF's connection is otherwise privileged.
  • Parameterised SQL only. Filter values flow through positional parameters, never concatenated.
  • Three GROUP BY queries scoped by the same WHERE clause:
    • date_trunc('day', created_at)::date AS day, COUNT(*) GROUP BY day
    • outcome::text, COUNT(*) GROUP BY outcome
    • date_trunc('day', created_at)::date AS day, event_type, COUNT(*) GROUP BY day, event_type

Redis cache — 5-minute TTL per filter-hash

  • Cache key: audit:stats:<sha256(canonical-JSON of filters), 16 hex chars>. Sorted-keys canonicalisation so the same filters in different argument orders map to the same key.
  • TTL: 300 s. Audit rows are append-only so past aggregations are stable; new events are continuously inserted, so admins see at most 5-minute-stale aggregations — acceptable for "approximate dashboard" usage, not for "did the last event just land" debugging (use the list endpoint for that).
  • Cache writes are best-effort — a Redis-write failure does not fail the response. The DB read already happened; the next call rebuilds the cache.
  • The cache write path is covered by spec; the cache-hit shortcut path is covered too (skips the DB transaction entirely).

New audit event — admin.audit.stats.query

Mirrors admin.audit.query in posture (every admin read is auditable per ADR-0020 §"Read actions ... to deter fishing expeditions") with two differences:

  • Distinct event_type so an auditor can spot "scanned aggregations" vs "paged through rows" — different observation signals (the stats endpoint can sweep millions of rows in one call; the list endpoint is bounded by MAX_LIMIT=200).
  • Payload carries total (size of the aggregated set) instead of resultCount — stats responses don't paginate, the value carries more "size of scan" signal.

Light amendment — ADR-0013

Two additions:

  • New "Reader endpoints" subsection that enumerates the two-endpoint reader surface (list + stats), documents the Redis-cache caveat, and points at the new admin.audit.stats.query event family.
  • The "events emitted in v1" table grows four rows it was previously missing on main: admin.access_denied, admin.audit.query, admin.audit.stats.query, admin.users.query.

No supersession, no new ADR. The decision shape (server-side aggregation + Redis cache + new audit event family) was settled in chat via AskUserQuestion before the implementation started; recording it here keeps the ADR honest without spawning a full ADR-0024 for what's essentially an extension of ADR-0013's reader surface.

Notes for the reviewer

  • Why not factor buildWhere into a shared helper between AuditReader and AuditStatsReader? Considered. The two readers' shapes diverge in non-trivial ways: AuditReader adds LIMIT/OFFSET parameters appended to the same parameter array, AuditStatsReader runs three queries that all share the same WHERE (no further params). A shared helper would have to either expose both shapes or hand back the raw clauses + params for callers to assemble — at which point the abstraction earns its weight back. Two ~50 LOC copies today, extraction when a third reader lands or when the shape diverges further.
  • Why not cap the time window when no filter is provided? Honest disclosure beats clever defaults. The list endpoint also returns "everything matching the filters" with no protective cap; the stats endpoint follows the same posture. The Redis cache absorbs the cost when the same heavy query lands repeatedly; an admin running unfiltered queries at high rate will see flat latency after the first call. If we later observe a real perf issue, a windowDays parameter is a smaller change than retrofitting one across the API.
  • Why a text cast on outcome in the SQL? Prisma's Postgres enum types come back as JS strings already, but the outcome column carries a Postgres enum (audit.AuditOutcome). The explicit ::text is defensive — $queryRawUnsafe's typing isn't enum-aware, and the cast keeps the projection unambiguous regardless of the driver's row-shape inference.
  • Why does the date round-trip through Date.toISOString().slice(0, 10)? date_trunc('day', ...)::date returns a Postgres date that node-postgres surfaces as a JS Date at UTC midnight. The default toJSON serialises the full ISO timestamp with the timezone offset — which is not what the chart x-axis wants. Slicing to YYYY-MM-DD matches the SPA's chart bucket convention exactly.
  • No mention of the actorIdHash audit row for the stats endpoint? It's the same hash flow as adminAuditQuery — the actor.oid from the session goes through HashUserIdService per ADR-0012's salt-based pseudonymisation. The same flow is exercised by the existing adminAuditQuery tests; the new adminAuditStatsQuery method just routes to recordEvent with a different eventType.

Test plan

  • pnpm nx test portal-bff414 specs pass (was 401; +13 new: 8 AuditStatsReader service + 5 controller stats endpoint).
  • pnpm nx run portal-bff:lint — clean.
  • pnpm nx build portal-bff — clean (webpack).
  • Manual smokepnpm nx serve portal-bff, sign in to portal-admin with Portal.Admin:
    • curl http://localhost:3000/api/admin/audit/stats --cookie-jar /tmp/admin returns the three projections.
    • Verify the admin.audit.stats.query row in audit.events after the call (SELECT * FROM audit.events WHERE event_type = 'admin.audit.stats.query' ORDER BY occurred_at DESC LIMIT 1).
    • Hit the endpoint twice in quick succession with the same filters → second call shows < 5 ms latency (cache hit, no DB transaction).
    • Hit it with different filters → first call hits DB, second cache, third with same filters → cache hit.
    • Stop Redis (./infra/local/dev.sh stop redis), hit the endpoint → still succeeds (cache miss + write swallowed), comes back live from DB.

What's next

PR 2 — SPA Tabs UX (Table / Charts) + replace dailyVolume() / outcomeBreakdown() / dailyByEventType() (currently computed from page().items) with calls to this endpoint. The three computeds become signals filled by the HTTP call; the chart components on the Charts tab consume them unchanged.

## Summary PR 1 of the tabs + full-result-charts chantier. New BFF endpoint `GET /api/admin/audit/stats` that computes the three chart aggregations server-side over the **full filtered set** (not the paginated slice the SPA currently feeds the charts with). ``` PR 1 (this one) — BFF endpoint + Redis cache + audit event + ADR-0013 amendment. PR 2 — SPA: Tabs UX (Table / Charts) + replace the per-page computeds with calls to this endpoint. ``` ## What lands ### New route — `GET /api/admin/audit/stats` ```ts GET /api/admin/audit/stats?eventType=...&audience=...&outcome=... &subjectPrefix=...&createdAtFrom=...&createdAtTo=... &actorIdHash=... → { dailyVolume: [{ day: 'YYYY-MM-DD', count }], outcomeBreakdown: [{ outcome, count }], eventTypeByDay: [{ day, eventType, count }], total // sum of dailyVolume.count, drives the donut centre } ``` Same filter shape as the existing `GET /api/admin/audit` minus pagination — the stats endpoint always aggregates the whole filtered set. `@RequireAdmin` gated (per [ADR-0020](docs/decisions/0020-portal-admin-app.md)). Time bound respects the filters strictly per the chantier brief: no filter → aggregates across the full audit retention (365 days per [ADR-0013](docs/decisions/0013-audit-trail-separated-postgres-append-only.md)). The Redis cache below absorbs repeated heavy queries. ### New service — [`AuditStatsReader`](apps/portal-bff/src/admin/audit-stats.service.ts) Mirrors `AuditReader`'s posture: - Every query inside a transaction that opens with `SET LOCAL ROLE audit_reader`. SELECT-only on `audit.events` even if the BFF's connection is otherwise privileged. - Parameterised SQL only. Filter values flow through positional parameters, never concatenated. - Three `GROUP BY` queries scoped by the same `WHERE` clause: - `date_trunc('day', created_at)::date AS day, COUNT(*) GROUP BY day` - `outcome::text, COUNT(*) GROUP BY outcome` - `date_trunc('day', created_at)::date AS day, event_type, COUNT(*) GROUP BY day, event_type` ### Redis cache — 5-minute TTL per filter-hash - Cache key: `audit:stats:<sha256(canonical-JSON of filters), 16 hex chars>`. Sorted-keys canonicalisation so the same filters in different argument orders map to the same key. - TTL: 300 s. Audit rows are append-only so past aggregations are stable; new events are continuously inserted, so admins see at most 5-minute-stale aggregations — acceptable for "approximate dashboard" usage, not for "did the last event just land" debugging (use the list endpoint for that). - Cache writes are best-effort — a Redis-write failure does not fail the response. The DB read already happened; the next call rebuilds the cache. - The cache *write* path is covered by spec; the cache-hit shortcut path is covered too (skips the DB transaction entirely). ### New audit event — `admin.audit.stats.query` Mirrors `admin.audit.query` in posture (every admin read is auditable per ADR-0020 §"Read actions ... to deter fishing expeditions") with two differences: - Distinct `event_type` so an auditor can spot "scanned aggregations" vs "paged through rows" — different observation signals (the stats endpoint can sweep millions of rows in one call; the list endpoint is bounded by `MAX_LIMIT=200`). - Payload carries `total` (size of the aggregated set) instead of `resultCount` — stats responses don't paginate, the value carries more "size of scan" signal. ### Light amendment — [ADR-0013](docs/decisions/0013-audit-trail-separated-postgres-append-only.md) Two additions: - New **"Reader endpoints"** subsection that enumerates the two-endpoint reader surface (list + stats), documents the Redis-cache caveat, and points at the new `admin.audit.stats.query` event family. - The "events emitted in v1" table grows four rows it was previously missing on `main`: `admin.access_denied`, `admin.audit.query`, `admin.audit.stats.query`, `admin.users.query`. No supersession, no new ADR. The decision shape (server-side aggregation + Redis cache + new audit event family) was settled in chat via `AskUserQuestion` before the implementation started; recording it here keeps the ADR honest without spawning a full ADR-0024 for what's essentially an extension of ADR-0013's reader surface. ## Notes for the reviewer - **Why not factor `buildWhere` into a shared helper between `AuditReader` and `AuditStatsReader`?** Considered. The two readers' shapes diverge in non-trivial ways: `AuditReader` adds `LIMIT/OFFSET` parameters appended to the same parameter array, `AuditStatsReader` runs three queries that all share the same `WHERE` (no further params). A shared helper would have to either expose both shapes or hand back the raw clauses + params for callers to assemble — at which point the abstraction earns its weight back. Two ~50 LOC copies today, extraction when a third reader lands or when the shape diverges further. - **Why not cap the time window when no filter is provided?** Honest disclosure beats clever defaults. The list endpoint also returns "everything matching the filters" with no protective cap; the stats endpoint follows the same posture. The Redis cache absorbs the cost when the same heavy query lands repeatedly; an admin running unfiltered queries at high rate will see flat latency after the first call. If we later observe a real perf issue, a `windowDays` parameter is a smaller change than retrofitting one across the API. - **Why a `text` cast on `outcome` in the SQL?** Prisma's Postgres enum types come back as JS strings already, but the `outcome` column carries a Postgres enum (`audit.AuditOutcome`). The explicit `::text` is defensive — `$queryRawUnsafe`'s typing isn't enum-aware, and the cast keeps the projection unambiguous regardless of the driver's row-shape inference. - **Why does the date round-trip through `Date.toISOString().slice(0, 10)`?** `date_trunc('day', ...)::date` returns a Postgres `date` that node-postgres surfaces as a JS `Date` at UTC midnight. The default `toJSON` serialises the full ISO timestamp with the timezone offset — which is not what the chart x-axis wants. Slicing to `YYYY-MM-DD` matches the SPA's chart bucket convention exactly. - **No mention of the `actorIdHash` audit row for the stats endpoint?** It's the same hash flow as `adminAuditQuery` — the `actor.oid` from the session goes through `HashUserIdService` per ADR-0012's salt-based pseudonymisation. The same flow is exercised by the existing `adminAuditQuery` tests; the new `adminAuditStatsQuery` method just routes to `recordEvent` with a different `eventType`. ## Test plan - [x] `pnpm nx test portal-bff` — **414 specs pass** (was 401; +13 new: 8 `AuditStatsReader` service + 5 controller `stats` endpoint). - [x] `pnpm nx run portal-bff:lint` — clean. - [x] `pnpm nx build portal-bff` — clean (webpack). - [ ] **Manual smoke** — `pnpm nx serve portal-bff`, sign in to portal-admin with `Portal.Admin`: - `curl http://localhost:3000/api/admin/audit/stats --cookie-jar /tmp/admin` returns the three projections. - Verify the `admin.audit.stats.query` row in `audit.events` after the call (`SELECT * FROM audit.events WHERE event_type = 'admin.audit.stats.query' ORDER BY occurred_at DESC LIMIT 1`). - Hit the endpoint twice in quick succession with the same filters → second call shows < 5 ms latency (cache hit, no DB transaction). - Hit it with different filters → first call hits DB, second cache, third with same filters → cache hit. - Stop Redis (`./infra/local/dev.sh stop redis`), hit the endpoint → still succeeds (cache miss + write swallowed), comes back live from DB. ## What's next PR 2 — SPA Tabs UX (Table / Charts) + replace `dailyVolume() / outcomeBreakdown() / dailyByEventType()` (currently computed from `page().items`) with calls to this endpoint. The three computeds become signals filled by the HTTP call; the chart components on the Charts tab consume them unchanged.
julien added 1 commit 2026-05-16 23:11:41 +02:00
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
875d61d858
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.
julien merged commit 2cdeb74341 into main 2026-05-16 23:11:53 +02:00
julien deleted branch feat/portal-bff-audit-stats-endpoint 2026-05-16 23:11:57 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#173