""" D-5: 불변 감사 로그 (Immutable Audit Log with Hash Chain) 기능: 1. SHA-256 해시 체인 — 각 로그가 이전 로그의 해시를 포함 2. 체인 무결성 검증 — 임의 변조 즉시 탐지 3. 범용 감사 기록 — SR, RFC, PAM, LDAP, 취약점 등 모든 이벤트 4. 심각도 분류 — INFO / WARN / ERROR / CRITICAL 5. 기간별 체인 스냅샷 — 날짜 기준 체인 분리 검증 6. 감사 로그 내보내기 — CSV/JSON (관리자 전용) 7. IP 해시 저장 — 원본 IP 저장 금지, SHA-256만 보관 엔드포인트: GET /api/audit — 감사 로그 목록 POST /api/audit/record — 감사 이벤트 수동 기록 GET /api/audit/verify — 해시 체인 무결성 검증 GET /api/audit/verify/{from_id}/{to_id} — 범위 검증 GET /api/audit/stats — 감사 통계 GET /api/audit/export — 로그 내보내기 (ADMIN) GET /api/audit/{log_id} — 단일 로그 상세 GET /api/audit/entity/{entity_type}/{entity_id} — 엔티티별 감사 이력 보안: - 로그 수정/삭제 절대 불가 (hash chain으로 변조 탐지) - IP 주소는 SHA-256 해시로만 저장 - 스택트레이스 노출 금지 """ from __future__ import annotations import csv import hashlib import io import json import logging from datetime import datetime, timedelta from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import StreamingResponse from pydantic import BaseModel from sqlalchemy import select, and_, func, desc from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user from database import get_db from models import AuditLog, AuditLogOut, User, UserRole, compute_log_hash logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/audit", tags=["audit"]) # ── 심각도 정의 ─────────────────────────────────────────────────────────────── SEVERITY_LEVELS = {"INFO": 0, "WARN": 1, "ERROR": 2, "CRITICAL": 3} # ── 해시 체인 헬퍼 ──────────────────────────────────────────────────────────── async def _get_last_hash(db: AsyncSession) -> Optional[str]: """마지막 감사 로그 해시 조회 (체인 연결용).""" last = (await db.execute( select(AuditLog.log_hash).order_by(AuditLog.id.desc()).limit(1) )).scalar() return last async def append_audit_log( db: AsyncSession, actor: str, action: str, detail: str = "", sr_id: Optional[str] = None, entity_type: Optional[str] = None, entity_id: Optional[str] = None, severity: str = "INFO", client_ip: Optional[str] = None, ) -> AuditLog: """ 감사 로그 체인에 새 항목 추가. 이전 로그의 hash를 포함하여 불변 체인 형성. """ prev_hash = await _get_last_hash(db) now = datetime.utcnow() ts = now.isoformat() # IP는 SHA-256 해시로만 저장 (원본 절대 금지) ip_hash = hashlib.sha256(client_ip.encode()).hexdigest() if client_ip else None log_hash = compute_log_hash(prev_hash, actor, action, detail, ts) log = AuditLog( sr_id = sr_id, actor = actor, action = action, detail = detail, prev_hash = prev_hash, log_hash = log_hash, created_at = now, entity_type = entity_type, entity_id = entity_id, severity = severity, ip_addr_hash = ip_hash, ) db.add(log) await db.flush() return log # ── Pydantic 스키마 ────────────────────────────────────────────────────────── class AuditRecordIn(BaseModel): action: str detail: Optional[str] = "" sr_id: Optional[str] = None entity_type: Optional[str] = None entity_id: Optional[str] = None severity: str = "INFO" class ChainVerifyResult(BaseModel): total: int intact: bool broken_at_id: Optional[int] verified_at: str chain_start: Optional[int] chain_end: Optional[int] # ── 엔드포인트 ─────────────────────────────────────────────────────────────── @router.get("", response_model=List[AuditLogOut]) async def list_audit_logs( sr_id: Optional[str] = Query(None), entity_type: Optional[str] = Query(None), entity_id: Optional[str] = Query(None), actor: Optional[str] = Query(None), severity: Optional[str] = Query(None), action: Optional[str] = Query(None), from_dt: Optional[str] = Query(None, description="ISO 날짜 (예: 2024-01-01)"), to_dt: Optional[str] = Query(None), skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=500), db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """감사 로그 목록 조회 (최신순).""" conditions = [] if sr_id: conditions.append(AuditLog.sr_id == sr_id) if entity_type: conditions.append(AuditLog.entity_type == entity_type.upper()) if entity_id: conditions.append(AuditLog.entity_id == entity_id) if actor: conditions.append(AuditLog.actor.ilike(f"%{actor}%")) if severity: conditions.append(AuditLog.severity == severity.upper()) if action: conditions.append(AuditLog.action.ilike(f"%{action}%")) if from_dt: try: conditions.append(AuditLog.created_at >= datetime.fromisoformat(from_dt)) except ValueError: raise HTTPException(400, f"from_dt 형식 오류: {from_dt}") if to_dt: try: conditions.append(AuditLog.created_at <= datetime.fromisoformat(to_dt)) except ValueError: raise HTTPException(400, f"to_dt 형식 오류: {to_dt}") q = ( select(AuditLog) .where(and_(*conditions) if conditions else True) .order_by(AuditLog.id.desc()) .offset(skip).limit(limit) ) return (await db.execute(q)).scalars().all() @router.post("/record", status_code=201) async def record_event( body: AuditRecordIn, request: Request, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """감사 이벤트 수동 기록 (API / 내부 시스템 호출).""" if body.severity.upper() not in SEVERITY_LEVELS: raise HTTPException(400, f"유효하지 않은 심각도: {body.severity}") # 클라이언트 IP 수집 (해시로만 저장) client_ip = request.client.host if request.client else None log = await append_audit_log( db, actor = current_user.username, action = body.action, detail = body.detail or "", sr_id = body.sr_id, entity_type = body.entity_type, entity_id = body.entity_id, severity = body.severity.upper(), client_ip = client_ip, ) await db.commit() return { "log_id": log.id, "log_hash": log.log_hash, "prev_hash": log.prev_hash, "action": log.action, "created_at": log.created_at.isoformat(), "message": "감사 로그가 체인에 추가되었습니다.", } @router.get("/verify", response_model=ChainVerifyResult) async def verify_chain( from_id: Optional[int] = Query(None), to_id: Optional[int] = Query(None), db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """SHA-256 해시 체인 무결성 전체 검증.""" q = select(AuditLog).order_by(AuditLog.id) if from_id: q = q.where(AuditLog.id >= from_id) if to_id: q = q.where(AuditLog.id <= to_id) logs = (await db.execute(q)).scalars().all() broken_at: Optional[int] = None prev_hash_expected: Optional[str] = None for i, log in enumerate(logs): # 1) 체인 연결 검증 (prev_hash가 이전 로그의 log_hash와 일치해야 함) if i == 0: # 첫 로그: from_id가 없으면 prev_hash는 None이어야 함 if from_id is None and log.prev_hash is not None: broken_at = log.id break else: if log.prev_hash != prev_hash_expected: broken_at = log.id break # 2) log_hash 재계산 검증 expected = compute_log_hash( log.prev_hash, log.actor or "", log.action, log.detail or "", log.created_at.isoformat() if log.created_at else "", ) if expected != log.log_hash: broken_at = log.id break prev_hash_expected = log.log_hash return ChainVerifyResult( total = len(logs), intact = broken_at is None, broken_at_id = broken_at, verified_at = datetime.utcnow().isoformat(), chain_start = logs[0].id if logs else None, chain_end = logs[-1].id if logs else None, ) @router.get("/verify/{from_id}/{to_id}", response_model=ChainVerifyResult) async def verify_chain_range( from_id: int, to_id: int, db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """특정 ID 범위 해시 체인 검증.""" if from_id > to_id: raise HTTPException(400, "from_id는 to_id보다 작아야 합니다.") return await verify_chain(from_id, to_id, db, _u) @router.get("/stats") async def audit_stats( days: int = Query(30, ge=1, le=365), db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """감사 로그 통계.""" since = datetime.utcnow() - timedelta(days=days) total = (await db.execute( select(func.count()).select_from(AuditLog) )).scalar() or 0 recent = (await db.execute( select(func.count()).select_from(AuditLog) .where(AuditLog.created_at >= since) )).scalar() or 0 # 심각도별 집계 sev_rows = (await db.execute( select(AuditLog.severity, func.count()) .where(AuditLog.created_at >= since) .group_by(AuditLog.severity) )).all() by_severity = {row[0] or "INFO": row[1] for row in sev_rows} # 엔티티 유형별 집계 ent_rows = (await db.execute( select(AuditLog.entity_type, func.count()) .where(AuditLog.created_at >= since) .group_by(AuditLog.entity_type) )).all() by_entity = {row[0] or "UNKNOWN": row[1] for row in ent_rows} # 최근 CRITICAL/ERROR 이벤트 alerts = (await db.execute( select(AuditLog) .where(and_( AuditLog.created_at >= since, AuditLog.severity.in_(["CRITICAL", "ERROR"]), )) .order_by(AuditLog.id.desc()) .limit(5) )).scalars().all() return { "total_logs": total, "recent_logs": recent, "period_days": days, "by_severity": by_severity, "by_entity": by_entity, "recent_alerts": [ {"id": a.id, "action": a.action, "severity": a.severity, "actor": a.actor, "created_at": a.created_at.isoformat()} for a in alerts ], } @router.get("/export") async def export_audit_logs( from_dt: Optional[str] = Query(None), to_dt: Optional[str] = Query(None), fmt: str = Query("json", enum=["json", "csv"]), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """감사 로그 내보내기 (ADMIN 전용).""" if current_user.role != UserRole.ADMIN: raise HTTPException(403, "ADMIN 권한이 필요합니다.") conditions = [] if from_dt: try: conditions.append(AuditLog.created_at >= datetime.fromisoformat(from_dt)) except ValueError: raise HTTPException(400, f"from_dt 형식 오류: {from_dt}") if to_dt: try: conditions.append(AuditLog.created_at <= datetime.fromisoformat(to_dt)) except ValueError: raise HTTPException(400, f"to_dt 형식 오류: {to_dt}") q = ( select(AuditLog) .where(and_(*conditions) if conditions else True) .order_by(AuditLog.id) .limit(10000) ) logs = (await db.execute(q)).scalars().all() if fmt == "csv": output = io.StringIO() writer = csv.writer(output) writer.writerow(["id", "created_at", "actor", "action", "detail", "entity_type", "entity_id", "severity", "log_hash", "prev_hash"]) for log in logs: writer.writerow([ log.id, log.created_at.isoformat() if log.created_at else "", log.actor or "", log.action, log.detail or "", log.entity_type or "", log.entity_id or "", log.severity or "INFO", log.log_hash or "", log.prev_hash or "", ]) output.seek(0) return StreamingResponse( iter([output.getvalue()]), media_type="text/csv", headers={"Content-Disposition": "attachment; filename=audit_log.csv"}, ) else: data = [ { "id": log.id, "created_at": log.created_at.isoformat() if log.created_at else None, "actor": log.actor, "action": log.action, "detail": log.detail, "entity_type": log.entity_type, "entity_id": log.entity_id, "severity": log.severity, "log_hash": log.log_hash, "prev_hash": log.prev_hash, } for log in logs ] return StreamingResponse( iter([json.dumps(data, ensure_ascii=False, default=str)]), media_type="application/json", headers={"Content-Disposition": "attachment; filename=audit_log.json"}, ) @router.get("/entity/{entity_type}/{entity_id}", response_model=List[AuditLogOut]) async def get_entity_audit_trail( entity_type: str, entity_id: str, limit: int = Query(50, ge=1, le=200), db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """특정 엔티티(SR, RFC, PAM 세션 등)의 전체 감사 이력.""" q = ( select(AuditLog) .where(and_( AuditLog.entity_type == entity_type.upper(), AuditLog.entity_id == entity_id, )) .order_by(AuditLog.id.desc()) .limit(limit) ) return (await db.execute(q)).scalars().all() @router.get("/{log_id}", response_model=AuditLogOut) async def get_audit_log( log_id: int, db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """단일 감사 로그 상세 (해시 포함).""" log = (await db.execute( select(AuditLog).where(AuditLog.id == log_id) )).scalars().first() if not log: raise HTTPException(404, f"감사 로그 {log_id}를 찾을 수 없습니다.") return log