import { Inject, Injectable } from '@nestjs/common'; import type { Prisma } from '../generated/prisma/client'; import type { AuditResult } from '../generated/prisma/enums'; import type { AuthContext, RequestContext } from '../common/request-context'; import { PrismaService } from '../database/prisma.service'; import { SensitiveDataService } from '../security/sensitive-data.service'; const SENSITIVE_METADATA_KEY = /(password|token|secret|openid|unionid|mobile|phone|address|description|content)/i; export interface AuditEvent { action: string; result: AuditResult; actor?: AuthContext; actorUserId?: bigint; actorRole?: string; resourceType?: string; resourcePublicId?: string; metadata?: Record; } @Injectable() export class AuditService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(SensitiveDataService) private readonly sensitiveData: SensitiveDataService, ) {} async record( request: RequestContext, event: AuditEvent, client: Prisma.TransactionClient | PrismaService = this.prisma, ): Promise { const metadata = this.safeMetadata(event.metadata); const userAgent = request .header('user-agent') ?.replace(/[\r\n\t]/g, ' ') .slice(0, 255) || null; await client.auditLog.create({ data: { requestId: request.requestId, actorUserId: event.actor?.userId ?? event.actorUserId ?? null, actorRole: event.actor?.roles[0] ?? event.actorRole ?? null, action: event.action, resourceType: event.resourceType ?? null, resourcePublicId: event.resourcePublicId ?? null, result: event.result, metadataJson: metadata ?? undefined, ipHash: request.ip ? this.sensitiveData.auditIpHash(request.ip) : null, userAgentSummary: userAgent, }, }); } safeMetadata(metadata: AuditEvent['metadata']): Prisma.InputJsonObject | undefined { if (!metadata) return undefined; const entries = Object.entries(metadata).map(([key, value]) => { if (SENSITIVE_METADATA_KEY.test(key)) { throw new Error(`Sensitive audit metadata key is not allowed: ${key}`); } return [key, value] as const; }); return Object.fromEntries(entries); } }