import { Injectable } from '@nestjs/common'; import { PrismaService } from 'nestjs-prisma'; import { DEFAULT_LIMIT, MAX_LIMIT, type AdminAuditQueryDto } from './audit-query.dto'; /** * A single audit row projected for SPA consumption. Naming matches * the `AuditEvent` Prisma model but exposes ISO-string timestamps * (`created_at` → ISO 8601) and `Record` payloads — * the SPA shouldn't have to know about Prisma `Json` runtime * shapes. */ export interface AdminAuditEventDto { readonly id: string; readonly createdAt: string; readonly eventType: string; readonly audience: string; readonly actorIdHash: string | null; readonly traceId: string | null; readonly subject: string | null; readonly outcome: string; readonly payload: Record | null; } export interface AdminAuditPage { readonly items: readonly AdminAuditEventDto[]; /** * Total rows matching the filter, ignoring `limit` / `offset`. * COUNT(*) on the filtered set — fine at v1 audit volume; if the * table grows past a few million rows we'll switch to keyset * pagination and drop the total. */ readonly total: number; readonly limit: number; readonly offset: number; } /** * Raw shape returned by the underlying `$queryRawUnsafe`. Postgres * returns timestamps as `Date` and json columns as already-parsed * objects; the projection in {@link toDto} normalises both into * SPA-friendly types. */ interface RawAuditRow { id: string; created_at: Date; event_type: string; audience: string; actor_id_hash: string | null; trace_id: string | null; subject: string | null; outcome: string; payload: Record | null; } /** * `AuditReader` — single entry point for ADR-0013 audit-log reads. * * Contract * -------- * - **Always runs under `audit_reader`.** Every query is wrapped in * a transaction whose first statement is `SET LOCAL ROLE * audit_reader`. That role has SELECT-only on `audit.events` * (per the migration that created the schema); UPDATE / INSERT / * DELETE all fail at the Postgres level even when the BFF's * connection is otherwise privileged. The role resets at * transaction end. Mirrors the `AuditWriter`'s role-locking * posture for symmetry. * * - **Parameterised SQL only.** Filter values flow into the * `$queryRawUnsafe` parameter array, never concatenated into the * SQL string — defends against admin-side SQL injection through * the audit-viewer filter form. * * - **No PII redaction here.** The payload JSON is returned as-is * to admin callers. Per ADR-0013, redaction is the writer's * responsibility; what landed in the row is what comes out. The * admin role is the trust boundary. */ @Injectable() export class AuditReader { constructor(private readonly prisma: PrismaService) {} async findEvents(filters: AdminAuditQueryDto): Promise { const limit = filters.limit ?? DEFAULT_LIMIT; const offset = filters.offset ?? 0; // Hard cap at the DTO ceiling even if a future caller bypasses // the ValidationPipe — defense in depth. const safeLimit = Math.min(limit, MAX_LIMIT); const { whereSql, params } = buildWhere(filters); return this.prisma.$transaction(async (tx) => { await tx.$executeRawUnsafe(`SET LOCAL ROLE audit_reader`); const totalRows = await tx.$queryRawUnsafe>( `SELECT COUNT(*)::bigint AS count FROM "audit"."events" ${whereSql}`, ...params, ); const total = Number(totalRows[0]?.count ?? 0n); // Ordering is `created_at DESC, id DESC` so the SPA gets the // newest events first (typical audit-viewer expectation) and // ties on identical timestamps break deterministically on the // uuid — preventing pagination drift if two events share a // microsecond. // // Postgres parameter numbering continues from the WHERE clause // (`params.length`), so LIMIT lands at `$N+1` and OFFSET at // `$N+2`. const items = await tx.$queryRawUnsafe( `SELECT id, created_at, event_type, audience, actor_id_hash, trace_id, subject, outcome, payload FROM "audit"."events" ${whereSql} ORDER BY created_at DESC, id DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`, ...params, safeLimit, offset, ); return { items: items.map(toDto), total, limit: safeLimit, offset, }; }); } } /** * Build the parameterised WHERE clause + the matching positional * parameter array. Every filter is optional; missing filters drop * out of the SQL so the planner can pick the cheapest index. */ function buildWhere(filters: AdminAuditQueryDto): { whereSql: string; params: unknown[] } { 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) { // `LIKE` with a literal escape so a `%` in the prefix doesn't // act as a wildcard. The prefix bound is concatenated *to a // parameter*, never to the SQL string — the parameter is the // already-escaped literal followed by `%`. 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 { // `\` first, then the two LIKE meta-characters. Order matters — // if `%`/`_` were escaped first their inserted `\` would be // double-escaped on the second pass. return raw.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_'); } function toDto(row: RawAuditRow): AdminAuditEventDto { return { id: row.id, createdAt: row.created_at.toISOString(), eventType: row.event_type, audience: row.audience, actorIdHash: row.actor_id_hash, traceId: row.trace_id, subject: row.subject, outcome: row.outcome, payload: row.payload, }; }