- itsm/ -> workspace/guardia-itsm/ - manager/ -> workspace/guardia-manager/ - app/ -> workspace/guardia-messenger/ - manual/ -> workspace/guardia-docs/ workspace/zioinfo-web/ unchanged. git mv preserves full commit history. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
450 lines
15 KiB
Python
450 lines
15 KiB
Python
"""
|
|
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
|