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 { CohortModule } from './modules/cohort/cohort.module';
|
||||||
import { TenantModule } from './modules/tenant/tenant.module';
|
import { TenantModule } from './modules/tenant/tenant.module';
|
||||||
import { TenantMiddleware } from './common/tenant/tenant.middleware';
|
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 { PolicyEngineModule } from './modules/policy-engine/policy-engine.module';
|
||||||
import { RecoveryIncidentModule } from './modules/recovery-incident/recovery-incident.module';
|
import { RecoveryIncidentModule } from './modules/recovery-incident/recovery-incident.module';
|
||||||
import { AuditLogModule } from './modules/audit-log/audit-log.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 {
|
export class AppModule implements NestModule {
|
||||||
configure(consumer: MiddlewareConsumer) {
|
configure(consumer: MiddlewareConsumer) {
|
||||||
|
consumer.apply(HttpLoggerMiddleware).forRoutes('*');
|
||||||
consumer.apply(TenantMiddleware).forRoutes('*');
|
consumer.apply(TenantMiddleware).forRoutes('*');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,11 +23,14 @@ const MODULE_MAP: Array<{ pattern: RegExp; module: string }> = [
|
|||||||
{ pattern: /master-data/, module: 'master-data' },
|
{ 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) {
|
for (const entry of MODULE_MAP) {
|
||||||
if (entry.pattern.test(url)) return entry.module;
|
if (entry.pattern.test(url)) return entry.module;
|
||||||
}
|
}
|
||||||
return 'unknown';
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveAction(method: string, url: string): string {
|
function resolveAction(method: string, url: string): string {
|
||||||
@@ -54,33 +57,62 @@ export class AuditInterceptor implements NestInterceptor {
|
|||||||
return next.handle();
|
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);
|
const module = resolveModule(url);
|
||||||
|
if (!module) {
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
|
||||||
const action = resolveAction(method, url);
|
const action = resolveAction(method, url);
|
||||||
const userAgent = headers['user-agent'] ?? '';
|
const userAgent = headers['user-agent'] ?? '';
|
||||||
const tenantId = (headers['x-tenant-id'] as string) ?? 'unknown';
|
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(
|
return next.handle().pipe(
|
||||||
tap({
|
tap({
|
||||||
next: async (responseData: any) => {
|
next: (responseData: unknown) => {
|
||||||
this.logger.debug(`Writing audit log for ${module} ${action}...`);
|
this.logger.debug(`Writing audit log for ${module} ${action}...`);
|
||||||
try {
|
const resObj =
|
||||||
await this.auditLogService.log({
|
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,
|
module,
|
||||||
action,
|
action,
|
||||||
entityId: responseData?.id ?? undefined,
|
entityId,
|
||||||
entityLabel: responseData?.name ?? responseData?.recoveryCode ?? responseData?.flightNumber ?? undefined,
|
entityLabel,
|
||||||
before: undefined, // no before on CREATE
|
before: undefined, // no before on CREATE
|
||||||
after: responseData ?? undefined,
|
after: resObj as Record<string, any> | undefined,
|
||||||
performedBy: tenantId,
|
performedBy: tenantId,
|
||||||
ipAddress: ip,
|
ipAddress: ip,
|
||||||
userAgent: String(userAgent),
|
userAgent: String(userAgent),
|
||||||
});
|
})
|
||||||
} catch (err) {
|
.catch((err: unknown) => {
|
||||||
// Never let audit failure break the main request
|
// 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