import { CallHandler, ExecutionContext, HttpException, Injectable, NestInterceptor, } from '@nestjs/common'; import { Observable, tap } from 'rxjs'; import type { Request, Response } from 'express'; import { AuditService } from './audit.service'; import { auditEndpointMatcher, type MatchedAuditEndpoint, } from './audit-endpoint-matcher'; import { isAuditableActor, resolveAuditActor, type AuditActorSource, } from './audit-actor'; import { redactUrlQuery, sanitizeRequestPayload } from './audit.sanitizer'; /** Methods that can change state. Everything else is never audited. */ const AUDITED_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); /** `error_message` ceiling — stack traces do not belong in this column. */ const MAX_ERROR_LENGTH = 2_000; type RequestWithUser = Request & { user?: AuditActorSource; files?: unknown; file?: unknown; id?: string; }; /** * Writes one `audit_logs` row per state-changing backoffice request. * * An interceptor rather than the two middlewares originally sketched, for one * decisive reason: Express middleware runs BEFORE guards, so `req.user` is not * populated yet. Both the backoffice-only rule and `user_id` would be * unavailable there. Interceptors run after guards and wrap the handler's * result, so a single class covers both halves — request context on the way in, * outcome on the way out — sharing one timer for `duration_ms`. * * Registered globally (see `audit.module.ts`), so new routes are covered * automatically as long as they appear in `AUDIT_ENDPOINTS`. */ @Injectable() export class AuditInterceptor implements NestInterceptor { constructor(private readonly auditService: AuditService) {} intercept(context: ExecutionContext, next: CallHandler): Observable { // Non-HTTP contexts (the RabbitMQ microservice transport) have no request. if (context.getType() !== 'http') return next.handle(); const httpContext = context.switchToHttp(); const request = httpContext.getRequest(); if (!AUDITED_METHODS.has(request.method)) return next.handle(); // Backoffice only. Customers and unauthenticated callers are skipped // outright — decided in `audit-actor.ts`, which reuses the same // `userType` discriminator as the permission guards. if (!isAuditableActor(request.user)) return next.handle(); const matched = auditEndpointMatcher.match(request.method, request.originalUrl); // Not in AUDIT_ENDPOINTS means the route is not a known auditable action; // recording it would produce rows with no title or entity. if (!matched) return next.handle(); const startedAt = Date.now(); // The body is captured up front: handlers are free to mutate the DTO they // are given, so reading it after the fact can record post-mutation values. const requestPayload = sanitizeRequestPayload( request.body, request.files ?? request.file, ); return next.handle().pipe( tap({ next: () => { const response = httpContext.getResponse(); void this.write(request, matched, requestPayload, startedAt, { isSuccess: true, // Nest has not applied the handler's @HttpCode yet at this point // for some routes; statusCode on the response object is the value // actually being sent. statusCode: response.statusCode, errorMessage: null, }); }, error: (error: unknown) => { void this.write(request, matched, requestPayload, startedAt, { isSuccess: false, statusCode: resolveErrorStatus(error), errorMessage: resolveErrorMessage(error), }); }, }), ); } /** * Build and persist the row. * * Deliberately not awaited by `intercept`: the audit write must not add * latency to the request, and `AuditService.record` already swallows its own * failures so a rejected promise cannot surface as an unhandled rejection. */ private async write( request: RequestWithUser, matched: MatchedAuditEndpoint, requestPayload: Record | null, startedAt: number, outcome: { isSuccess: boolean; statusCode: number | null; errorMessage: string | null; }, ): Promise { const actor = resolveAuditActor(request.user as AuditActorSource); await this.auditService.record({ title: matched.title, method: request.method, // Full URL including query string, with sensitive query values redacted. url: redactUrlQuery(request.originalUrl), routePath: matched.routePath, type: matched.type, isSuccess: outcome.isSuccess, statusCode: outcome.statusCode, errorMessage: outcome.errorMessage, userId: actor.userId, userName: actor.userName, userRole: actor.userRole, resourceId: matched.resourceId, request: requestPayload, ipAddress: resolveIp(request), userAgent: request.headers['user-agent'] ?? null, requestId: resolveRequestId(request), durationMs: Date.now() - startedAt, }); } } /** HTTP status for the failure, falling back to 500 for non-HTTP errors. */ function resolveErrorStatus(error: unknown): number { return error instanceof HttpException ? error.getStatus() : 500; } /** Message only — stack traces belong in application logs, not this column. */ function resolveErrorMessage(error: unknown): string | null { if (error instanceof HttpException) { const response = error.getResponse(); const message = typeof response === 'string' ? response : ((response as { message?: unknown })?.message ?? error.message); const text = Array.isArray(message) ? message.join('; ') : String(message); return text.slice(0, MAX_ERROR_LENGTH); } if (error instanceof Error) return error.message.slice(0, MAX_ERROR_LENGTH); return error ? String(error).slice(0, MAX_ERROR_LENGTH) : null; } /** * Client IP. The API sits behind a reverse proxy, so `req.ip` is the proxy * unless `trust proxy` is set; the forwarded header is preferred and its first * entry (the original client) taken. */ function resolveIp(request: Request): string | null { const forwarded = request.headers['x-forwarded-for']; const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded; const candidate = raw?.split(',')[0]?.trim() || request.ip; if (!candidate) return null; // Normalize IPv4-mapped IPv6 (`::ffff:10.0.0.1`), which the `inet` column // accepts but which reads badly and breaks grouping by address. return candidate.startsWith('::ffff:') ? candidate.slice(7) : candidate; } /** Correlation id from the proxy/tracing layer, when present. */ function resolveRequestId(request: RequestWithUser): string | null { const header = request.headers['x-request-id'] ?? request.headers['x-correlation-id']; const value = Array.isArray(header) ? header[0] : header; return (value ?? request.id ?? null)?.toString().slice(0, 64) ?? null; }