import { db } from '@/lib/db'; import { auditLogs } from '@/lib/db/schema'; import { logger } from '@/lib/logger'; /** * Widen a Drizzle row (or any object) to the shape audit_logs.oldValue / * newValue expects. Centralizes the structurally-safe `Record` cast 20+ services were doing inline via * `as unknown as Record`. Mirrors gdpr-bundle-builder's * `toJsonRow` helper (same audit-found motivation). */ export function toAuditJson(row: T): Record { return row as unknown as Record; } export type AuditAction = | 'create' | 'update' | 'delete' | 'archive' | 'restore' | 'merge' | 'login' | 'logout' | 'permission_denied' | 'revert' | 'revoke_invite' | 'resend_invite' | 'request_gdpr_export' | 'send_gdpr_export' | 'password_change' | 'portal_invite' | 'portal_activate' | 'portal_password_reset_request' | 'portal_password_reset' | 'send' | 'view' | 'request_hard_delete_code' | 'hard_delete' // Branding (port logo upload pipeline). | 'branding.logo.uploaded' | 'branding.logo.archived' // System / background events. | 'webhook_delivered' | 'webhook_failed' | 'webhook_dead_letter' | 'webhook_retried' | 'job_failed' | 'cron_run' // Berth-rule decision trace: emitted by the rules engine on every // evaluateRule() call so admins can debug "why did this fire / not fire" // without reading server logs. Distinct from the actual `update` audit // row the auto-applied path emits when it mutates berth status. | 'rule_evaluated' // M-AU04: distinct verbs for outcome-set / outcome-cleared. The pre-fix // path used a generic `update` row with `metadata.type = 'outcome_set'`, // which the audit filter dropdown couldn't surface as its own bucket // and the FTS GENERATED index missed entirely. | 'outcome_set' | 'outcome_cleared' // Phase 3 - EOI override / contact promote / yacht spawn from EOI. // The DB column is free-text per migration 0073; these strings just // formalise the catalogue so the audit-log filter dropdown can surface // them as their own buckets. | 'eoi_field_override' | 'promote_to_primary' | 'eoi_spawn_yacht'; /** * Common shape passed to service functions so they can stamp audit logs and * propagate request context. Every authenticated route resolves these from * the session + headers; services accept them rather than reaching into * Next.js APIs themselves. */ export interface AuditMeta { userId: string; portId: string; ipAddress: string; userAgent: string; } export type AuditSeverity = 'info' | 'warning' | 'error' | 'critical'; export type AuditSource = 'user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job'; 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; /** Optional. Services that don't have request context (e.g. background * jobs, internal helpers) may omit. */ ipAddress?: string; userAgent?: string; /** Defaults to 'info'. Bump to 'warning' for permission_denied, * 'error' for failed background jobs / webhook DLQ, 'critical' for * hard-deletes / security-relevant events. */ severity?: AuditSeverity; /** Defaults to 'user'. Use 'auth' for session lifecycle, * 'webhook' for delivery events, 'job' / 'cron' / 'system' for * background work. The inspector filters on this column. */ source?: AuditSource; } // Lower-cased key fragments. A metadata key is masked if any fragment is // contained as a substring after camelCase→snake + lowercase + kebab→snake // normalization. Substring match catches `recipientEmail`, `sent_to_email`, // `userEmail`, `from_address`, `phone_number`, `passwordHash`, `firstName`, // `postalCode`, `dateOfBirth`, etc. const SENSITIVE_KEY_FRAGMENTS = [ 'email', 'phone', 'password', 'token', 'credentials', 'secret', 'api_key', 'apikey', 'auth', 'authorization', 'cookie', 'address', // physical/mailing addresses 'city', 'postal', 'country', 'dob', 'date_of_birth', 'birth', 'tax_id', 'taxid', 'national_id', 'ssn', 'passport', 'iban', 'card_number', 'cvv', 'recipient', 'first_name', 'last_name', 'full_name', 'fullname', ]; function isSensitiveKey(key: string): boolean { const k = key .replace(/([a-z0-9])([A-Z])/g, '$1_$2') .toLowerCase() .replace(/[-]/g, '_'); return SENSITIVE_KEY_FRAGMENTS.some((frag) => k.includes(frag)); } function maskString(val: string): string { return val.length > 4 ? `${val.slice(0, 2)}***${val.slice(-2)}` : '***'; } function maskValue(value: unknown, depth: number): unknown { if (depth > 4) return '[depth-limit]'; if (value === null || value === undefined) return value; if (typeof value === 'string') return maskString(value); if (Array.isArray(value)) return value.map((v) => maskValue(v, depth + 1)); if (typeof value === 'object') { // Recurse into nested object - only mask keys that themselves look // sensitive. Parents stay traversable. return maskObject(value as Record, depth + 1); } // Non-string primitives (number/boolean/bigint/symbol) at sensitive keys // are passed through unchanged. The original contract was "only mask // strings" - a number at an `email` key is a type error upstream and // shouldn't be silently replaced with `***`. return value; } function maskObject(data: Record, depth: number): Record { if (depth > 4) return { _truncated: '[depth-limit]' }; const masked: Record = {}; for (const [key, value] of Object.entries(data)) { if (isSensitiveKey(key)) { masked[key] = maskValue(value, depth + 1); } else if (value && typeof value === 'object' && !Array.isArray(value)) { masked[key] = maskObject(value as Record, depth + 1); } else if (Array.isArray(value)) { masked[key] = value.map((v) => v && typeof v === 'object' && !Array.isArray(v) ? maskObject(v as Record, depth + 1) : v, ); } else { masked[key] = value; } } return masked; } /** * 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. * Walks nested objects/arrays so e.g. `{recipient: {email: "a@b"}}` masks * the inner value too. */ export function maskSensitiveFields( data?: Record, ): Record | undefined { if (!data) return undefined; return maskObject(data, 0); } /** * 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. */ // Some actions get a default severity bump so callers don't have to // remember; explicit `severity` on the call still wins. const DEFAULT_SEVERITY_BY_ACTION: Partial> = { permission_denied: 'warning', hard_delete: 'critical', // L-AU01: explicit severities so the row badge in /admin/audit lights // up correctly. Without these, security-relevant verbs landed as // generic 'info' grey rows next to read events. password_change: 'warning', portal_invite: 'info', portal_activate: 'info', portal_password_reset_request: 'warning', portal_password_reset: 'warning', revoke_invite: 'warning', request_gdpr_export: 'info', send_gdpr_export: 'info', request_hard_delete_code: 'warning', outcome_set: 'info', outcome_cleared: 'info', // Webhook lifecycle defaults to warning when a delivery fails. webhook_failed: 'warning', webhook_dead_letter: 'error', job_failed: 'error', }; const AUTH_ACTIONS = new Set(['login', 'logout', 'password_change']); export async function createAuditLog(params: AuditLogParams): Promise { try { const severity = params.severity ?? DEFAULT_SEVERITY_BY_ACTION[params.action] ?? 'info'; const source = params.source ?? (AUTH_ACTIONS.has(params.action) ? 'auth' : 'user'); 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, // Mask metadata too - the audit found portal-auth, crm-invite, // hard-delete, and email-accounts services were writing raw emails // into this column. metadata: maskSensitiveFields(params.metadata) ?? null, ipAddress: params.ipAddress ?? null, userAgent: params.userAgent ?? null, severity, source, }); } 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', ); } }