src/common/interceptors/audit-log.interceptor.ts
Methods |
constructor(prisma: PrismaService)
|
||||||
|
Parameters :
|
| intercept | |||||||||
intercept(context: ExecutionContext, next: CallHandler)
|
|||||||||
|
Parameters :
Returns :
Observable<unknown>
|
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class AuditLogInterceptor implements NestInterceptor {
private readonly logger = new Logger('AuditLog');
constructor(private readonly prisma: PrismaService) {
this.logger.log('AuditLogInterceptor initialized');
}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest();
if (!request) return next.handle();
const method = request.method;
// Only log mutating actions
if (!['POST', 'PATCH', 'PUT', 'DELETE'].includes(method)) {
return next.handle();
}
const user = request.user;
const url: string = request.url || '';
const body = request.body;
// Parse URL to determine entity and action
const cleanPath = url.replace(/^\/api\/v1\//, '').split('?')[0];
const pathParts = cleanPath.split('/').filter(Boolean);
const entityType = (pathParts[0] || 'SYSTEM').toUpperCase();
const entityId = pathParts[1] && pathParts[1].length > 10 ? pathParts[1] : null;
const subAction = pathParts.length > 2 ? pathParts[pathParts.length - 1] : null;
let action: string;
if (subAction && !entityId) {
action = subAction.toUpperCase(); // e.g., "upload", "auto-allocate"
} else if (subAction && entityId) {
action = subAction.toUpperCase(); // e.g., "approve", "dispatch"
} else {
const map: Record<string, string> = { POST: 'CREATE', PATCH: 'UPDATE', PUT: 'UPDATE', DELETE: 'DELETE' };
action = map[method] || method;
}
return next.handle().pipe(
tap({
next: (responseBody) => {
const orgId = user?.organizationId;
if (!orgId) return;
const sanitized = this.sanitize(body);
const resId = this.extractId(responseBody);
this.prisma.auditLog.create({
data: {
organizationId: orgId,
userId: user?.sub || user?.id || null,
action,
entityType,
entityId: entityId || resId || null,
newValues: sanitized as any,
ipAddress: request.ip || request.headers?.['x-forwarded-for'] || null,
userAgent: (request.headers?.['user-agent'] || '').substring(0, 255) || null,
},
}).catch(err => {
this.logger.warn(`Audit log failed: ${err.message}`);
});
// Set addedByName / modifiedByName on the record
const userName = [user?.firstName, user?.lastName].filter(Boolean).join(' ') || user?.email || 'System';
const recordId = entityId || resId;
if (recordId && userName) {
const table = entityType.toLowerCase();
const prismaModel = (this.prisma as any)[table === 'ingestions' ? 'ingestion' : table.endsWith('s') ? table.slice(0, -1) : table];
if (prismaModel?.update) {
const nameField = action === 'CREATE' ? 'addedByName' : 'modifiedByName';
prismaModel.update({
where: { id: recordId },
data: { [nameField]: userName, modifiedByName: userName },
}).catch(() => {}); // Silent — field may not exist on all models
}
}
},
error: (err) => {
const orgId = user?.organizationId;
if (!orgId) return;
this.prisma.auditLog.create({
data: {
organizationId: orgId,
userId: user?.sub || user?.id || null,
action: `${action}_FAILED`,
entityType,
entityId: entityId || null,
newValues: { error: err?.message, body: this.sanitize(body) } as any,
ipAddress: request.ip || request.headers?.['x-forwarded-for'] || null,
userAgent: (request.headers?.['user-agent'] || '').substring(0, 255) || null,
},
}).catch(() => {});
},
}),
);
}
private sanitize(body: unknown): unknown {
if (!body || typeof body !== 'object') return body;
const obj = { ...(body as Record<string, unknown>) };
delete obj.password;
delete obj.currentPassword;
delete obj.newPassword;
delete obj.token;
delete obj.refreshToken;
delete obj.fileBase64;
for (const [k, v] of Object.entries(obj)) {
if (typeof v === 'string' && v.length > 500) {
obj[k] = v.substring(0, 500) + '...[truncated]';
}
}
return obj;
}
private extractId(body: unknown): string | null {
if (!body || typeof body !== 'object') return null;
const obj = body as Record<string, unknown>;
return (obj.id as string) || ((obj.data as Record<string, unknown>)?.id as string) || null;
}
}