From 2cdeb7434109acc321cc5f60660e9a11f6201219 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Sat, 16 May 2026 23:11:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(portal-bff):=20audit-stats=20endpoint=20?= =?UTF-8?q?=E2=80=94=20server-side=20aggregations=20with=20redis=20cache?= =?UTF-8?q?=20(#173)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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:`. 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. --------- Co-authored-by: Julien Gautier Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/173 --- .../src/admin/admin-audit.controller.spec.ts | 64 ++++- .../src/admin/admin-audit.controller.ts | 37 +++ apps/portal-bff/src/admin/admin.module.ts | 6 +- apps/portal-bff/src/admin/audit-stats.dto.ts | 57 +++++ .../src/admin/audit-stats.service.spec.ts | 199 +++++++++++++++ .../src/admin/audit-stats.service.ts | 226 ++++++++++++++++++ apps/portal-bff/src/audit/audit.service.ts | 25 ++ apps/portal-bff/src/audit/audit.types.ts | 14 ++ ...it-trail-separated-postgres-append-only.md | 13 + 9 files changed, 638 insertions(+), 3 deletions(-) create mode 100644 apps/portal-bff/src/admin/audit-stats.dto.ts create mode 100644 apps/portal-bff/src/admin/audit-stats.service.spec.ts create mode 100644 apps/portal-bff/src/admin/audit-stats.service.ts diff --git a/apps/portal-bff/src/admin/admin-audit.controller.spec.ts b/apps/portal-bff/src/admin/admin-audit.controller.spec.ts index c09cf23..72ee478 100644 --- a/apps/portal-bff/src/admin/admin-audit.controller.spec.ts +++ b/apps/portal-bff/src/admin/admin-audit.controller.spec.ts @@ -2,6 +2,8 @@ import type { Request } from 'express'; import { AdminAuditController } from './admin-audit.controller'; import type { AdminAuditQueryDto } from './audit-query.dto'; import type { AuditReader, AdminAuditPage } from './audit-reader.service'; +import type { AdminAuditStatsQueryDto } from './audit-stats.dto'; +import type { AuditStatsReader, AdminAuditStats } from './audit-stats.service'; import type { AuditWriter } from '../audit/audit.service'; const PAGE: AdminAuditPage = { @@ -27,18 +29,30 @@ function makeReq(user?: { oid: string }): Request { return { session: user !== undefined ? { user } : {} } as unknown as Request; } +const STATS: AdminAuditStats = { + dailyVolume: [{ day: '2026-05-13', count: 1 }], + outcomeBreakdown: [{ outcome: 'success', count: 1 }], + eventTypeByDay: [{ day: '2026-05-13', eventType: 'auth.sign_in', count: 1 }], + total: 1, +}; + function makeFixture() { const auditReader = { findEvents: jest.fn().mockResolvedValue(PAGE), }; + const auditStatsReader = { + getStats: jest.fn().mockResolvedValue(STATS), + }; const auditWriter = { adminAuditQuery: jest.fn().mockResolvedValue(undefined), + adminAuditStatsQuery: jest.fn().mockResolvedValue(undefined), }; const controller = new AdminAuditController( auditReader as unknown as AuditReader, + auditStatsReader as unknown as AuditStatsReader, auditWriter as unknown as AuditWriter, ); - return { controller, auditReader, auditWriter }; + return { controller, auditReader, auditStatsReader, auditWriter }; } describe('AdminAuditController.list', () => { @@ -98,3 +112,51 @@ describe('AdminAuditController.list', () => { ).rejects.toThrow('audit_writer denied'); }); }); + +describe('AdminAuditController.stats', () => { + it('returns the aggregated stats from AuditStatsReader', async () => { + const { controller } = makeFixture(); + const result = await controller.stats( + makeReq({ oid: 'admin-oid' }), + {} as AdminAuditStatsQueryDto, + ); + expect(result).toBe(STATS); + }); + + it('forwards the validated DTO to AuditStatsReader as-is', async () => { + const { controller, auditStatsReader } = makeFixture(); + const filters: AdminAuditStatsQueryDto = { + eventType: 'auth.sign_in', + audience: 'workforce', + createdAtFrom: '2026-05-01T00:00:00Z', + }; + await controller.stats(makeReq({ oid: 'admin-oid' }), filters); + expect(auditStatsReader.getStats).toHaveBeenCalledWith(filters); + }); + + it('emits admin.audit.stats.query with the filters + total as the deterrent signal', async () => { + const { controller, auditWriter } = makeFixture(); + const filters: AdminAuditStatsQueryDto = { outcome: 'denied' }; + await controller.stats(makeReq({ oid: 'admin-oid' }), filters); + expect(auditWriter.adminAuditStatsQuery).toHaveBeenCalledWith({ + actor: { oid: 'admin-oid' }, + filters: { outcome: 'denied' }, + total: STATS.total, + }); + }); + + it('still returns stats when there is no session.user (defensive)', async () => { + const { controller, auditWriter } = makeFixture(); + const result = await controller.stats(makeReq(), {} as AdminAuditStatsQueryDto); + expect(result).toBe(STATS); + expect(auditWriter.adminAuditStatsQuery).not.toHaveBeenCalled(); + }); + + it('propagates audit write failures (same blocking posture as list)', async () => { + const { controller, auditWriter } = makeFixture(); + auditWriter.adminAuditStatsQuery.mockRejectedValueOnce(new Error('audit_writer denied')); + await expect( + controller.stats(makeReq({ oid: 'admin-oid' }), {} as AdminAuditStatsQueryDto), + ).rejects.toThrow('audit_writer denied'); + }); +}); diff --git a/apps/portal-bff/src/admin/admin-audit.controller.ts b/apps/portal-bff/src/admin/admin-audit.controller.ts index 73b5107..69d5ab8 100644 --- a/apps/portal-bff/src/admin/admin-audit.controller.ts +++ b/apps/portal-bff/src/admin/admin-audit.controller.ts @@ -4,6 +4,8 @@ import type { Request } from 'express'; import { AuditWriter } from '../audit/audit.service'; import { AdminAuditQueryDto } from './audit-query.dto'; import { AuditReader, type AdminAuditPage } from './audit-reader.service'; +import { AdminAuditStatsQueryDto } from './audit-stats.dto'; +import { AuditStatsReader, type AdminAuditStats } from './audit-stats.service'; import { RequireAdmin } from './require-admin.decorator'; /** @@ -35,6 +37,7 @@ import { RequireAdmin } from './require-admin.decorator'; export class AdminAuditController { constructor( private readonly auditReader: AuditReader, + private readonly auditStats: AuditStatsReader, private readonly audit: AuditWriter, ) {} @@ -61,4 +64,38 @@ export class AdminAuditController { return page; } + + /** + * `GET /api/admin/audit/stats` — server-side aggregations over + * the full filtered set (no pagination). Powers the "Charts" tab + * of the audit viewer per the chantier brief. + * + * Emits `admin.audit.stats.query` per call — same fishing- + * expedition deterrent posture as `list` above. Results are + * Redis-cached for 5 minutes inside `AuditStatsReader` to absorb + * the cost of repeated identical queries (the SPA hits this on + * each tab switch + filter apply). + */ + @ApiOperation({ + summary: + 'Aggregated audit stats (dailyVolume, outcomeBreakdown, eventTypeByDay) for the filtered set', + }) + @Get('stats') + async stats( + @Req() req: Request, + @Query() filters: AdminAuditStatsQueryDto, + ): Promise { + const result = await this.auditStats.getStats(filters); + + const actorOid = req.session.user?.oid; + if (actorOid !== undefined) { + await this.audit.adminAuditStatsQuery({ + actor: { oid: actorOid }, + filters: { ...filters }, + total: result.total, + }); + } + + return result; + } } diff --git a/apps/portal-bff/src/admin/admin.module.ts b/apps/portal-bff/src/admin/admin.module.ts index f01d183..9a552fd 100644 --- a/apps/portal-bff/src/admin/admin.module.ts +++ b/apps/portal-bff/src/admin/admin.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; +import { RedisModule } from '../redis/redis.module'; import { AdminAuditController } from './admin-audit.controller'; import { AdminAuthController } from './admin-auth.controller'; import { AdminController } from './admin.controller'; @@ -7,6 +8,7 @@ import { AdminRoleGuard } from './admin-role.guard'; import { AdminUsersController } from './admin-users.controller'; import { AdminUsersReader } from './admin-users-reader.service'; import { AuditReader } from './audit-reader.service'; +import { AuditStatsReader } from './audit-stats.service'; /** * `AdminModule` — root of the `/api/admin/*` surface per ADR-0020. @@ -31,8 +33,8 @@ import { AuditReader } from './audit-reader.service'; * by `AuditModule`, so no extra import is needed for it. */ @Module({ - imports: [AuthModule], + imports: [AuthModule, RedisModule], controllers: [AdminController, AdminAuthController, AdminAuditController, AdminUsersController], - providers: [AdminRoleGuard, AuditReader, AdminUsersReader], + providers: [AdminRoleGuard, AuditReader, AuditStatsReader, AdminUsersReader], }) export class AdminModule {} diff --git a/apps/portal-bff/src/admin/audit-stats.dto.ts b/apps/portal-bff/src/admin/audit-stats.dto.ts new file mode 100644 index 0000000..38e81ec --- /dev/null +++ b/apps/portal-bff/src/admin/audit-stats.dto.ts @@ -0,0 +1,57 @@ +import { Type } from 'class-transformer'; +import { IsEnum, IsISO8601, IsOptional, IsString, MaxLength } from 'class-validator'; + +/** + * Query parameters for `GET /api/admin/audit/stats`. Mirrors the + * filter shape of {@link AdminAuditQueryDto} **minus pagination**: + * the stats endpoint always aggregates the whole filtered set, so + * `limit` / `offset` carry no meaning. + * + * Per the chantier brief, the endpoint respects filters strictly — + * an unfiltered call aggregates across the full audit retention + * (365 days by default per ADR-0013). The 5-minute Redis cache + * absorbs the cost of a heavy unfiltered query if the same shape + * lands twice in quick succession. + */ + +/** Subset of `AuditAudience` accepted by the filter — matches the list endpoint. */ +const AUDIT_AUDIENCES = ['workforce', 'customer'] as const; +type AuditAudienceFilter = (typeof AUDIT_AUDIENCES)[number]; + +/** Subset of `AuditOutcome` accepted by the filter. */ +const AUDIT_OUTCOMES = ['success', 'failure', 'denied'] as const; +type AuditOutcomeFilter = (typeof AUDIT_OUTCOMES)[number]; + +export class AdminAuditStatsQueryDto { + @IsOptional() + @IsString() + @MaxLength(128) + eventType?: string; + + @IsOptional() + @IsString() + @MaxLength(128) + actorIdHash?: string; + + @IsOptional() + @IsEnum(AUDIT_AUDIENCES) + audience?: AuditAudienceFilter; + + @IsOptional() + @IsEnum(AUDIT_OUTCOMES) + outcome?: AuditOutcomeFilter; + + @IsOptional() + @IsString() + @MaxLength(128) + subjectPrefix?: string; + + @IsOptional() + @IsISO8601() + createdAtFrom?: string; + + @IsOptional() + @IsISO8601() + @Type(() => String) + createdAtTo?: string; +} diff --git a/apps/portal-bff/src/admin/audit-stats.service.spec.ts b/apps/portal-bff/src/admin/audit-stats.service.spec.ts new file mode 100644 index 0000000..93c8121 --- /dev/null +++ b/apps/portal-bff/src/admin/audit-stats.service.spec.ts @@ -0,0 +1,199 @@ +import { Test } from '@nestjs/testing'; +import { PrismaService } from 'nestjs-prisma'; +import { REDIS_CLIENT } from '../redis/redis.token'; +import type { AdminAuditStatsQueryDto } from './audit-stats.dto'; +import { AuditStatsReader } from './audit-stats.service'; + +interface MockTx { + $executeRawUnsafe: jest.Mock; + $queryRawUnsafe: jest.Mock; +} + +interface MockPrisma { + $transaction: jest.Mock; + tx: MockTx; +} + +interface MockRedis { + get: jest.Mock; + set: jest.Mock; +} + +function buildPrisma(opts?: { + dailyRows?: Array<{ day: Date; count: bigint }>; + outcomeRows?: Array<{ outcome: string; count: bigint }>; + eventTypeRows?: Array<{ day: Date; event_type: string; count: bigint }>; +}): MockPrisma { + const tx: MockTx = { + $executeRawUnsafe: jest.fn().mockResolvedValue(0), + $queryRawUnsafe: jest + .fn() + // Order matches the service: dailyVolume → outcomeBreakdown → eventTypeByDay. + .mockResolvedValueOnce(opts?.dailyRows ?? []) + .mockResolvedValueOnce(opts?.outcomeRows ?? []) + .mockResolvedValueOnce(opts?.eventTypeRows ?? []), + }; + return { + tx, + $transaction: jest.fn(async (fn: (tx: MockTx) => Promise) => fn(tx)), + }; +} + +function buildRedis(cached?: string): MockRedis { + return { + get: jest.fn().mockResolvedValue(cached ?? null), + set: jest.fn().mockResolvedValue('OK'), + }; +} + +async function createSubject(prisma: MockPrisma, redis: MockRedis): Promise { + const moduleRef = await Test.createTestingModule({ + providers: [ + AuditStatsReader, + { provide: PrismaService, useValue: prisma }, + { provide: REDIS_CLIENT, useValue: redis }, + ], + }).compile(); + return moduleRef.get(AuditStatsReader); +} + +describe('AuditStatsReader', () => { + it('returns the cached payload when Redis has a hit (no DB call)', async () => { + const cached = JSON.stringify({ + dailyVolume: [{ day: '2026-05-13', count: 12 }], + outcomeBreakdown: [{ outcome: 'success', count: 12 }], + eventTypeByDay: [{ day: '2026-05-13', eventType: 'auth.sign_in', count: 12 }], + total: 12, + }); + const prisma = buildPrisma(); + const redis = buildRedis(cached); + const reader = await createSubject(prisma, redis); + + const result = await reader.getStats({}); + + expect(result.total).toBe(12); + expect(prisma.$transaction).not.toHaveBeenCalled(); + expect(prisma.tx.$queryRawUnsafe).not.toHaveBeenCalled(); + expect(redis.set).not.toHaveBeenCalled(); + }); + + it('locks the transaction to audit_reader before running GROUP BY queries', async () => { + const prisma = buildPrisma(); + const redis = buildRedis(); + const reader = await createSubject(prisma, redis); + + await reader.getStats({}); + + const setRoleCalls = prisma.tx.$executeRawUnsafe.mock.calls; + expect(setRoleCalls[0]?.[0]).toBe('SET LOCAL ROLE audit_reader'); + }); + + it('issues exactly three GROUP BY queries (daily / outcome / by-event-type)', async () => { + const prisma = buildPrisma(); + const redis = buildRedis(); + const reader = await createSubject(prisma, redis); + + await reader.getStats({}); + + const queries = prisma.tx.$queryRawUnsafe.mock.calls; + expect(queries).toHaveLength(3); + expect(queries[0]?.[0]).toMatch( + /date_trunc\('day', created_at\)::date AS day[\s\S]*GROUP BY day/, + ); + expect(queries[1]?.[0]).toMatch(/outcome::text AS outcome[\s\S]*GROUP BY outcome/); + expect(queries[2]?.[0]).toMatch(/GROUP BY day, event_type/); + }); + + it('projects rows into the SPA-facing shape (ISO day, numeric count, computed total)', async () => { + const prisma = buildPrisma({ + dailyRows: [ + { day: new Date('2026-05-13T00:00:00.000Z'), count: 8n }, + { day: new Date('2026-05-14T00:00:00.000Z'), count: 12n }, + ], + outcomeRows: [ + { outcome: 'success', count: 18n }, + { outcome: 'failure', count: 2n }, + ], + eventTypeRows: [ + { day: new Date('2026-05-13T00:00:00.000Z'), event_type: 'auth.sign_in', count: 8n }, + ], + }); + const redis = buildRedis(); + const reader = await createSubject(prisma, redis); + + const result = await reader.getStats({}); + + expect(result.dailyVolume).toEqual([ + { day: '2026-05-13', count: 8 }, + { day: '2026-05-14', count: 12 }, + ]); + expect(result.outcomeBreakdown).toEqual([ + { outcome: 'success', count: 18 }, + { outcome: 'failure', count: 2 }, + ]); + expect(result.eventTypeByDay).toEqual([ + { day: '2026-05-13', eventType: 'auth.sign_in', count: 8 }, + ]); + expect(result.total).toBe(20); + }); + + it('writes the result to Redis with the 5-minute TTL on cache miss', async () => { + const prisma = buildPrisma(); + const redis = buildRedis(); + const reader = await createSubject(prisma, redis); + + await reader.getStats({ outcome: 'denied' }); + + expect(redis.set).toHaveBeenCalledTimes(1); + const [key, payload, mode, ttl] = redis.set.mock.calls[0] as [string, string, string, number]; + expect(key).toMatch(/^audit:stats:[0-9a-f]{16}$/); + expect(JSON.parse(payload)).toMatchObject({ total: 0 }); + expect(mode).toBe('EX'); + expect(ttl).toBe(300); + }); + + it('uses the same cache key for identical filter shapes regardless of key order', async () => { + const prisma1 = buildPrisma(); + const redis1 = buildRedis(); + const reader1 = await createSubject(prisma1, redis1); + const prisma2 = buildPrisma(); + const redis2 = buildRedis(); + const reader2 = await createSubject(prisma2, redis2); + + const a: AdminAuditStatsQueryDto = { eventType: 'auth.sign_in', outcome: 'success' }; + const b: AdminAuditStatsQueryDto = { outcome: 'success', eventType: 'auth.sign_in' }; + + await reader1.getStats(a); + await reader2.getStats(b); + + const key1 = (redis1.get.mock.calls[0] as [string])[0]; + const key2 = (redis2.get.mock.calls[0] as [string])[0]; + expect(key1).toBe(key2); + }); + + it('passes filter values as positional parameters (no string concatenation)', async () => { + const prisma = buildPrisma(); + const redis = buildRedis(); + const reader = await createSubject(prisma, redis); + + await reader.getStats({ eventType: 'auth.sign_in', outcome: 'denied' }); + + // All three queries should carry the same `params` tail — + // event_type then outcome cast to enum. Verify on the first one. + const [, ...params] = prisma.tx.$queryRawUnsafe.mock.calls[0] as [string, ...unknown[]]; + expect(params).toEqual(['auth.sign_in', 'denied']); + }); + + it('survives a Redis-write failure on cache miss (best-effort cache, response still flows)', async () => { + const prisma = buildPrisma({ + dailyRows: [{ day: new Date('2026-05-13T00:00:00.000Z'), count: 5n }], + }); + const redis = buildRedis(); + redis.set.mockRejectedValueOnce(new Error('redis unavailable')); + const reader = await createSubject(prisma, redis); + + const result = await reader.getStats({}); + + expect(result.total).toBe(5); + }); +}); diff --git a/apps/portal-bff/src/admin/audit-stats.service.ts b/apps/portal-bff/src/admin/audit-stats.service.ts new file mode 100644 index 0000000..b937663 --- /dev/null +++ b/apps/portal-bff/src/admin/audit-stats.service.ts @@ -0,0 +1,226 @@ +import { createHash } from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import { PrismaService } from 'nestjs-prisma'; +import { REDIS_CLIENT, type Redis } from '../redis/redis.token'; +import type { AdminAuditStatsQueryDto } from './audit-stats.dto'; + +/** + * Aggregated audit-event statistics — the shape consumed by the + * portal-admin `/audit` page's "Charts" tab (PR 2 of the stats + * chantier). + * + * Each axis matches one of the three charts: + * - `dailyVolume` → bar chart of events per UTC day, + * - `outcomeBreakdown` → donut of success/failure/denied counts, + * - `eventTypeByDay` → stacked-bar of events per day, by type. + * + * Returned to the SPA as plain JSON; consumed by the shared chart + * components without further transformation. + */ +export interface AdminAuditStats { + readonly dailyVolume: ReadonlyArray<{ day: string; count: number }>; + readonly outcomeBreakdown: ReadonlyArray<{ outcome: string; count: number }>; + readonly eventTypeByDay: ReadonlyArray<{ day: string; eventType: string; count: number }>; + /** Sum of `dailyVolume.count` — drives the donut centre label. */ + readonly total: number; +} + +/** + * Redis cache TTL for stats queries. Audit rows are append-only so + * past aggregations are stable, but new events are continuously + * inserted — a 5-minute TTL is the chosen compromise (per the + * chantier brief): admins see at most 5-minute-stale aggregations + * which is fine for "approximate dashboard" usage; not appropriate + * for "did the last event land yet" debugging (use the list + * endpoint for that). + */ +const STATS_CACHE_TTL_SECONDS = 300; + +/** Key prefix in Redis so cache keys are scoped under one root. */ +const CACHE_KEY_PREFIX = 'audit:stats:'; + +/** + * `AuditStatsReader` — server-side audit aggregations behind a + * Redis cache. + * + * Contract mirrors `AuditReader` for consistency: + * - Every query runs in a transaction that opens with + * `SET LOCAL ROLE audit_reader`, so SELECT is the only DB + * verb that can succeed even if the BFF's connection were + * otherwise privileged. + * - Parameterised SQL only; filter values flow through positional + * parameters, never concatenated into the SQL string. + * + * Cache key is a SHA-256 of the canonical (sorted-keys) JSON + * representation of the filter object — deterministic across + * processes, immune to JSON key ordering. + */ +@Injectable() +export class AuditStatsReader { + constructor( + private readonly prisma: PrismaService, + @Inject(REDIS_CLIENT) private readonly redis: Redis, + ) {} + + async getStats(filters: AdminAuditStatsQueryDto): Promise { + const cacheKey = `${CACHE_KEY_PREFIX}${hashFilters(filters)}`; + const cached = await this.redis.get(cacheKey); + if (cached) { + return JSON.parse(cached) as AdminAuditStats; + } + + const stats = await this.computeStats(filters); + // Best-effort cache write. If Redis is briefly unavailable, we + // serve the live result and the next call rebuilds the cache. + // The DB read already happened — don't fail the response on a + // cache-write blip. + try { + await this.redis.set(cacheKey, JSON.stringify(stats), 'EX', STATS_CACHE_TTL_SECONDS); + } catch { + // swallow — see comment above + } + return stats; + } + + private async computeStats(filters: AdminAuditStatsQueryDto): Promise { + const { whereSql, params } = buildWhere(filters); + + return this.prisma.$transaction(async (tx) => { + await tx.$executeRawUnsafe(`SET LOCAL ROLE audit_reader`); + + // Three GROUP BY queries, each scoped by the same WHERE clause. + // `date_trunc('day', ...)::date` yields a Postgres date (no + // time, no zone) that serializes to `YYYY-MM-DD` directly — + // matches the SPA's chart bucket convention. + const dailyRows = await tx.$queryRawUnsafe>( + `SELECT date_trunc('day', created_at)::date AS day, + COUNT(*)::bigint AS count + FROM "audit"."events" + ${whereSql} + GROUP BY day + ORDER BY day`, + ...params, + ); + + const outcomeRows = await tx.$queryRawUnsafe>( + `SELECT outcome::text AS outcome, + COUNT(*)::bigint AS count + FROM "audit"."events" + ${whereSql} + GROUP BY outcome + ORDER BY outcome`, + ...params, + ); + + const eventTypeRows = await tx.$queryRawUnsafe< + Array<{ day: Date; event_type: string; count: bigint }> + >( + `SELECT date_trunc('day', created_at)::date AS day, + event_type, + COUNT(*)::bigint AS count + FROM "audit"."events" + ${whereSql} + GROUP BY day, event_type + ORDER BY day, event_type`, + ...params, + ); + + const dailyVolume = dailyRows.map((r) => ({ + day: toIsoDay(r.day), + count: Number(r.count), + })); + const total = dailyVolume.reduce((acc, r) => acc + r.count, 0); + + return { + dailyVolume, + outcomeBreakdown: outcomeRows.map((r) => ({ + outcome: r.outcome, + count: Number(r.count), + })), + eventTypeByDay: eventTypeRows.map((r) => ({ + day: toIsoDay(r.day), + eventType: r.event_type, + count: Number(r.count), + })), + total, + }; + }); + } +} + +/** + * Deterministic hash of the filter object. Sorted keys + JSON + * stringify so two requests with the same filters land on the same + * cache key regardless of ordering or whitespace. Truncated to + * 16 hex chars (64 bits) — collision probability negligible at + * realistic admin-side query volumes. + */ +function hashFilters(filters: AdminAuditStatsQueryDto): string { + const sortedKeys = Object.keys(filters).sort(); + const canonical = sortedKeys + .map((k) => `${k}=${(filters as Record)[k] ?? ''}`) + .join('&'); + return createHash('sha256').update(canonical).digest('hex').slice(0, 16); +} + +interface BuiltWhere { + readonly whereSql: string; + readonly params: unknown[]; +} + +/** + * Same shape as `AuditReader`'s `buildWhere` — kept in sync by + * convention. Diverges only in that the stats DTO doesn't carry + * `limit` / `offset`, so those clauses don't appear here. + */ +function buildWhere(filters: AdminAuditStatsQueryDto): BuiltWhere { + const clauses: string[] = []; + const params: unknown[] = []; + + if (filters.eventType !== undefined) { + params.push(filters.eventType); + clauses.push(`event_type = $${params.length}`); + } + if (filters.actorIdHash !== undefined) { + params.push(filters.actorIdHash); + clauses.push(`actor_id_hash = $${params.length}`); + } + if (filters.audience !== undefined) { + params.push(filters.audience); + clauses.push(`audience = $${params.length}::"audit"."AuditAudience"`); + } + if (filters.outcome !== undefined) { + params.push(filters.outcome); + clauses.push(`outcome = $${params.length}::"audit"."AuditOutcome"`); + } + if (filters.subjectPrefix !== undefined) { + params.push(`${escapeLikeLiteral(filters.subjectPrefix)}%`); + clauses.push(`subject LIKE $${params.length} ESCAPE '\\'`); + } + if (filters.createdAtFrom !== undefined) { + params.push(new Date(filters.createdAtFrom)); + clauses.push(`created_at >= $${params.length}`); + } + if (filters.createdAtTo !== undefined) { + params.push(new Date(filters.createdAtTo)); + clauses.push(`created_at < $${params.length}`); + } + + const whereSql = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''; + return { whereSql, params }; +} + +function escapeLikeLiteral(raw: string): string { + return raw.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_'); +} + +/** + * `date_trunc('day', ...)::date` round-trips through node-postgres + * as a JS Date at UTC midnight. Slice the ISO string back to + * `YYYY-MM-DD` so the SPA gets a stable bucket key — `Date#toJSON` + * would otherwise serialise the full ISO timestamp with the local + * timezone offset, which is not what the chart x-axis wants. + */ +function toIsoDay(d: Date): string { + return d.toISOString().slice(0, 10); +} diff --git a/apps/portal-bff/src/audit/audit.service.ts b/apps/portal-bff/src/audit/audit.service.ts index 22df74e..b8af3e3 100644 --- a/apps/portal-bff/src/audit/audit.service.ts +++ b/apps/portal-bff/src/audit/audit.service.ts @@ -5,6 +5,7 @@ import { PrismaService } from 'nestjs-prisma'; import type { AdminAccessDeniedInput, AdminAuditQueryInput, + AdminAuditStatsQueryInput, AdminUsersQueryInput, AuditEventInput, MfaRequiredInput, @@ -174,6 +175,30 @@ export class AuditWriter { }); } + /** + * 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 { + 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 diff --git a/apps/portal-bff/src/audit/audit.types.ts b/apps/portal-bff/src/audit/audit.types.ts index 818afe7..4107046 100644 --- a/apps/portal-bff/src/audit/audit.types.ts +++ b/apps/portal-bff/src/audit/audit.types.ts @@ -133,6 +133,20 @@ export interface AdminUsersQueryInput { resultCount: number; } +/** + * Audit aggregations read — `GET /api/admin/audit/stats`. Same + * deterrent posture as {@link AdminAuditQueryInput} (every admin + * read is auditable per ADR-0020) but the payload captures the + * `total` event count of the aggregated set instead of the + * paginated `resultCount` — there's no pagination on stats, the + * value carries more "size of the scan" signal. + */ +export interface AdminAuditStatsQueryInput { + actor: { oid: string }; + filters: Record; + total: number; +} + /** Reason `RequireMfaGuard` rejected a request — fed into the audit payload. */ export type MfaRequiredReason = 'no-mfa-in-amr' | 'no-mfa-verified-at' | 'mfa-stale'; diff --git a/docs/decisions/0013-audit-trail-separated-postgres-append-only.md b/docs/decisions/0013-audit-trail-separated-postgres-append-only.md index 2b0dcf2..b744d81 100644 --- a/docs/decisions/0013-audit-trail-separated-postgres-append-only.md +++ b/docs/decisions/0013-audit-trail-separated-postgres-append-only.md @@ -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 |