Files
Aislo/common_util/common_util_audit.py
T
eomsangdonandClaude Opus 5 4066504ba9 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>
2026-09-06 18:57:22 +09:00

85 lines
3.4 KiB
Python

"""시스템 로그 기록·정리 한 곳 (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