feat(B01): 시스템 로그에 대상·접속 정보 기록 + 1년 보관 정리 (2026-09-06 사용자 확정)

- 기록을 `common_util_audit.record_audit()` 한 곳으로 모음 — 여섯 자리에 흩어져 있던
  raw INSERT 제거
- 대상 식별자 칸 신설(`017_audit_log_detail.sql`, 적용 완료) — 기존 `resource_id` 는 INT 라
  프로젝트 UUID 를 못 담아 늘 NULL 이었음. 프로젝트 생성·수정·삭제·회사 생성이 대상을 남김
- 접속 주소·브라우저 기록 — 라우터가 요청을 넘겨 주고, 프록시 뒤에서는 X-Forwarded-For 우선
- 보관 기간 `AUDIT_LOG_RETENTION_DAYS` 기본 365일, 임시 보관함 정리 루프에 얹어 함께 정리
- 화면: 시스템 로그 표에 대상·접속 주소 열 추가(대상은 UUID 앞 8자만)

자체검증 — 프로젝트 생성·하드삭제를 실화면에서 돌려 두 줄 모두
`프로젝트 e1040640… · 127.0.0.1 · 2026-09-06 09:54/09:55` 로 남는 것 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-06 18:57:22 +09:00
co-authored by Claude Opus 5
parent d50956381d
commit 4066504ba9
13 changed files with 186 additions and 37 deletions
+3
View File
@@ -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;
}
+20 -11
View File
@@ -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),
@@ -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"}
+9 -4
View File
@@ -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}
+8 -2
View File
@@ -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<string, string> = {
@@ -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)),
]),
@@ -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()
+3 -1
View File
@@ -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(),
+84
View File
@@ -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
+10 -5
View File
@@ -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()
+3
View File
@@ -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)
+2
View File
@@ -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 = (
+13
View File
@@ -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 인덱스를 그대로 쓴다.
+1
View File
@@ -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"],