import { Injectable, Logger } from "@nestjs/common"; import { createHmac } from "crypto"; import { PrismaService } from "../prisma/prisma.service"; export interface AuditLogEntry { tenantId?: string; userId?: string; action: string; resource?: string; resourceId?: string; metadata?: Record; ipAddress?: string; userAgent?: string; } /** * AuditLogService — M-5 * Adds HMAC-SHA256 integrity hash to every log entry so tampering can be detected. * Hash is stored in metadata.integrity. * Secret: env var AUDIT_HMAC_SECRET (falls back to a default for dev). */ @Injectable() export class AuditLogService { private readonly logger = new Logger(AuditLogService.name); private readonly hmacSecret = process.env.AUDIT_HMAC_SECRET ?? "moraworld-audit-hmac-secret-change-in-prod"; constructor(private prisma: PrismaService) {} /** * Compute HMAC-SHA256 of the canonical log payload. * Canonical form: JSON.stringify of { tenantId, userId, action, resource, resourceId, createdAt } */ private computeIntegrity(entry: AuditLogEntry, createdAt: Date): string { const canonical = JSON.stringify({ tenantId: entry.tenantId ?? null, userId: entry.userId ?? null, action: entry.action, resource: entry.resource ?? null, resourceId: entry.resourceId ?? null, createdAt: createdAt.toISOString(), }); return createHmac("sha256", this.hmacSecret).update(canonical).digest("hex"); } async log(entry: AuditLogEntry): Promise { try { const createdAt = new Date(); const integrity = this.computeIntegrity(entry, createdAt); await this.prisma.client.auditLog.create({ data: { ...entry, metadata: { ...(entry.metadata ?? {}), integrity, // HMAC-SHA256 of canonical payload }, createdAt, }, }); } catch (e: unknown) { // Never let audit log failure break main flow this.logger.error("AuditLog write failed", (e as Error).message); } } /** * Verify the integrity hash of a stored audit log entry. * Returns true if the hash matches, false if the record was tampered with. */ verify(entry: { tenantId?: string | null; userId?: string | null; action: string; resource?: string | null; resourceId?: string | null; createdAt: Date; metadata?: any; }): boolean { const stored = entry.metadata?.integrity; if (!stored) return false; const canonical = JSON.stringify({ tenantId: entry.tenantId ?? null, userId: entry.userId ?? null, action: entry.action, resource: entry.resource ?? null, resourceId: entry.resourceId ?? null, createdAt: entry.createdAt instanceof Date ? entry.createdAt.toISOString() : new Date(entry.createdAt).toISOString(), }); const expected = createHmac("sha256", this.hmacSecret).update(canonical).digest("hex"); return expected === stored; } async findAll(filters: { tenantId?: string; userId?: string; action?: string; resource?: string; from?: string; to?: string; page?: number; limit?: number; }): Promise<{ data: any[]; total: number }> { const page = filters.page ?? 1; const limit = filters.limit ?? 50; const skip = (page - 1) * limit; const where: any = {}; if (filters.tenantId) where.tenantId = filters.tenantId; if (filters.userId) where.userId = filters.userId; if (filters.action) where.action = { contains: filters.action, mode: "insensitive" }; if (filters.resource) where.resource = { contains: filters.resource, mode: "insensitive" }; if (filters.from || filters.to) { where.createdAt = {}; if (filters.from) where.createdAt.gte = new Date(filters.from); if (filters.to) where.createdAt.lte = new Date(filters.to); } const [data, total] = await Promise.all([ this.prisma.client.auditLog.findMany({ where, orderBy: { createdAt: "desc" }, skip, take: limit, }), this.prisma.client.auditLog.count({ where }), ]); return { data, total }; } }