Files
invyone/backend-node/src/services/auditLogService.ts
T
kjs 7c96461f59 feat: enhance audit log functionality and file upload components
- Updated the audit log controller to determine super admin status based on user type instead of company code.
- Added detailed logging for column settings updates and batch updates in the table management controller, capturing user actions and changes made.
- Implemented security measures in the audit log service to mask sensitive data for non-super admin users.
- Introduced a new TableCellFile component to handle file attachments, supporting both objid and JSON array formats for file information.
- Enhanced the file upload component to manage file states more effectively during record changes and mode transitions.

These updates aim to improve the audit logging capabilities and file management features within the ERP system, ensuring better security and user experience.
2026-03-17 11:31:54 +09:00

340 lines
9.7 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"
| "NODE_FLOW";
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;
company_name: string | null;
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 {
logger.info(`[AuditLog] 기록 시도: ${params.resourceType} / ${params.action} / ${params.resourceName || params.resourceId || "N/A"}`);
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,
]
);
logger.info(`[AuditLog] 기록 성공: ${params.resourceType} / ${params.action}`);
} catch (error: any) {
logger.error(`[AuditLog] 기록 실패: ${params.resourceType} / ${params.action} - ${error?.message}`, { 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(`sal.company_code = $${paramIndex++}`);
params.push(filters.companyCode);
} else if (isSuperAdmin && filters.companyCode) {
conditions.push(`sal.company_code = $${paramIndex++}`);
params.push(filters.companyCode);
}
if (filters.userId) {
conditions.push(`sal.user_id = $${paramIndex++}`);
params.push(filters.userId);
}
if (filters.resourceType) {
conditions.push(`sal.resource_type = $${paramIndex++}`);
params.push(filters.resourceType);
}
if (filters.action) {
conditions.push(`sal.action = $${paramIndex++}`);
params.push(filters.action);
}
if (filters.tableName) {
conditions.push(`sal.table_name = $${paramIndex++}`);
params.push(filters.tableName);
}
if (filters.dateFrom) {
conditions.push(`sal.created_at >= $${paramIndex++}::timestamptz`);
params.push(filters.dateFrom);
}
if (filters.dateTo) {
conditions.push(`sal.created_at <= $${paramIndex++}::timestamptz`);
params.push(filters.dateTo);
}
if (filters.search) {
conditions.push(
`(sal.summary ILIKE $${paramIndex} OR sal.resource_name ILIKE $${paramIndex} OR sal.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 sal ${whereClause}`,
params
);
const total = parseInt(countResult[0].count, 10);
const data = await query<AuditLogEntry>(
`SELECT sal.*, ci.company_name
FROM system_audit_log sal
LEFT JOIN company_mng ci ON sal.company_code = ci.company_code
${whereClause}
ORDER BY sal.created_at DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
[...params, limit, offset]
);
const SECURITY_MASK = "(보안 항목 - 값 비공개)";
const securedTables = ["table_type_columns"];
if (!isSuperAdmin) {
for (const entry of data) {
if (entry.table_name && securedTables.includes(entry.table_name) && entry.changes) {
const changes = typeof entry.changes === "string" ? JSON.parse(entry.changes) : entry.changes;
if (changes.before) {
for (const key of Object.keys(changes.before)) {
changes.before[key] = SECURITY_MASK;
}
}
if (changes.after) {
for (const key of Object.keys(changes.after)) {
changes.after[key] = SECURITY_MASK;
}
}
entry.changes = changes;
}
}
}
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();