Files
moraworld/apps/api/src/audit-log/audit-log.service.ts
T

134 lines
4.1 KiB
TypeScript

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<string, any>;
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<void> {
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 };
}
}