guardia-itsm/routers/audit.py
DESKTOP-TKLFCPRython 64c27c3509 feat(itsm): G-1~G-12 확장 기능 + 하네스/봇/설치스크립트 구현
G-1: 메신저 Webhook Relay + _send_to_room 실제 httpx 호출 구현
G-2: POST /api/tasks/bulk SR 대량작업 엔드포인트 (최대 100건)
G-3: 라이선스 만료 알림 스케줄러 (매일 09:00 KST)
G-4: 체험판 upgrade_banner 필드 + license.py 배너 로직
G-5: core/auto_rca.py + incidents/problem auto-rca 엔드포인트
G-6: core/deploy_impact.py + vibe impact-analysis 엔드포인트
G-7: core/ticket_classifier.py + SR 생성 시 AI 분류 + ai-suggestion API
G-8: VulnPatchRecord 모델 + vuln_scan 패치추적 4개 엔드포인트
G-9: core/jira_sync.py + gateway Jira/Confluence 연동 엔드포인트
G-10: core/push_notify.py + routers/push.py + PushSubscription 모델
G-11: approvals 다중승인 (위임/서명/기한초과/마감연장)
G-12: alembic.ini + migrations/ + cicd/migrate_to_postgres.sh

하네스: guardia-orchestrator 확장기능 Phase 반영
봇명령어: /sr /status /license /bulk 슬래시 명령어 추가
설치스크립트: setup/ (Ubuntu, CentOS, RHEL, Windows) --test 옵션 포함

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:18:52 +09:00

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