diff --git a/src/app.module.ts b/src/app.module.ts index 001e080..c90f0ba 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,6 +9,7 @@ import { MasterDataModule } from './modules/master-data/master-data.module'; import { CohortModule } from './modules/cohort/cohort.module'; import { TenantModule } from './modules/tenant/tenant.module'; import { TenantMiddleware } from './common/tenant/tenant.middleware'; +import { HttpLoggerMiddleware } from './common/middleware/http-logger.middleware'; import { PolicyEngineModule } from './modules/policy-engine/policy-engine.module'; import { RecoveryIncidentModule } from './modules/recovery-incident/recovery-incident.module'; import { AuditLogModule } from './modules/audit-log/audit-log.module'; @@ -48,6 +49,7 @@ const envFilePath = env ? [`.env.${env}`, '.env.local', '.env'] : ['.env.local', }) export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { + consumer.apply(HttpLoggerMiddleware).forRoutes('*'); consumer.apply(TenantMiddleware).forRoutes('*'); } } diff --git a/src/common/interceptors/audit.interceptor.ts b/src/common/interceptors/audit.interceptor.ts index cb95d4e..a5a4e73 100644 --- a/src/common/interceptors/audit.interceptor.ts +++ b/src/common/interceptors/audit.interceptor.ts @@ -23,11 +23,14 @@ const MODULE_MAP: Array<{ pattern: RegExp; module: string }> = [ { pattern: /master-data/, module: 'master-data' }, ]; -function resolveModule(url: string): string { +// Routes that should never trigger an entity creation audit log +const EXCLUDED_PATTERNS: RegExp[] = [/\/auth\b/, /\/evaluate\b/]; + +function resolveModule(url: string): string | null { for (const entry of MODULE_MAP) { if (entry.pattern.test(url)) return entry.module; } - return 'unknown'; + return null; } function resolveAction(method: string, url: string): string { @@ -54,33 +57,62 @@ export class AuditInterceptor implements NestInterceptor { return next.handle(); } + // Never audit authentication endpoints or non-persisting evaluation endpoints + if (EXCLUDED_PATTERNS.some((pattern) => pattern.test(url))) { + return next.handle(); + } + const module = resolveModule(url); + if (!module) { + return next.handle(); + } + const action = resolveAction(method, url); const userAgent = headers['user-agent'] ?? ''; const tenantId = (headers['x-tenant-id'] as string) ?? 'unknown'; - this.logger.debug(`Intercepted ${method} ${url} -> Module: ${module}, Action: ${action}`); + this.logger.debug( + `Intercepted ${method} ${url} -> Module: ${module}, Action: ${action}`, + ); return next.handle().pipe( tap({ - next: async (responseData: any) => { + next: (responseData: unknown) => { this.logger.debug(`Writing audit log for ${module} ${action}...`); - try { - await this.auditLogService.log({ + const resObj = + typeof responseData === 'object' && responseData !== null + ? (responseData as Record) + : undefined; + + const entityId = + typeof resObj?.id === 'string' ? resObj.id : undefined; + const entityLabel = + typeof resObj?.name === 'string' + ? resObj.name + : typeof resObj?.recoveryCode === 'string' + ? resObj.recoveryCode + : typeof resObj?.flightNumber === 'string' + ? resObj.flightNumber + : undefined; + + void this.auditLogService + .log({ module, action, - entityId: responseData?.id ?? undefined, - entityLabel: responseData?.name ?? responseData?.recoveryCode ?? responseData?.flightNumber ?? undefined, + entityId, + entityLabel, before: undefined, // no before on CREATE - after: responseData ?? undefined, + after: resObj as Record | undefined, performedBy: tenantId, ipAddress: ip, userAgent: String(userAgent), + }) + .catch((err: unknown) => { + // Never let audit failure break the main request + this.logger.warn( + `Audit log failed for ${method} ${url}: ${String(err)}`, + ); }); - } catch (err) { - // Never let audit failure break the main request - this.logger.warn(`Audit log failed for ${method} ${url}: ${err}`); - } }, }), ); diff --git a/src/common/middleware/http-logger.middleware.ts b/src/common/middleware/http-logger.middleware.ts new file mode 100644 index 0000000..268dc84 --- /dev/null +++ b/src/common/middleware/http-logger.middleware.ts @@ -0,0 +1,42 @@ +import { Injectable, Logger, NestMiddleware } from '@nestjs/common'; +import { NextFunction, Request, Response } from 'express'; + +@Injectable() +export class HttpLoggerMiddleware implements NestMiddleware { + private readonly logger = new Logger('HTTP'); + + use(req: Request, res: Response, next: NextFunction): void { + const { method, originalUrl, ip } = req; + const startTime = Date.now(); + + res.on('finish', () => { + const { statusCode } = res; + const duration = Date.now() - startTime; + const rawTenant = req.headers['x-tenant-id']; + const tenantHeader = Array.isArray(rawTenant) ? rawTenant[0] : rawTenant; + const tenantInfo = tenantHeader ? ` [tenant: ${tenantHeader}]` : ''; + const clientIp = typeof ip === 'string' ? ip : 'unknown'; + const message = `${method} ${originalUrl} ${statusCode} +${duration}ms - ${clientIp}${tenantInfo}`; + + if (statusCode >= 500) { + this.logger.error(message); + } else if (statusCode >= 400) { + this.logger.warn(message); + } else { + this.logger.log(message); + } + }); + + res.on('close', () => { + if (!res.writableEnded) { + const duration = Date.now() - startTime; + const clientIp = typeof ip === 'string' ? ip : 'unknown'; + this.logger.warn( + `${method} ${originalUrl} CLOSED_PREMATURELY +${duration}ms - ${clientIp}`, + ); + } + }); + + next(); + } +}