360 lines
8.9 KiB
TypeScript
360 lines
8.9 KiB
TypeScript
/**
|
|
* 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<string, any>
|
|
): Promise<void> {
|
|
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<void> {
|
|
logger.info("DDL 실행 시작", {
|
|
userId,
|
|
companyCode,
|
|
ddlType,
|
|
tableName,
|
|
requestData,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 최근 DDL 실행 로그 조회
|
|
*/
|
|
static async getRecentDDLLogs(
|
|
limit: number = 50,
|
|
userId?: string,
|
|
ddlType?: string
|
|
): Promise<any[]> {
|
|
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<any>(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<string, number>;
|
|
byUser: Record<string, number>;
|
|
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<any>(
|
|
`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<any>(
|
|
`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<any>(
|
|
`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<any>(
|
|
`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<any[]> {
|
|
try {
|
|
const history = await query<any>(
|
|
`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<number> {
|
|
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<void> {
|
|
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);
|
|
}
|
|
}
|
|
}
|