import { randomUUID } from 'crypto'; import { clickhouseService } from './clickhouse'; import { hashMappingService } from './hashMapping'; import { createPHIReference, parsePHIReference } from '../utils/crypto'; import { doc, getDoc, getFirestore } from 'firebase/firestore'; import { getApp } from 'firebase/app'; export interface PHIFields { patientName?: string; patientEmail?: string; patientPhone?: string; patientDob?: string; patientSsn?: string; patientMrn?: string; diagnosis?: string; treatmentDetails?: string; medications?: string; [key: string]: string | undefined; } export interface AuditEvent { id?: string; timestamp?: Date; actor: { id: string; email?: string; name?: string; role?: string; }; action: string; resource?: { type: string; id: string; name?: string; }; severity?: 'low' | 'medium' | 'high' | 'critical'; success?: boolean; error?: string; phi?: PHIFields; metadata?: Record; ipAddress?: string; userAgent?: string; department?: string; location?: string; sessionId?: string; requestId?: string; } export interface PHIMapping { collection: string; field: string; value: any; } /** * Privacy-aware audit logger that separates PHI from audit events */ export class PrivacyAuditLogger { private static instance: PrivacyAuditLogger; private db = getFirestore(getApp()); private constructor() {} static getInstance(): PrivacyAuditLogger { if (!PrivacyAuditLogger.instance) { PrivacyAuditLogger.instance = new PrivacyAuditLogger(); } return PrivacyAuditLogger.instance; } /** * Log an audit event with automatic PHI obfuscation */ async logEvent(event: AuditEvent): Promise { const eventId = event.id || randomUUID(); const timestamp = event.timestamp || new Date(); // Process PHI fields if present const phiReferences: Record = {}; if (event.phi) { // Get hash for the patient/user const patientUserId = event.resource?.id || event.actor.id; const hashedUserId = await hashMappingService.getOrCreateHashMapping(patientUserId); // Convert PHI fields to references for (const [key, value] of Object.entries(event.phi)) { if (value !== undefined && value !== null) { // Determine collection and field based on PHI field name const { collection, field } = this.getFirestoreLocation(key); phiReferences[this.mapPHIFieldToColumn(key)] = createPHIReference( hashedUserId, collection, field ); } } } // Prepare event for ClickHouse const clickhouseEvent = { id: eventId, timestamp: timestamp.toISOString(), actor_id: await hashMappingService.getOrCreateHashMapping(event.actor.id), actor_email: event.actor.email || null, actor_name: event.actor.name || null, actor_role: event.actor.role || null, action: event.action, resource_type: event.resource?.type || null, resource_id: event.resource?.id || null, resource_name: event.resource?.name || null, severity: event.severity || 'low', success: event.success !== false, error_message: event.error || null, ...phiReferences, ip_address: event.ipAddress || null, user_agent: event.userAgent || null, department: event.department || null, location: event.location || null, session_id: event.sessionId || null, request_id: event.requestId || null, metadata: JSON.stringify(event.metadata || {}) }; // Insert into ClickHouse await clickhouseService.insertEvents([clickhouseEvent]); return eventId; } /** * Log multiple events in batch */ async logEvents(events: AuditEvent[]): Promise { const processedEvents = await Promise.all( events.map(async (event) => { const eventId = event.id || randomUUID(); const timestamp = event.timestamp || new Date(); // Process PHI fields const phiReferences: Record = {}; if (event.phi) { const patientUserId = event.resource?.id || event.actor.id; const hashedUserId = await hashMappingService.getOrCreateHashMapping(patientUserId); for (const [key, value] of Object.entries(event.phi)) { if (value !== undefined && value !== null) { const { collection, field } = this.getFirestoreLocation(key); phiReferences[this.mapPHIFieldToColumn(key)] = createPHIReference( hashedUserId, collection, field ); } } } return { id: eventId, timestamp: timestamp.toISOString(), actor_id: await hashMappingService.getOrCreateHashMapping(event.actor.id), actor_email: event.actor.email || null, actor_name: event.actor.name || null, actor_role: event.actor.role || null, action: event.action, resource_type: event.resource?.type || null, resource_id: event.resource?.id || null, resource_name: event.resource?.name || null, severity: event.severity || 'low', success: event.success !== false, error_message: event.error || null, ...phiReferences, ip_address: event.ipAddress || null, user_agent: event.userAgent || null, department: event.department || null, location: event.location || null, session_id: event.sessionId || null, request_id: event.requestId || null, metadata: JSON.stringify(event.metadata || {}) }; }) ); await clickhouseService.insertEvents(processedEvents); return processedEvents.map(e => e.id); } /** * Reconstruct an audit event with PHI data */ async reconstructWithPHI(eventId: string): Promise { // Query ClickHouse for the event const query = ` SELECT * FROM audit_logs.audit_events WHERE id = {eventId:String} LIMIT 1 `; const results = await clickhouseService.queryEvents(query, { eventId }); if (results.length === 0) { return null; } const event = results[0]; // Resolve actor ID const actorId = await hashMappingService.resolveHash(event.actor_id); if (!actorId) { throw new Error(`Unable to resolve actor hash: ${event.actor_id}`); } // Reconstruct basic event const reconstructed: AuditEvent = { id: event.id, timestamp: new Date(event.timestamp), actor: { id: actorId, email: event.actor_email, name: event.actor_name, role: event.actor_role }, action: event.action, resource: event.resource_type ? { type: event.resource_type, id: event.resource_id, name: event.resource_name } : undefined, severity: event.severity, success: event.success, error: event.error_message, metadata: JSON.parse(event.metadata || '{}'), ipAddress: event.ip_address, userAgent: event.user_agent, department: event.department, location: event.location, sessionId: event.session_id, requestId: event.request_id }; // Resolve PHI fields const phiFields: PHIFields = {}; const phiColumns = [ 'patient_name', 'patient_email', 'patient_phone', 'patient_dob', 'patient_ssn', 'patient_mrn', 'diagnosis', 'treatment_details', 'medications' ]; for (const column of phiColumns) { if (event[column]) { const parsed = parsePHIReference(event[column]); if (parsed) { const userId = await hashMappingService.resolveHash(parsed.hashedUserId); if (userId) { const value = await this.fetchPHIValue(userId, parsed.collection, parsed.field); if (value !== null) { phiFields[this.mapColumnToPHIField(column)] = value; } } } } } if (Object.keys(phiFields).length > 0) { reconstructed.phi = phiFields; } return reconstructed; } /** * Query audit events with optional PHI reconstruction */ async queryEvents( query: string, params?: Record, includePHI: boolean = false ): Promise { const results = await clickhouseService.queryEvents(query, params); if (!includePHI) { // Return events without PHI reconstruction return results.map(event => ({ id: event.id, timestamp: new Date(event.timestamp), actor: { id: event.actor_id, // Keep hashed for privacy email: event.actor_email, name: event.actor_name, role: event.actor_role }, action: event.action, resource: event.resource_type ? { type: event.resource_type, id: event.resource_id, name: event.resource_name } : undefined, severity: event.severity, success: event.success, error: event.error_message, metadata: JSON.parse(event.metadata || '{}') })); } // Reconstruct with PHI const reconstructed = await Promise.all( results.map(r => this.reconstructWithPHI(r.id)) ); return reconstructed.filter(e => e !== null) as AuditEvent[]; } /** * Generate audit report for compliance */ async generateAuditReport( startDate: Date, endDate: Date, options?: { actorId?: string; resourceType?: string; resourceId?: string; includePHI?: boolean; } ): Promise { let query = ` SELECT * FROM audit_logs.audit_events WHERE timestamp >= {startDate:DateTime64} AND timestamp <= {endDate:DateTime64} `; const params: Record = { startDate: startDate.toISOString(), endDate: endDate.toISOString() }; if (options?.actorId) { const hashedActorId = await hashMappingService.getOrCreateHashMapping(options.actorId); query += ` AND actor_id = {actorId:String}`; params.actorId = hashedActorId; } if (options?.resourceType) { query += ` AND resource_type = {resourceType:String}`; params.resourceType = options.resourceType; } if (options?.resourceId) { query += ` AND resource_id = {resourceId:String}`; params.resourceId = options.resourceId; } query += ` ORDER BY timestamp DESC`; const events = await this.queryEvents(query, params, options?.includePHI || false); // Generate summary statistics const summaryQuery = ` SELECT count() as total_events, countIf(success = 1) as successful_events, countIf(success = 0) as failed_events, uniqExact(actor_id) as unique_actors, groupArray(DISTINCT action) as actions FROM audit_logs.audit_events WHERE timestamp >= {startDate:DateTime64} AND timestamp <= {endDate:DateTime64} `; const summary = await clickhouseService.queryEvents(summaryQuery, params); return { report: { startDate, endDate, summary: summary[0], events } }; } /** * Determine Firestore collection and field for a PHI field */ private getFirestoreLocation(phiFieldName: string): { collection: string; field: string } { // Default mappings - can be customized per implementation const mappings: Record = { patientName: { collection: 'users', field: 'displayName' }, patientEmail: { collection: 'users', field: 'email' }, patientPhone: { collection: 'users', field: 'phoneNumber' }, patientDob: { collection: 'patient_profiles', field: 'dateOfBirth' }, patientSsn: { collection: 'patient_profiles', field: 'ssn' }, patientMrn: { collection: 'patient_profiles', field: 'medicalRecordNumber' }, diagnosis: { collection: 'medical_records', field: 'diagnosis' }, treatmentDetails: { collection: 'medical_records', field: 'treatmentPlan' }, medications: { collection: 'medical_records', field: 'medications' } }; return mappings[phiFieldName] || { collection: 'users', field: phiFieldName }; } /** * Map PHI field names to ClickHouse column names */ private mapPHIFieldToColumn(fieldName: string): string { const mappings: Record = { patientName: 'patient_name', patientEmail: 'patient_email', patientPhone: 'patient_phone', patientDob: 'patient_dob', patientSsn: 'patient_ssn', patientMrn: 'patient_mrn', diagnosis: 'diagnosis', treatmentDetails: 'treatment_details', medications: 'medications' }; return mappings[fieldName] || fieldName.replace(/([A-Z])/g, '_$1').toLowerCase(); } /** * Map ClickHouse column names back to PHI field names */ private mapColumnToPHIField(columnName: string): string { const mappings: Record = { patient_name: 'patientName', patient_email: 'patientEmail', patient_phone: 'patientPhone', patient_dob: 'patientDob', patient_ssn: 'patientSsn', patient_mrn: 'patientMrn', diagnosis: 'diagnosis', treatment_details: 'treatmentDetails', medications: 'medications' }; return mappings[columnName] || columnName; } /** * Fetch PHI value from Firestore */ private async fetchPHIValue( userId: string, collection: string, field: string ): Promise { try { const docRef = doc(this.db, collection, userId); const snapshot = await getDoc(docRef); if (!snapshot.exists()) { return null; } const data = snapshot.data(); return data[field] || null; } catch (error) { console.error(`Failed to fetch PHI value from ${collection}/${userId}/${field}:`, error); return null; } } } // Export singleton instance export const privacyAuditLogger = PrivacyAuditLogger.getInstance();