- Integrated client IP address retrieval in the audit logging functionality across multiple controllers, including admin, common code, department, flow, screen, and table management. - Updated the `auditLogService` to include a new method for obtaining the client's IP address, ensuring accurate logging of user actions. - This enhancement improves traceability and accountability by capturing the source of requests, thereby strengthening the overall logging mechanism within the application.
311 lines
8.5 KiB
TypeScript
311 lines
8.5 KiB
TypeScript
import { Request } from "express";
|
|
import { query, pool } from "../database/db";
|
|
import logger from "../utils/logger";
|
|
|
|
export function getClientIp(req: Request): string {
|
|
const forwarded = req.headers["x-forwarded-for"];
|
|
if (forwarded) {
|
|
const first = Array.isArray(forwarded) ? forwarded[0] : forwarded.split(",")[0];
|
|
return first.trim();
|
|
}
|
|
const realIp = req.headers["x-real-ip"];
|
|
if (realIp) {
|
|
return Array.isArray(realIp) ? realIp[0] : realIp;
|
|
}
|
|
return req.ip || req.socket?.remoteAddress || "unknown";
|
|
}
|
|
|
|
export type AuditAction =
|
|
| "CREATE"
|
|
| "UPDATE"
|
|
| "DELETE"
|
|
| "COPY"
|
|
| "LOGIN"
|
|
| "STATUS_CHANGE"
|
|
| "BATCH_CREATE"
|
|
| "BATCH_UPDATE"
|
|
| "BATCH_DELETE";
|
|
|
|
export type AuditResourceType =
|
|
| "MENU"
|
|
| "SCREEN"
|
|
| "SCREEN_LAYOUT"
|
|
| "FLOW"
|
|
| "FLOW_STEP"
|
|
| "USER"
|
|
| "ROLE"
|
|
| "PERMISSION"
|
|
| "COMPANY"
|
|
| "CODE_CATEGORY"
|
|
| "CODE"
|
|
| "DATA"
|
|
| "TABLE"
|
|
| "NUMBERING_RULE"
|
|
| "BATCH";
|
|
|
|
export interface AuditLogParams {
|
|
companyCode: string;
|
|
userId: string;
|
|
userName?: string;
|
|
action: AuditAction;
|
|
resourceType: AuditResourceType;
|
|
resourceId?: string;
|
|
resourceName?: string;
|
|
tableName?: string;
|
|
summary?: string;
|
|
changes?: {
|
|
before?: Record<string, any>;
|
|
after?: Record<string, any>;
|
|
fields?: string[];
|
|
};
|
|
ipAddress?: string;
|
|
requestPath?: string;
|
|
}
|
|
|
|
export interface AuditLogEntry {
|
|
id: number;
|
|
company_code: string;
|
|
user_id: string;
|
|
user_name: string | null;
|
|
action: string;
|
|
resource_type: string;
|
|
resource_id: string | null;
|
|
resource_name: string | null;
|
|
table_name: string | null;
|
|
summary: string | null;
|
|
changes: any;
|
|
ip_address: string | null;
|
|
request_path: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface AuditLogFilters {
|
|
companyCode?: string;
|
|
userId?: string;
|
|
resourceType?: string;
|
|
action?: string;
|
|
tableName?: string;
|
|
dateFrom?: string;
|
|
dateTo?: string;
|
|
search?: string;
|
|
page?: number;
|
|
limit?: number;
|
|
}
|
|
|
|
export interface AuditLogStats {
|
|
dailyCounts: Array<{ date: string; count: number }>;
|
|
resourceTypeCounts: Array<{ resource_type: string; count: number }>;
|
|
actionCounts: Array<{ action: string; count: number }>;
|
|
topUsers: Array<{ user_id: string; user_name: string; count: number }>;
|
|
}
|
|
|
|
class AuditLogService {
|
|
/**
|
|
* 감사 로그 1건 기록 (fire-and-forget)
|
|
* 본 작업에 영향을 주지 않도록 에러를 내부에서 처리
|
|
*/
|
|
async log(params: AuditLogParams): Promise<void> {
|
|
try {
|
|
await query(
|
|
`INSERT INTO system_audit_log
|
|
(company_code, user_id, user_name, action, resource_type,
|
|
resource_id, resource_name, table_name, summary, changes,
|
|
ip_address, request_path)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
|
[
|
|
params.companyCode,
|
|
params.userId,
|
|
params.userName || null,
|
|
params.action,
|
|
params.resourceType,
|
|
params.resourceId || null,
|
|
params.resourceName || null,
|
|
params.tableName || null,
|
|
params.summary || null,
|
|
params.changes ? JSON.stringify(params.changes) : null,
|
|
params.ipAddress || null,
|
|
params.requestPath || null,
|
|
]
|
|
);
|
|
} catch (error) {
|
|
logger.error("감사 로그 기록 실패 (무시됨)", { error, params });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 감사 로그 다건 기록 (배치)
|
|
*/
|
|
async logBatch(entries: AuditLogParams[]): Promise<void> {
|
|
if (entries.length === 0) return;
|
|
try {
|
|
const values = entries
|
|
.map(
|
|
(_, i) =>
|
|
`($${i * 12 + 1}, $${i * 12 + 2}, $${i * 12 + 3}, $${i * 12 + 4}, $${i * 12 + 5}, $${i * 12 + 6}, $${i * 12 + 7}, $${i * 12 + 8}, $${i * 12 + 9}, $${i * 12 + 10}, $${i * 12 + 11}, $${i * 12 + 12})`
|
|
)
|
|
.join(", ");
|
|
|
|
const params = entries.flatMap((e) => [
|
|
e.companyCode,
|
|
e.userId,
|
|
e.userName || null,
|
|
e.action,
|
|
e.resourceType,
|
|
e.resourceId || null,
|
|
e.resourceName || null,
|
|
e.tableName || null,
|
|
e.summary || null,
|
|
e.changes ? JSON.stringify(e.changes) : null,
|
|
e.ipAddress || null,
|
|
e.requestPath || null,
|
|
]);
|
|
|
|
await query(
|
|
`INSERT INTO system_audit_log
|
|
(company_code, user_id, user_name, action, resource_type,
|
|
resource_id, resource_name, table_name, summary, changes,
|
|
ip_address, request_path)
|
|
VALUES ${values}`,
|
|
params
|
|
);
|
|
} catch (error) {
|
|
logger.error("감사 로그 배치 기록 실패 (무시됨)", { error });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 감사 로그 조회 (페이징, 필터)
|
|
*/
|
|
async queryLogs(
|
|
filters: AuditLogFilters,
|
|
isSuperAdmin: boolean = false
|
|
): Promise<{ data: AuditLogEntry[]; total: number }> {
|
|
const conditions: string[] = [];
|
|
const params: any[] = [];
|
|
let paramIndex = 1;
|
|
|
|
if (!isSuperAdmin && filters.companyCode) {
|
|
conditions.push(`company_code = $${paramIndex++}`);
|
|
params.push(filters.companyCode);
|
|
} else if (isSuperAdmin && filters.companyCode) {
|
|
conditions.push(`company_code = $${paramIndex++}`);
|
|
params.push(filters.companyCode);
|
|
}
|
|
|
|
if (filters.userId) {
|
|
conditions.push(`user_id = $${paramIndex++}`);
|
|
params.push(filters.userId);
|
|
}
|
|
if (filters.resourceType) {
|
|
conditions.push(`resource_type = $${paramIndex++}`);
|
|
params.push(filters.resourceType);
|
|
}
|
|
if (filters.action) {
|
|
conditions.push(`action = $${paramIndex++}`);
|
|
params.push(filters.action);
|
|
}
|
|
if (filters.tableName) {
|
|
conditions.push(`table_name = $${paramIndex++}`);
|
|
params.push(filters.tableName);
|
|
}
|
|
if (filters.dateFrom) {
|
|
conditions.push(`created_at >= $${paramIndex++}::timestamptz`);
|
|
params.push(filters.dateFrom);
|
|
}
|
|
if (filters.dateTo) {
|
|
conditions.push(`created_at <= $${paramIndex++}::timestamptz`);
|
|
params.push(filters.dateTo);
|
|
}
|
|
if (filters.search) {
|
|
conditions.push(
|
|
`(summary ILIKE $${paramIndex} OR resource_name ILIKE $${paramIndex} OR user_name ILIKE $${paramIndex})`
|
|
);
|
|
params.push(`%${filters.search}%`);
|
|
paramIndex++;
|
|
}
|
|
|
|
const whereClause =
|
|
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
|
|
const page = filters.page || 1;
|
|
const limit = filters.limit || 50;
|
|
const offset = (page - 1) * limit;
|
|
|
|
const countResult = await query<{ count: string }>(
|
|
`SELECT COUNT(*) as count FROM system_audit_log ${whereClause}`,
|
|
params
|
|
);
|
|
const total = parseInt(countResult[0].count, 10);
|
|
|
|
const data = await query<AuditLogEntry>(
|
|
`SELECT * FROM system_audit_log ${whereClause}
|
|
ORDER BY created_at DESC
|
|
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
|
[...params, limit, offset]
|
|
);
|
|
|
|
return { data, total };
|
|
}
|
|
|
|
/**
|
|
* 통계 조회
|
|
*/
|
|
async getStats(
|
|
companyCode?: string,
|
|
days: number = 30
|
|
): Promise<AuditLogStats> {
|
|
const companyFilter = companyCode
|
|
? "AND company_code = $1"
|
|
: "";
|
|
const params = companyCode ? [companyCode] : [];
|
|
|
|
const dailyCounts = await query<{ date: string; count: number }>(
|
|
`SELECT DATE(created_at) as date, COUNT(*)::int as count
|
|
FROM system_audit_log
|
|
WHERE created_at >= NOW() - INTERVAL '${days} days' ${companyFilter}
|
|
GROUP BY DATE(created_at)
|
|
ORDER BY date DESC`,
|
|
params
|
|
);
|
|
|
|
const resourceTypeCounts = await query<{
|
|
resource_type: string;
|
|
count: number;
|
|
}>(
|
|
`SELECT resource_type, COUNT(*)::int as count
|
|
FROM system_audit_log
|
|
WHERE created_at >= NOW() - INTERVAL '${days} days' ${companyFilter}
|
|
GROUP BY resource_type
|
|
ORDER BY count DESC`,
|
|
params
|
|
);
|
|
|
|
const actionCounts = await query<{ action: string; count: number }>(
|
|
`SELECT action, COUNT(*)::int as count
|
|
FROM system_audit_log
|
|
WHERE created_at >= NOW() - INTERVAL '${days} days' ${companyFilter}
|
|
GROUP BY action
|
|
ORDER BY count DESC`,
|
|
params
|
|
);
|
|
|
|
const topUsers = await query<{
|
|
user_id: string;
|
|
user_name: string;
|
|
count: number;
|
|
}>(
|
|
`SELECT user_id, COALESCE(MAX(user_name), user_id) as user_name, COUNT(*)::int as count
|
|
FROM system_audit_log
|
|
WHERE created_at >= NOW() - INTERVAL '${days} days' ${companyFilter}
|
|
GROUP BY user_id
|
|
ORDER BY count DESC
|
|
LIMIT 10`,
|
|
params
|
|
);
|
|
|
|
return { dailyCounts, resourceTypeCounts, actionCounts, topUsers };
|
|
}
|
|
}
|
|
|
|
export const auditLogService = new AuditLogService();
|