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