feat(portal-bff): audit-stats endpoint — server-side aggregations with redis cache (#173)
CI / check (push) Successful in 3m26s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m47s
CI / a11y (push) Successful in 4m1s
CI / perf (push) Successful in 7m30s
Docs site / build (push) Successful in 6m2s

## 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.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #173
This commit was merged in pull request #173.
This commit is contained in:
2026-05-16 23:11:52 +02:00
parent 209f44d667
commit 2cdeb74341
9 changed files with 638 additions and 3 deletions
@@ -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');
});
});
@@ -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<AdminAuditStats> {
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;
}
}
+4 -2
View File
@@ -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 {}
@@ -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;
}
@@ -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<unknown>) => 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<AuditStatsReader> {
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);
});
});
@@ -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<AdminAuditStats> {
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<AdminAuditStats> {
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<Array<{ day: Date; count: bigint }>>(
`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<Array<{ outcome: string; count: bigint }>>(
`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<string, unknown>)[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);
}
@@ -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<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
+14
View File
@@ -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<string, unknown>;
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';