import { Controller, Get, Query, Req } from '@nestjs/common'; import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; 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 { RequireAdmin } from './require-admin.decorator'; /** * `GET /api/admin/audit` — paginated audit-log viewer per * ADR-0020's v1 admin module catalogue. Reads `audit.events` via * the `audit_reader` Postgres role (locked inside the `AuditReader` * service), shapes the result for SPA consumption, and emits an * `admin.audit.query` row so the read itself is auditable per * ADR-0020 §"Read actions are also captured … to deter fishing * expeditions". * * The query DTO (`AdminAuditQueryDto`) carries every supported * filter + pagination knob; Nest's global ValidationPipe rejects * unknown keys before they reach this handler. * * `@RequireAdmin()` at the class level gates the route on the * `admin` Entra app role per ADR-0020 §"Auth — same Entra ID". * `@RequireMfa()` is not applied here in v1: the entire admin * surface already sits behind a freshly-MFA'd session at the * sign-in entry, and the audit row itself is the per-query * deterrent. A future security review can layer `@RequireMfa` * with a tighter freshness here without touching this code's * shape — that's exactly why the decorator was designed-in. */ @ApiTags('admin (audit log)') @ApiCookieAuth('portal_admin_session') @Controller('admin/audit') @RequireAdmin() export class AdminAuditController { constructor( private readonly auditReader: AuditReader, private readonly audit: AuditWriter, ) {} @ApiOperation({ summary: 'Paginated audit-log query — emits `admin.audit.query` on every call', }) @Get() async list(@Req() req: Request, @Query() filters: AdminAuditQueryDto): Promise { const page = await this.auditReader.findEvents(filters); // Guard guarantees `req.session.user`; we still defensively // narrow rather than assert — the audit emission is what // makes this read auditable, so if we somehow get here without // an actor we should still surface the read (with no actor // hash) rather than silently swallow it. const actorOid = req.session.user?.oid; if (actorOid !== undefined) { await this.audit.adminAuditQuery({ actor: { oid: actorOid }, filters: { ...filters }, resultCount: page.items.length, }); } return page; } }