Files
study_work/packages/backend/src/audit/operation-log.interceptor.ts
T

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;
}
}