import { CallHandler, ExecutionContext, Injectable, NestInterceptor, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import type { Request } from 'express'; import { catchError, from, mergeMap, Observable, throwError } from 'rxjs'; import { AuditService } from './audit.service'; import { OPERATION_LOG_METADATA, type OperationLogOptions, } from './operation-log.decorator'; const sensitiveKeys = [ 'password', 'oldPassword', 'newPassword', 'passwordHash', 'token', 'key', 'secret', 'studentPassword', ]; type RequestWithUser = Request & { user?: { id?: string; name?: string; username?: string; }; }; @Injectable() export class OperationLogInterceptor implements NestInterceptor { constructor( private readonly auditService: AuditService, private readonly reflector: Reflector, ) {} intercept(context: ExecutionContext, next: CallHandler): Observable { const options = this.reflector.getAllAndOverride( OPERATION_LOG_METADATA, [context.getHandler(), context.getClass()], ); if (!options) { return next.handle(); } const startedAt = Date.now(); const request = context.switchToHttp().getRequest(); return next.handle().pipe( mergeMap((result) => from( this.writeLog({ options, request, result, status: 'success', durationMs: Date.now() - startedAt, }), ).pipe(mergeMap(() => [result])), ), catchError((error: unknown) => from( this.writeLog({ options, request, status: 'failed', durationMs: Date.now() - startedAt, error, }), ).pipe(mergeMap(() => throwError(() => error))), ), ); } private async writeLog(input: { options: OperationLogOptions; request: RequestWithUser; result?: unknown; status: 'success' | 'failed'; durationMs: number; error?: unknown; }) { const { options, request, result, status, durationMs, error } = input; const actor = request.user; await this.auditService.write({ actorId: actor?.id ?? null, actorUsername: actor?.name || actor?.username || null, action: options.action, resourceType: options.resourceType ?? null, resourceId: this.getResourceId(options, request, result), metadata: { status, description: options.description, durationMs, request: this.pickRequestMetadata(request, options), error: error ? this.toErrorMetadata(error) : undefined, }, ipAddress: this.getIp(request), }); } private getResourceId( options: OperationLogOptions, request: RequestWithUser, result?: unknown, ) { if (options.resourceIdParam) { return String(request.params?.[options.resourceIdParam] ?? '') || null; } if (options.resourceIdFromResult) { const value = this.readPath(result, options.resourceIdFromResult); return value == null ? null : String(value); } return null; } private pickRequestMetadata( request: RequestWithUser, options: OperationLogOptions, ) { return { params: this.maskValue(request.params || {}), query: this.pickAndMask(request.query, options.metadataFromQuery), body: this.pickAndMask(request.body, options.metadataFromBody), userAgent: request.headers['user-agent'] ?? null, }; } private pickAndMask(value: unknown, keys?: string[]) { if (!value || typeof value !== 'object') { return null; } const source = value as Record; if (!keys?.length) { return this.maskValue(source); } const picked: Record = {}; for (const key of keys) { if (key in source) { picked[key] = source[key]; } } return this.maskValue(picked); } private maskValue(value: unknown): unknown { if (Array.isArray(value)) { return value.map((item) => this.maskValue(item)); } if (!value || typeof value !== 'object') { return value; } const masked: Record = {}; for (const [key, item] of Object.entries(value as Record)) { if (this.isSensitiveKey(key)) { masked[key] = '******'; } else { masked[key] = this.maskValue(item); } } return masked; } private isSensitiveKey(key: string) { const normalized = key.toLowerCase(); return sensitiveKeys.some((item) => normalized.includes(item.toLowerCase())); } private toErrorMetadata(error: unknown) { if (error instanceof Error) { return { name: error.name, message: error.message, }; } return { message: String(error) }; } private readPath(value: unknown, path: string) { return path.split('.').reduce((current, key) => { if (!current || typeof current !== 'object') { return undefined; } return (current as Record)[key]; }, value); } private getIp(request: Request) { const forwardedFor = request.headers['x-forwarded-for']; if (typeof forwardedFor === 'string') { return forwardedFor.split(',')[0]?.trim() || null; } return request.ip || request.socket.remoteAddress || null; } }