feat(portal-bff): audit-stats endpoint — server-side aggregations with redis cache (#173)
## 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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user