feat: implement global HTTP request logging and audit interception for entity creation
This commit is contained in:
@@ -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('*');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>)
|
||||
: 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<string, any> | undefined,
|
||||
performedBy: tenantId,
|
||||
ipAddress: ip,
|
||||
userAgent: String(userAgent),
|
||||
});
|
||||
} catch (err) {
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
// Never let audit failure break the main request
|
||||
this.logger.warn(`Audit log failed for ${method} ${url}: ${err}`);
|
||||
}
|
||||
this.logger.warn(
|
||||
`Audit log failed for ${method} ${url}: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user