diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts index 2d4e01a6..e258ca87 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -117,6 +117,9 @@ export interface AuditLog { action: string; resource_type?: string | null; resource_id?: number | null; + /** 대상 식별자 문자열 — 프로젝트는 UUID 라 숫자 칸에 못 담는다 (2026-09-06). */ + resource_ref?: string | null; + ip_address?: string | null; timestamp: string; } diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index fc772cbf..9bed97a0 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -10,6 +10,7 @@ from typing import Any import aiomysql import psutil +from common_util.common_util_audit import record_audit from config.config_db import get_db_pool from config.config_system import ADMIN_EMAIL, EMAIL_REVERIFY_DAYS @@ -256,7 +257,9 @@ async def get_project(project_id: str) -> dict[str, Any] | None: return await cursor.fetchone() -async def update_project(project_id: str, data: dict[str, Any], actor_id: int) -> bool: +async def update_project( + project_id: str, data: dict[str, Any], actor_id: int, request: Any | None = None +) -> bool: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: await connection.begin() @@ -300,16 +303,19 @@ async def update_project(project_id: str, data: dict[str, Any], actor_id: int) - ) changed = await cursor.fetchone() is not None else: - await cursor.execute( - """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) - VALUES (%s, 'PROJECT_UPDATE', 'project', NULL)""", - (actor_id,), + await record_audit( + cursor, + actor_id=actor_id, + action="PROJECT_UPDATE", + resource_type="project", + resource_ref=project_id, + request=request, ) await connection.commit() return changed -async def soft_delete_project(project_id: str, actor_id: int) -> bool: +async def soft_delete_project(project_id: str, actor_id: int, request: Any | None = None) -> bool: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: await connection.begin() @@ -320,10 +326,13 @@ async def soft_delete_project(project_id: str, actor_id: int) -> bool: ) changed = cursor.rowcount > 0 if changed: - await cursor.execute( - """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) - VALUES (%s, 'PROJECT_DELETE', 'project', NULL)""", - (actor_id,), + await record_audit( + cursor, + actor_id=actor_id, + action="PROJECT_DELETE", + resource_type="project", + resource_ref=project_id, + request=request, ) await connection.commit() return changed @@ -447,7 +456,7 @@ async def list_audit_logs(limit: int, offset: int) -> dict[str, Any]: total = (await cursor.fetchone())["total"] await cursor.execute( """SELECT l.id, l.user_id, u.email, l.action, l.resource_type, - l.resource_id, l.timestamp + l.resource_id, l.resource_ref, l.ip_address, l.timestamp FROM system_audit_logs l LEFT JOIN users u ON u.id = l.user_id ORDER BY l.timestamp DESC LIMIT %s OFFSET %s""", (limit, offset), diff --git a/B01_Dashboard/B01_Dashboard_Repository_Company.py b/B01_Dashboard/B01_Dashboard_Repository_Company.py index e7a4eb12..c4a6ae6a 100644 --- a/B01_Dashboard/B01_Dashboard_Repository_Company.py +++ b/B01_Dashboard/B01_Dashboard_Repository_Company.py @@ -10,6 +10,7 @@ from typing import Any import aiomysql +from common_util.common_util_audit import record_audit from config.config_db import get_db_pool from .B01_Dashboard_Repository import role_for_company @@ -77,7 +78,9 @@ async def search_companies(query: str) -> list[dict[str, Any]]: return list(await cursor.fetchall()) -async def create_company(user_id: int, data: dict[str, Any]) -> dict[str, Any]: +async def create_company( + user_id: int, data: dict[str, Any], request: Any | None = None +) -> dict[str, Any]: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: try: @@ -104,10 +107,13 @@ async def create_company(user_id: int, data: dict[str, Any]) -> dict[str, Any]: WHERE id = %s""", (company_id, user_id), ) - await cursor.execute( - """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) - VALUES (%s, 'COMPANY_CREATE', 'company', %s)""", - (user_id, company_id), + await record_audit( + cursor, + actor_id=user_id, + action="COMPANY_CREATE", + resource_type="company", + resource_ref=company_id, + request=request, ) await connection.commit() return {"company_id": company_id, "status": "ACTIVE"} @@ -116,7 +122,9 @@ async def create_company(user_id: int, data: dict[str, Any]) -> dict[str, Any]: raise -async def create_system_company(actor_id: int, data: dict[str, Any]) -> dict[str, Any]: +async def create_system_company( + actor_id: int, data: dict[str, Any], request: Any | None = None +) -> dict[str, Any]: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: try: @@ -135,10 +143,13 @@ async def create_system_company(actor_id: int, data: dict[str, Any]) -> dict[str ), ) company_id = cursor.lastrowid - await cursor.execute( - """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) - VALUES (%s, 'COMPANY_CREATE', 'company', %s)""", - (actor_id, company_id), + await record_audit( + cursor, + actor_id=actor_id, + action="COMPANY_CREATE", + resource_type="company", + resource_ref=company_id, + request=request, ) await connection.commit() return {"company_id": company_id, "status": "ACTIVE"} diff --git a/B01_Dashboard/B01_Dashboard_Router.py b/B01_Dashboard/B01_Dashboard_Router.py index 119651f8..1f4740cf 100644 --- a/B01_Dashboard/B01_Dashboard_Router.py +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -13,6 +13,7 @@ from fastapi import ( HTTPException, Path, Query, + Request, Response, UploadFile, ) @@ -196,10 +197,11 @@ async def user_company_search( @router.post("/user/company/create") async def user_company_create( + request: Request, payload: CreateCompanyRequest, session: dict[str, Any] = Depends(verify_session), ): - result = await create_company(int(session["user_id"]), payload.model_dump()) + result = await create_company(int(session["user_id"]), payload.model_dump(), request) return {"status": "success", **result} @@ -369,6 +371,7 @@ async def admin_projects(session: dict[str, Any] = Depends(require_company_admin @router.put("/projects/{project_id}") async def dashboard_update_project( project_id: str, + request: Request, payload: UpdateProjectRequest, session: dict[str, Any] = Depends(verify_session), ): @@ -387,7 +390,7 @@ async def dashboard_update_project( ) await check_project_refs(int(project["company_id"]), data) member_ids = data.pop("member_user_ids", None) - if not await update_project(project_id, data, int(session["user_id"])): + if not await update_project(project_id, data, int(session["user_id"]), request): raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") if member_ids is not None: # 만든 사람은 늘 참여자로 남는다. @@ -398,6 +401,7 @@ async def dashboard_update_project( @router.delete("/projects/{project_id}") async def dashboard_delete_project( project_id: str, + request: Request, session: dict[str, Any] = Depends(verify_session), ): project = await get_project(project_id) @@ -413,7 +417,7 @@ async def dashboard_delete_project( # 개발 PC에서만 하드 삭제. 배포 기본값은 지금까지처럼 소프트 삭제다. delete_project = hard_delete_project if PROJECT_DELETE_HARD_ENABLED else soft_delete_project - if not await delete_project(project_id, int(session["user_id"])): + if not await delete_project(project_id, int(session["user_id"]), request): raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") return {"status": "success"} @@ -426,10 +430,11 @@ async def system_companies(session: dict[str, Any] = Depends(require_system_admi @router.post("/admin/companies") async def system_create_company( + request: Request, payload: CreateCompanyRequest, session: dict[str, Any] = Depends(require_system_admin), ): - result = await create_company(int(session["user_id"]), payload.model_dump()) + result = await create_company(int(session["user_id"]), payload.model_dump(), request) return {"status": "success", **result} diff --git a/B01_Dashboard/B01_Dashboard_UI_Admin.ts b/B01_Dashboard/B01_Dashboard_UI_Admin.ts index 5bd3cbd4..7aca5024 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Admin.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Admin.ts @@ -80,8 +80,11 @@ function auditTarget(log: AuditLog): string { // 저장값은 소문자(`project`)로 들어온다 — 대문자로 맞춰 찾는다. const key = (log.resource_type ?? "").toUpperCase(); const kind = TARGET_LABELS[key] ?? log.resource_type ?? ""; - if (!kind) return "-"; - return log.resource_id ? `${kind} #${log.resource_id}` : kind; + const reference = log.resource_ref ?? (log.resource_id ? String(log.resource_id) : ""); + if (!kind) return reference || "-"; + // 프로젝트 UUID 는 길어 앞 8자만 — 어느 프로젝트인지 가리기에는 충분하다. + const shortened = reference.length > 12 ? `${reference.slice(0, 8)}…` : reference; + return shortened ? `${kind} ${shortened}` : kind; } const TARGET_LABELS: Record = { @@ -98,12 +101,15 @@ export function auditLogTable(logs: AuditLog[]): HTMLElement { // 관리 버튼 열과 같은 말(「관리」)을 돌려 쓰던 것을 갈랐다 (2026-09-06 사용자 지적). L("B01_Dashboard_Table_Event"), L("B01_Dashboard_Table_Target"), + L("B01_Dashboard_Table_Origin"), L("B01_Dashboard_Table_When"), ], logs.map((log) => [ text(log.email), text(log.action), text(auditTarget(log)), + // 접속 주소 — 기록이 없는 옛 줄은 빈칸으로 남는다 (2026-09-06부터 기록). + text(log.ip_address ?? "-"), // 날짜와 시각을 두 줄로 — 아랫줄이 작은 글씨라 행 높이는 그대로다. stackedCell(formatDate(log.timestamp), formatTime(log.timestamp)), ]), diff --git a/B02_ProjRegister/B02_ProjRegister_Repository.py b/B02_ProjRegister/B02_ProjRegister_Repository.py index 9c3d68b0..4714daa9 100644 --- a/B02_ProjRegister/B02_ProjRegister_Repository.py +++ b/B02_ProjRegister/B02_ProjRegister_Repository.py @@ -9,6 +9,7 @@ from uuid import uuid4 import aiomysql +from common_util.common_util_audit import record_audit from common_util.common_util_json import atomic_write_json from common_util.common_util_storage import PROJECT_STORAGE_LAYOUT_V2 from common_util.common_util_workflow import load_project_workflow @@ -43,6 +44,7 @@ def _initialize_project_storage(project_root: Path, project_id: str) -> None: async def create_project( *, + request: Any | None = None, user_id: int, company_id: int, name: str, @@ -110,10 +112,13 @@ async def create_project( # 워크플로우 단계별 상태 초기화 시드 await initialize_project_stages(cursor, project_id) - await cursor.execute( - """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) - VALUES (%s, 'PROJECT_CREATE', 'project', NULL)""", - (user_id,), + await record_audit( + cursor, + actor_id=user_id, + action="PROJECT_CREATE", + resource_type="project", + resource_ref=project_id, + request=request, ) _initialize_project_storage(project_root, project_id) await connection.commit() diff --git a/B02_ProjRegister/B02_ProjRegister_Router.py b/B02_ProjRegister/B02_ProjRegister_Router.py index f8127d0c..54934c5f 100644 --- a/B02_ProjRegister/B02_ProjRegister_Router.py +++ b/B02_ProjRegister/B02_ProjRegister_Router.py @@ -2,7 +2,7 @@ from typing import Any -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from B01_Dashboard.B01_Dashboard_Repository_Members import check_project_refs from common_util.common_util_auth import require_company @@ -15,6 +15,7 @@ router = APIRouter(prefix="/api/b02", tags=["B02_ProjRegister"]) @router.post("/project", response_model=CreateProjectResponse) async def post_project( + request: Request, payload: CreateProjectRequest, session: dict[str, Any] = Depends(require_company), ) -> CreateProjectResponse: @@ -50,6 +51,7 @@ async def post_project( try: result = await create_project( + request=request, user_id=int(session["user_id"]), company_id=int(company_id), name=payload.name.strip(), diff --git a/common_util/common_util_audit.py b/common_util/common_util_audit.py new file mode 100644 index 00000000..e0dfd3a5 --- /dev/null +++ b/common_util/common_util_audit.py @@ -0,0 +1,84 @@ +"""시스템 로그 기록·정리 한 곳 (2026-09-06 사용자 확정). + +같은 INSERT 문이 여섯 자리에 흩어져 있었고, 대상(무엇에 한 일인가)과 접속 정보(어디서 +했는가)는 칸만 있고 값이 비어 있었다. 기록은 이 함수 하나로 모은다. + +보관 기간은 `AUDIT_LOG_RETENTION_DAYS`(기본 365일) — 사고 추적에 1년이면 충분하다는 +사용자 판단(2026-09-06). 임시 보관함 정리 루프가 돌 때 함께 지운다. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, timezone +from typing import Any + +from config.config_db import get_db_pool +from config.config_system import AUDIT_LOG_RETENTION_DAYS + +logger = logging.getLogger(__name__) + +# 브라우저 문자열은 길다 — 표 칸은 TEXT 지만 화면·로그가 감당할 만큼만 자른다. +_USER_AGENT_MAX = 300 + + +def request_origin(request: Any | None) -> tuple[str | None, str | None]: + """요청에서 접속 주소와 브라우저 문자열을 꺼낸다. 없으면 (None, None). + + 프록시 뒤에서는 `X-Forwarded-For` 의 **첫 주소**가 실제 사용자다. + """ + if request is None: + return None, None + try: + forwarded = request.headers.get("x-forwarded-for") + address = forwarded.split(",")[0].strip() if forwarded else None + if not address and request.client is not None: + address = request.client.host + agent = (request.headers.get("user-agent") or "")[:_USER_AGENT_MAX] or None + return address, agent + except Exception: # 기록이 본 작업을 막으면 안 된다. + return None, None + + +async def record_audit( + cursor: Any, + *, + actor_id: int, + action: str, + resource_type: str | None = None, + resource_ref: str | int | None = None, + request: Any | None = None, +) -> None: + """시스템 로그 한 줄을 적는다 — 호출부의 트랜잭션(cursor)에 얹는다. + + `resource_ref` 는 프로젝트 UUID 처럼 문자열이어도 되고 숫자여도 된다. 숫자면 옛 + `resource_id` 칸에도 같이 넣어 예전 기록과 같은 모양을 지킨다. + """ + reference = None if resource_ref is None else str(resource_ref) + numeric = int(resource_ref) if isinstance(resource_ref, int) else None + address, agent = request_origin(request) + await cursor.execute( + """INSERT INTO system_audit_logs + (user_id, action, resource_type, resource_id, resource_ref, ip_address, user_agent) + VALUES (%s, %s, %s, %s, %s, %s, %s)""", + (actor_id, action, resource_type, numeric, reference, address, agent), + ) + + +async def purge_expired_audit_logs() -> int: + """보관 기간이 지난 시스템 로그를 지운다. 지운 줄 수를 돌려준다.""" + cutoff = datetime.now(timezone.utc) - timedelta(days=max(1, AUDIT_LOG_RETENTION_DAYS)) + pool = get_db_pool() + try: + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute("DELETE FROM system_audit_logs WHERE timestamp < %s", (cutoff,)) + removed = cursor.rowcount + if removed: + await connection.commit() + logger.info( + "시스템 로그 정리: %d건 삭제 (보관 %d일)", removed, AUDIT_LOG_RETENTION_DAYS + ) + return removed + except Exception: + logger.exception("시스템 로그 정리 실패") + return 0 diff --git a/common_util/common_util_project_delete.py b/common_util/common_util_project_delete.py index 3008e4a2..7aa318bb 100644 --- a/common_util/common_util_project_delete.py +++ b/common_util/common_util_project_delete.py @@ -8,14 +8,16 @@ import logging import shutil +from typing import Any +from common_util.common_util_audit import record_audit from common_util.common_util_storage import resolve_project_root_for_delete from config.config_db import get_db_pool logger = logging.getLogger(__name__) -async def hard_delete_project(project_id: str, actor_id: int) -> bool: +async def hard_delete_project(project_id: str, actor_id: int, request: Any | None = None) -> bool: """프로젝트를 DB와 영구저장소에서 완전히 지운다. 되돌릴 수 없다. 자식 테이블은 나열하지 않는다 — `projects.id`를 참조하는 테이블이 전부 @@ -60,10 +62,13 @@ async def hard_delete_project(project_id: str, actor_id: int) -> bool: await connection.rollback() return False # 감사 기록은 프로젝트가 사라진 뒤에도 남는다. resource_id는 FK가 없어 고아가 되지 않는다. - await cursor.execute( - """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) - VALUES (%s, 'PROJECT_HARD_DELETE', 'project', NULL)""", - (actor_id,), + await record_audit( + cursor, + actor_id=actor_id, + action="PROJECT_HARD_DELETE", + resource_type="project", + resource_ref=project_id, + request=request, ) await connection.commit() diff --git a/common_util/common_util_temp_cleanup.py b/common_util/common_util_temp_cleanup.py index cc81af03..d864c547 100644 --- a/common_util/common_util_temp_cleanup.py +++ b/common_util/common_util_temp_cleanup.py @@ -14,6 +14,7 @@ from B03_FileInput.B03_FileInput_Repository_Temp import ( delete_temp_batch, list_expired_temp_batches, ) +from common_util.common_util_audit import purge_expired_audit_logs from common_util.common_util_storage import resolve_temp_batch_path, temp_upload_root from config.config_db import get_db_pool from config.config_system import ( @@ -81,4 +82,6 @@ async def cleanup_expired_temp_uploads_loop() -> None: removed = await cleanup_expired_temp_uploads() if removed: logger.info("임시 보관함 정리 완료: %d건 삭제", removed) + # 시스템 로그 보관 기간 정리도 같은 주기에 얹는다 — 루프를 따로 두지 않는다. + await purge_expired_audit_logs() await asyncio.sleep(interval_seconds) diff --git a/config/config_system.py b/config/config_system.py index 701e782e..1d29b57c 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -98,6 +98,8 @@ CHUNK_RETENTION_HOURS = int(os.getenv("CHUNK_RETENTION_HOURS", "24")) # 운영하며 조정할 값이라 여기서 관리한다(2026-08-08 사용자 지시). TEMP_UPLOAD_DIR_NAME = os.getenv("TEMP_UPLOAD_DIR_NAME", "tmp") TEMP_UPLOAD_RETENTION_DAYS = int(os.getenv("TEMP_UPLOAD_RETENTION_DAYS", "30")) +# 시스템 로그(누가 무엇을 했나) 보관 기간 — 사고 추적에 1년 (2026-09-06 사용자 확정). +AUDIT_LOG_RETENTION_DAYS = int(os.getenv("AUDIT_LOG_RETENTION_DAYS", "365")) TEMP_UPLOAD_CLEANUP_INTERVAL_HOURS = int(os.getenv("TEMP_UPLOAD_CLEANUP_INTERVAL_HOURS", "6")) MERGE_TIMEOUT_SECONDS = int(os.getenv("MERGE_TIMEOUT_SECONDS", "3600")) SEND_ANALYSIS_COMPLETION_EMAIL = ( diff --git a/db_management/017_audit_log_detail.sql b/db_management/017_audit_log_detail.sql new file mode 100644 index 00000000..8afbbed2 --- /dev/null +++ b/db_management/017_audit_log_detail.sql @@ -0,0 +1,13 @@ +-- 017_audit_log_detail.sql +-- 시스템 로그에 「무엇에」 한 일인지와 「어디서」 했는지를 남긴다 (2026-09-06 사용자 확정). +-- +-- 기존 `resource_id` 는 INT 라 프로젝트 id(UUID 문자열)를 담지 못해 늘 NULL 로 들어갔다. +-- 문자 칸을 따로 두어 프로젝트·회사·사용자 어느 쪽이든 그대로 적는다. +-- IP·브라우저 칸(`ip_address`·`user_agent`)은 004 에서 이미 만들어 두었으나 값을 넣는 +-- 코드가 없었다 — 이 마이그레이션 뒤부터 기록한다. + +ALTER TABLE system_audit_logs + ADD COLUMN IF NOT EXISTS resource_ref VARCHAR(64) NULL + COMMENT '대상 식별자 (프로젝트 UUID 등 문자열). 숫자 대상은 resource_id 와 같이 채운다'; + +-- 보관 기간(기본 365일) 정리가 날짜로 훑으므로 인덱스는 004 의 timestamp 인덱스를 그대로 쓴다. diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index e12e1569..69fe4d01 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -138,6 +138,7 @@ export const ui_locales_b1 = { B01_Dashboard_Table_Action: ["관리", "Action"], B01_Dashboard_Table_Event: ["동작", "Event"], B01_Dashboard_Table_Target: ["대상", "Target"], + B01_Dashboard_Table_Origin: ["접속 주소", "From"], B01_Dashboard_Table_When: ["일시", "When"], B01_Dashboard_Table_Owner: ["소유자", "Owner"], B01_Dashboard_Field_BusinessNumber: ["사업자등록번호", "Business number"],