134 lines
4.1 KiB
TypeScript
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 };
|
|
}
|
|
}
|