import { db } from '@/lib/db'; import { auditLogs } from '@/lib/db/schema'; import { logger } from '@/lib/logger'; export type AuditAction = | 'create' | 'update' | 'delete' | 'archive' | 'restore' | 'merge' | 'login' | 'logout' | 'permission_denied' | 'revert' | 'revoke_invite' | 'resend_invite'; export interface AuditLogParams { /** Null for system-generated events. */ userId: string | null; /** Null for system-level events not tied to a port. */ portId: string | null; action: AuditAction; entityType: string; entityId: string; fieldChanged?: string; oldValue?: Record; newValue?: Record; metadata?: Record; ipAddress: string; userAgent: string; } const SENSITIVE_FIELDS = new Set(['email', 'phone', 'password', 'credentials_enc', 'token']); /** * Masks sensitive field values to prevent PII or secrets from being stored * verbatim in the audit log (SECURITY-GUIDELINES.md §5.2). * * Strings are replaced with a partial mask — first 2 chars + *** + last 2 chars. */ export function maskSensitiveFields( data?: Record, ): Record | undefined { if (!data) return undefined; const masked = { ...data }; for (const key of Object.keys(masked)) { if (SENSITIVE_FIELDS.has(key) && typeof masked[key] === 'string') { const val = masked[key] as string; masked[key] = val.length > 4 ? `${val.slice(0, 2)}***${val.slice(-2)}` : '***'; } } return masked; } /** * Computes a field-level diff between two records. * Returns one entry per changed field with the old and new values. */ export function diffFields( oldRecord: Record, newRecord: Record, ): Array<{ field: string; oldValue: unknown; newValue: unknown }> { const changes: Array<{ field: string; oldValue: unknown; newValue: unknown }> = []; for (const key of Object.keys(newRecord)) { if (JSON.stringify(oldRecord[key]) !== JSON.stringify(newRecord[key])) { changes.push({ field: key, oldValue: oldRecord[key], newValue: newRecord[key] }); } } return changes; } /** * Inserts an audit log entry into the database. * * This function NEVER throws — errors are caught and logged so that an audit * failure never rolls back or disrupts the parent operation. */ export async function createAuditLog(params: AuditLogParams): Promise { try { await db.insert(auditLogs).values({ portId: params.portId, userId: params.userId, action: params.action, entityType: params.entityType, entityId: params.entityId, fieldChanged: params.fieldChanged ?? null, oldValue: maskSensitiveFields(params.oldValue) ?? null, newValue: maskSensitiveFields(params.newValue) ?? null, metadata: params.metadata ?? null, ipAddress: params.ipAddress, userAgent: params.userAgent, }); } catch (err) { // Strip old/new values from the log to avoid secondary exposure of the data // that just failed to persist. logger.error( { err, audit: { userId: params.userId, portId: params.portId, action: params.action, entityType: params.entityType, entityId: params.entityId, }, }, 'Failed to write audit log', ); } }