/** * DDL 실행 감사 로깅 서비스 * 모든 DDL 실행을 추적하고 기록 */ import { query, queryOne } from "../database/db"; import { logger } from "../utils/logger"; export class DDLAuditLogger { /** * DDL 실행 로그 기록 */ static async logDDLExecution( userId: string, companyCode: string, ddlType: "CREATE_TABLE" | "ADD_COLUMN" | "DROP_TABLE" | "DROP_COLUMN", tableName: string, ddlQuery: string, success: boolean, error?: string, additionalInfo?: Record ): Promise { try { // DDL 실행 로그 데이터베이스에 저장 await query( `INSERT INTO ddl_execution_log ( user_id, company_code, ddl_type, table_name, ddl_query, success, error_message, executed_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`, [ userId, companyCode, ddlType, tableName, ddlQuery, success, error || null, ] ); // 추가 로깅 (파일 로그) const logData = { userId, companyCode, ddlType, tableName, success, queryLength: ddlQuery.length, error: error || null, additionalInfo: additionalInfo || null, timestamp: new Date().toISOString(), }; if (success) { logger.info("DDL 실행 성공", logData); } else { logger.error("DDL 실행 실패", { ...logData, ddlQuery }); } // 중요한 DDL 실행은 별도 알림 (필요시) if (ddlType === "CREATE_TABLE" || ddlType === "DROP_TABLE") { logger.warn("중요 DDL 실행", { ...logData, severity: "HIGH", action: "TABLE_STRUCTURE_CHANGE", }); } } catch (logError) { // 로그 기록 실패는 시스템에 영향을 주지 않도록 처리 logger.error("DDL 실행 로그 기록 실패:", { originalUserId: userId, originalDdlType: ddlType, originalTableName: tableName, originalSuccess: success, logError: logError, }); // 로그 기록 실패를 파일 로그로라도 남김 console.error("CRITICAL: DDL 로그 기록 실패", { userId, ddlType, tableName, success, logError, timestamp: new Date().toISOString(), }); } } /** * DDL 실행 시작 로그 */ static async logDDLStart( userId: string, companyCode: string, ddlType: "CREATE_TABLE" | "ADD_COLUMN" | "DROP_TABLE" | "DROP_COLUMN", tableName: string, requestData: any ): Promise { logger.info("DDL 실행 시작", { userId, companyCode, ddlType, tableName, requestData, timestamp: new Date().toISOString(), }); } /** * 최근 DDL 실행 로그 조회 */ static async getRecentDDLLogs( limit: number = 50, userId?: string, ddlType?: string ): Promise { try { let whereClause = "WHERE 1=1"; const params: any[] = []; if (userId) { whereClause += " AND user_id = $" + (params.length + 1); params.push(userId); } if (ddlType) { whereClause += " AND ddl_type = $" + (params.length + 1); params.push(ddlType); } const sql = ` SELECT id, user_id, company_code, ddl_type, table_name, success, error_message, executed_at, CASE WHEN LENGTH(ddl_query) > 100 THEN SUBSTRING(ddl_query, 1, 100) || '...' ELSE ddl_query END as ddl_query_preview FROM ddl_execution_log ${whereClause} ORDER BY executed_at DESC LIMIT $${params.length + 1} `; params.push(limit); const logs = await query(sql, params); return logs; } catch (error) { logger.error("DDL 로그 조회 실패:", error); return []; } } /** * DDL 실행 통계 조회 */ static async getDDLStatistics( fromDate?: Date, toDate?: Date ): Promise<{ totalExecutions: number; successfulExecutions: number; failedExecutions: number; byDDLType: Record; byUser: Record; recentFailures: any[]; }> { try { let dateFilter = ""; const params: any[] = []; if (fromDate) { dateFilter += " AND executed_at >= $" + (params.length + 1); params.push(fromDate); } if (toDate) { dateFilter += " AND executed_at <= $" + (params.length + 1); params.push(toDate); } // 전체 통계 const totalStats = await query( `SELECT COUNT(*) as total_executions, SUM(CASE WHEN success = true THEN 1 ELSE 0 END) as successful_executions, SUM(CASE WHEN success = false THEN 1 ELSE 0 END) as failed_executions FROM ddl_execution_log WHERE 1=1 ${dateFilter}`, params ); // DDL 타입별 통계 const ddlTypeStats = await query( `SELECT ddl_type, COUNT(*) as count FROM ddl_execution_log WHERE 1=1 ${dateFilter} GROUP BY ddl_type ORDER BY count DESC`, params ); // 사용자별 통계 const userStats = await query( `SELECT user_id, COUNT(*) as count FROM ddl_execution_log WHERE 1=1 ${dateFilter} GROUP BY user_id ORDER BY count DESC LIMIT 10`, params ); // 최근 실패 로그 const recentFailures = await query( `SELECT user_id, ddl_type, table_name, error_message, executed_at FROM ddl_execution_log WHERE success = false ${dateFilter} ORDER BY executed_at DESC LIMIT 10`, params ); const stats = totalStats[0]; return { totalExecutions: parseInt(stats.total_executions) || 0, successfulExecutions: parseInt(stats.successful_executions) || 0, failedExecutions: parseInt(stats.failed_executions) || 0, byDDLType: ddlTypeStats.reduce((acc, row) => { acc[row.ddl_type] = parseInt(row.count); return acc; }, {}), byUser: userStats.reduce((acc, row) => { acc[row.user_id] = parseInt(row.count); return acc; }, {}), recentFailures, }; } catch (error) { logger.error("DDL 통계 조회 실패:", error); return { totalExecutions: 0, successfulExecutions: 0, failedExecutions: 0, byDDLType: {}, byUser: {}, recentFailures: [], }; } } /** * 특정 테이블의 DDL 히스토리 조회 */ static async getTableDDLHistory(tableName: string): Promise { try { const history = await query( `SELECT id, user_id, ddl_type, ddl_query, success, error_message, executed_at FROM ddl_execution_log WHERE table_name = $1 ORDER BY executed_at DESC LIMIT 20`, [tableName] ); return history; } catch (error) { logger.error(`테이블 '${tableName}' DDL 히스토리 조회 실패:`, error); return []; } } /** * DDL 로그 정리 (오래된 로그 삭제) */ static async cleanupOldLogs(retentionDays: number = 90): Promise { try { const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - retentionDays); const result = await query( `DELETE FROM ddl_execution_log WHERE executed_at < $1`, [cutoffDate] ); const deletedCount = result.length; logger.info(`DDL 로그 정리 완료: ${deletedCount}개 레코드 삭제`, { retentionDays, cutoffDate: cutoffDate.toISOString(), }); return deletedCount; } catch (error) { logger.error("DDL 로그 정리 실패:", error); return 0; } } /** * 긴급 상황 로그 (시스템 테이블 접근 시도 등) */ static async logSecurityAlert( userId: string, companyCode: string, alertType: | "SYSTEM_TABLE_ACCESS" | "INVALID_PERMISSION" | "SUSPICIOUS_ACTIVITY", details: string, requestData?: any ): Promise { try { // 보안 알림은 별도의 고급 로깅 logger.error("DDL 보안 알림", { alertType, userId, companyCode, details, requestData, severity: "CRITICAL", timestamp: new Date().toISOString(), }); // 필요시 외부 알림 시스템 연동 (이메일, 슬랙 등) // await sendSecurityAlert(alertType, userId, details); } catch (error) { logger.error("보안 알림 기록 실패:", error); } } }