214 lines
5.3 KiB
TypeScript
214 lines
5.3 KiB
TypeScript
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<unknown> {
|
|
const options = this.reflector.getAllAndOverride<OperationLogOptions>(
|
|
OPERATION_LOG_METADATA,
|
|
[context.getHandler(), context.getClass()],
|
|
);
|
|
|
|
if (!options) {
|
|
return next.handle();
|
|
}
|
|
|
|
const startedAt = Date.now();
|
|
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
|
|
|
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<string, unknown>;
|
|
if (!keys?.length) {
|
|
return this.maskValue(source);
|
|
}
|
|
|
|
const picked: Record<string, unknown> = {};
|
|
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<string, unknown> = {};
|
|
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
|
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<unknown>((current, key) => {
|
|
if (!current || typeof current !== 'object') {
|
|
return undefined;
|
|
}
|
|
|
|
return (current as Record<string, unknown>)[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;
|
|
}
|
|
}
|