/** * Audit Service * * GDPR compliance: * - Art. 5(2) Accountability: every write to personal data is logged. * - Art. 30 Records of Processing Activities: who, what, when, delta. * - Art. 5(1)e Storage limitation: IP addresses anonymised after 90 days * via anonymizeOldIpAddresses(), called by the scheduled job. * * Critical invariant: logAudit() MUST NEVER throw or reject. Audit failures * are logged to Winston and silently swallowed so that the main request flow * is never interrupted. */ import pool from '../config/database'; import logger from '../utils/logger'; // --------------------------------------------------------------------------- // Enums — kept as const objects rather than TypeScript enums so that the // values are available as plain strings in runtime code and SQL parameters. // --------------------------------------------------------------------------- export const AuditAction = { CREATE: 'CREATE', UPDATE: 'UPDATE', DELETE: 'DELETE', LOGIN: 'LOGIN', LOGOUT: 'LOGOUT', EXPORT: 'EXPORT', PERMISSION_DENIED:'PERMISSION_DENIED', PASSWORD_CHANGE: 'PASSWORD_CHANGE', ROLE_CHANGE: 'ROLE_CHANGE', } as const; export type AuditAction = typeof AuditAction[keyof typeof AuditAction]; export const AuditResourceType = { MEMBER: 'MEMBER', INCIDENT: 'INCIDENT', VEHICLE: 'VEHICLE', EQUIPMENT: 'EQUIPMENT', QUALIFICATION: 'QUALIFICATION', USER: 'USER', SYSTEM: 'SYSTEM', } as const; export type AuditResourceType = typeof AuditResourceType[keyof typeof AuditResourceType]; // --------------------------------------------------------------------------- // Core interfaces // --------------------------------------------------------------------------- export interface AuditLogEntry { id: string; // UUID, set by database user_id: string | null; // UUID; null for unauthenticated events user_email: string | null; // denormalised snapshot action: AuditAction; resource_type: AuditResourceType; resource_id: string | null; old_value: Record | null; new_value: Record | null; ip_address: string | null; user_agent: string | null; metadata: Record; created_at: Date; } /** * Input type for logAudit() — the caller never supplies id or created_at. */ export type AuditLogInput = Omit; // --------------------------------------------------------------------------- // Filter + pagination types (used by the admin API) // --------------------------------------------------------------------------- export interface AuditFilters { userId?: string; action?: AuditAction | AuditAction[]; resourceType?: AuditResourceType | AuditResourceType[]; resourceId?: string; dateFrom?: Date; dateTo?: Date; page: number; // 1-based pageSize: number; // max 200 } export interface AuditLogPage { entries: AuditLogEntry[]; total: number; page: number; pages: number; } // --------------------------------------------------------------------------- // Sensitive field stripping // Ensures that passwords, tokens, and secrets never appear in the log even // if a caller accidentally passes a raw request body. // --------------------------------------------------------------------------- const SENSITIVE_KEYS = new Set([ 'password', 'password_hash', 'passwordHash', 'secret', 'token', 'accessToken', 'refreshToken', 'access_token', 'refresh_token', 'id_token', 'client_secret', 'authorization', 'cookie', ]); function stripSensitiveFields( value: Record | null | undefined ): Record | null { if (value === null || value === undefined) return null; const result: Record = {}; for (const [k, v] of Object.entries(value)) { if (SENSITIVE_KEYS.has(k.toLowerCase())) { result[k] = '[REDACTED]'; } else if (v !== null && typeof v === 'object' && !Array.isArray(v)) { result[k] = stripSensitiveFields(v as Record); } else { result[k] = v; } } return result; } // --------------------------------------------------------------------------- // AuditService // --------------------------------------------------------------------------- class AuditService { /** * logAudit — fire-and-forget. * * The caller does NOT await this. Even if awaited, it will never reject; * all errors are swallowed and written to Winston instead. */ async logAudit(entry: AuditLogInput): Promise { // Start immediately — do not block the caller. this._writeAuditEntry(entry).catch(() => { // _writeAuditEntry already handles its own error logging; this .catch() // prevents a potential unhandledRejection if the async method itself // throws synchronously before the inner try/catch. }); } /** * Internal write — all failures are caught and sent to Winston. * This method should never surface an exception to its caller. */ private async _writeAuditEntry(entry: AuditLogInput): Promise { try { const sanitisedOld = stripSensitiveFields(entry.old_value ?? null); const sanitisedNew = stripSensitiveFields(entry.new_value ?? null); const query = ` INSERT INTO audit_log ( user_id, user_email, action, resource_type, resource_id, old_value, new_value, ip_address, user_agent, metadata ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) `; const values = [ entry.user_id ?? null, entry.user_email ?? null, entry.action, entry.resource_type, entry.resource_id ?? null, sanitisedOld ? JSON.stringify(sanitisedOld) : null, sanitisedNew ? JSON.stringify(sanitisedNew) : null, entry.ip_address ?? null, entry.user_agent ?? null, JSON.stringify(entry.metadata ?? {}), ]; await pool.query(query, values); logger.debug('Audit entry written', { action: entry.action, resource_type: entry.resource_type, resource_id: entry.resource_id, user_id: entry.user_id, }); } catch (error) { // GDPR obligation: log the failure so it can be investigated, but // NEVER propagate — the main request must complete successfully. logger.error('AUDIT LOG FAILURE — entry could not be persisted to database', { error: error instanceof Error ? error.message : String(error), action: entry.action, resource_type: entry.resource_type, resource_id: entry.resource_id, user_id: entry.user_id, // Note: we intentionally do NOT log old_value/new_value here // because they may contain personal data. }); } } // ------------------------------------------------------------------------- // Query — admin UI // ------------------------------------------------------------------------- /** * getAuditLog — paginated, filtered query for the admin dashboard. * * Never called from hot paths; can be awaited normally. */ async getAuditLog(filters: AuditFilters): Promise { const page = Math.max(1, filters.page ?? 1); const pageSize = Math.min(200, Math.max(1, filters.pageSize ?? 25)); const offset = (page - 1) * pageSize; const conditions: string[] = []; const values: unknown[] = []; let paramIndex = 1; if (filters.userId) { conditions.push(`user_id = $${paramIndex++}`); values.push(filters.userId); } if (filters.action) { const actions = Array.isArray(filters.action) ? filters.action : [filters.action]; conditions.push(`action = ANY($${paramIndex++}::audit_action[])`); values.push(actions); } if (filters.resourceType) { const types = Array.isArray(filters.resourceType) ? filters.resourceType : [filters.resourceType]; conditions.push(`resource_type = ANY($${paramIndex++}::audit_resource_type[])`); values.push(types); } if (filters.resourceId) { conditions.push(`resource_id = $${paramIndex++}`); values.push(filters.resourceId); } if (filters.dateFrom) { conditions.push(`created_at >= $${paramIndex++}`); values.push(filters.dateFrom); } if (filters.dateTo) { conditions.push(`created_at <= $${paramIndex++}`); values.push(filters.dateTo); } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const countQuery = ` SELECT COUNT(*) AS total FROM audit_log ${whereClause} `; const dataQuery = ` SELECT id, user_id, user_email, action, resource_type, resource_id, old_value, new_value, ip_address, user_agent, metadata, created_at FROM audit_log ${whereClause} ORDER BY created_at DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++} `; const [countResult, dataResult] = await Promise.all([ pool.query(countQuery, values), pool.query(dataQuery, [...values, pageSize, offset]), ]); const total = parseInt(countResult.rows[0].total, 10); const entries = dataResult.rows as AuditLogEntry[]; return { entries, total, page, pages: Math.ceil(total / pageSize), }; } // ------------------------------------------------------------------------- // GDPR IP anonymisation — run as a scheduled job // ------------------------------------------------------------------------- /** * anonymizeOldIpAddresses * * Replaces the ip_address of every audit_log row older than 90 days with * the literal string '[anonymized]'. This satisfies the GDPR storage * limitation principle (Art. 5(1)(e)) for IP addresses as personal data. * * Uses a single UPDATE with a WHERE clause covered by idx_audit_log_ip_retention, * so performance is proportional to the number of rows being anonymised, not * the total table size. * * Note: The database RULE audit_log_no_update blocks UPDATE statements issued * *by application queries*, but that rule is created with DO INSTEAD NOTHING, * which means the application-level anonymisation query also cannot run unless * the rule is dropped or replaced. * * Resolution: the migration creates the rule. For the anonymisation job to * work, the database role used by this application must either: * (a) own the table (then rules do not apply to the owner), OR * (b) use a separate privileged role for the anonymisation query only. * * Recommended approach: use option (a) by ensuring DB_USER is the table * owner, which is the default when the same user runs the migrations. * The immutability rule then protects against accidental application-level * UPDATE/DELETE while the owner can still perform sanctioned data operations. */ async anonymizeOldIpAddresses(): Promise { try { const result = await pool.query(` UPDATE audit_log SET ip_address = '[anonymized]' WHERE created_at < NOW() - INTERVAL '90 days' AND ip_address IS NOT NULL AND ip_address != '[anonymized]' `); const count = result.rowCount ?? 0; if (count > 0) { logger.info('GDPR IP anonymisation complete', { rows_anonymized: count }); } else { logger.debug('GDPR IP anonymisation: no rows required anonymisation'); } } catch (error) { logger.error('GDPR IP anonymisation job failed', { error: error instanceof Error ? error.message : String(error), }); // Do not re-throw: the job scheduler should not crash the process. } } // ------------------------------------------------------------------------- // CSV export helper (used by the admin API route) // ------------------------------------------------------------------------- /** * Converts a page of audit entries to CSV format. * Passwords and secrets are already stripped; old_value/new_value are * serialised as compact JSON strings within the CSV cell. */ entriesToCsv(entries: AuditLogEntry[]): string { const header = [ 'id', 'created_at', 'user_id', 'user_email', 'action', 'resource_type', 'resource_id', 'ip_address', 'user_agent', 'old_value', 'new_value', 'metadata', ].join(','); const escape = (v: unknown): string => { if (v === null || v === undefined) return ''; const str = typeof v === 'object' ? JSON.stringify(v) : String(v); // RFC 4180: wrap in quotes, double any internal quotes return `"${str.replace(/"/g, '""')}"`; }; const rows = entries.map((e) => [ e.id, e.created_at.toISOString(), e.user_id ?? '', e.user_email ?? '', e.action, e.resource_type, e.resource_id ?? '', e.ip_address ?? '', escape(e.user_agent), escape(e.old_value), escape(e.new_value), escape(e.metadata), ].join(',') ); return [header, ...rows].join('\r\n'); } } export default new AuditService();