zioinfo-mail/itsm/routers/external_api.py
DESKTOP-TKLFCPR\ython 11c670f2a0 refactor: 101.79.17.164 → zioinfo.co.kr 전체 도메인 변환 + Manager UI 배포
- 37개 파일 IP → zioinfo.co.kr 치환 (소스/매뉴얼/설정/하네스)
- Manager DrConsole/NetworkConsole/CsapConsole 빌드 + /var/www/manager/ 배포
- 테스트: Manager HTTP 200, ITSM 신규 API 7개 전체 200

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 10:09:17 +09:00

262 lines
8.9 KiB
Python

"""
개방망 외부 API 라우터
- /api/external/keys : API Key CRUD (관리자 JWT 인증)
- /api/external/health : 공개 헬스체크
- /api/external/webhook : 외부 메신저 웹훅 수신
- /api/external/sr : 외부에서 SR 조회·등록 (API Key)
- /api/external/notify : 외부 알림 전송 (API Key)
- /api/external/status : 시스템 상태 공개 요약
"""
import hashlib
import hmac
import json
import os
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Request, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.external_security import generate_api_key, hash_key, require_scope, verify_api_key
from core.auth import get_current_user
from database import get_db
from models import APIKey, SRRequest as ServiceRequest, User
router = APIRouter(prefix="/api/external", tags=["External API (개방망)"])
# ── 공개 엔드포인트 ───────────────────────────────────────────────────────────
@router.get("/health", summary="헬스체크 (인증 불필요)")
async def health():
return {
"status": "ok",
"service": "GUARDiA ITSM",
"version": "2.0.0",
"timestamp": datetime.utcnow().isoformat(),
}
@router.get("/status", summary="시스템 공개 상태")
async def public_status(db: AsyncSession = Depends(get_db)):
"""인증 없이 시스템 가동 상태를 반환합니다."""
try:
sr_count = (await db.execute(
select(ServiceRequest).where(ServiceRequest.status != "CLOSED")
)).scalars().all()
open_sr = len(sr_count)
except Exception:
open_sr = -1
return {
"status": "operational",
"open_service_requests": open_sr,
"api_version": "v2",
"docs": "/docs",
}
# ── API Key 관리 (관리자 전용) ────────────────────────────────────────────────
class APIKeyCreate(BaseModel):
name: str
scopes: str = "read" # read, write, admin, webhook (쉼표 구분)
allowed_ips: Optional[str] = None # "1.2.3.4,5.6.7.8" (빈 문자열 = 전체 허용)
expires_days: Optional[int] = 365
@router.get("/keys", summary="API Key 목록 (관리자)")
async def list_keys(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if current_user.role not in ("admin", "pm"):
raise HTTPException(status_code=403, detail="관리자 권한이 필요합니다.")
rows = (await db.execute(select(APIKey).order_by(APIKey.created_at.desc()))).scalars().all()
return [
{
"id": k.id,
"name": k.name,
"scopes": k.scopes,
"allowed_ips": k.allowed_ips,
"is_active": k.is_active,
"use_count": k.use_count,
"last_used_at": k.last_used_at,
"expires_at": k.expires_at,
"created_at": k.created_at,
}
for k in rows
]
@router.post("/keys", summary="API Key 발급 (관리자)", status_code=201)
async def create_key(
body: APIKeyCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if current_user.role not in ("admin", "pm"):
raise HTTPException(status_code=403, detail="관리자 권한이 필요합니다.")
plain, key_hash = generate_api_key()
expires_at = None
if body.expires_days:
expires_at = datetime.utcnow() + timedelta(days=body.expires_days)
api_key = APIKey(
name=body.name,
key_hash=key_hash,
scopes=body.scopes,
allowed_ips=body.allowed_ips or "",
expires_at=expires_at,
created_by=current_user.username,
is_active=True,
use_count=0,
)
db.add(api_key)
await db.commit()
await db.refresh(api_key)
return {
"id": api_key.id,
"name": api_key.name,
"api_key": plain, # ← 단 1회만 노출
"scopes": api_key.scopes,
"expires_at": api_key.expires_at,
"warning": "이 키는 다시 조회할 수 없습니다. 안전한 곳에 저장하세요.",
}
@router.delete("/keys/{key_id}", summary="API Key 비활성화")
async def revoke_key(
key_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if current_user.role not in ("admin", "pm"):
raise HTTPException(status_code=403, detail="관리자 권한이 필요합니다.")
row = await db.get(APIKey, key_id)
if not row:
raise HTTPException(status_code=404, detail="키를 찾을 수 없습니다.")
row.is_active = False
await db.commit()
return {"message": f"API Key '{row.name}' 비활성화 완료"}
# ── 외부 메신저 웹훅 ─────────────────────────────────────────────────────────
WEBHOOK_SECRET = os.environ.get("GUARDIA_WEBHOOK_SECRET", "guardia-webhook-2026")
def _verify_hmac(body: bytes, signature: str) -> bool:
expected = hmac.new(WEBHOOK_SECRET.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature.removeprefix("sha256="))
@router.post("/webhook", summary="외부 메신저 웹훅 수신")
async def receive_webhook(
request: Request,
x_guardia_signature: Optional[str] = Header(None, alias="X-GUARDiA-Signature"),
x_source: str = Header("unknown", alias="X-Source"),
db: AsyncSession = Depends(get_db),
):
"""
카카오워크/네이버웍스/Slack/Teams 등 외부 메신저에서
GUARDiA 명령을 전달받는 엔드포인트.
Headers:
X-GUARDiA-Signature: sha256=<hmac> (선택, 권장)
X-Source: kakaotalk | naverworks | slack | teams | custom
"""
body = await request.body()
# HMAC 검증 (서명이 있는 경우만 강제)
if x_guardia_signature:
if not _verify_hmac(body, x_guardia_signature):
raise HTTPException(status_code=401, detail="웹훅 서명 검증 실패")
try:
payload = json.loads(body)
except Exception:
raise HTTPException(status_code=400, detail="JSON 파싱 오류")
command = payload.get("command") or payload.get("text") or payload.get("message", "")
user_id = payload.get("user_id") or payload.get("sender") or "external"
# AI 자연어 명령 파싱 후 처리
try:
from core.nlu import parse_command
parsed = await parse_command(command, db)
action = parsed.get("action", "unknown")
except Exception:
parsed = {"action": "echo", "raw": command}
action = "echo"
return {
"received": True,
"source": x_source,
"command": command[:200],
"action": action,
"message": f"[GUARDiA] 명령 수신됨: {command[:100]}",
"timestamp": datetime.utcnow().isoformat(),
}
# ── 외부 SR 인터페이스 (API Key 인증) ────────────────────────────────────────
class ExternalSRCreate(BaseModel):
title: str
description: str
priority: str = "MEDIUM" # LOW | MEDIUM | HIGH | CRITICAL
requester_name: str
requester_email: str = ""
@router.get("/sr", summary="SR 목록 조회 (API Key read 권한)")
async def external_list_sr(
status_filter: Optional[str] = None,
limit: int = 20,
db: AsyncSession = Depends(get_db),
api_key=Depends(require_scope("read")),
):
q = select(ServiceRequest).limit(limit).order_by(ServiceRequest.created_at.desc())
if status_filter:
q = q.where(ServiceRequest.status == status_filter)
rows = (await db.execute(q)).scalars().all()
return [
{
"id": r.id,
"sr_id": r.sr_id,
"title": r.title,
"status": r.status,
"priority": r.priority,
"created_at": r.created_at,
}
for r in rows
]
@router.post("/sr", summary="SR 등록 (API Key write 권한)", status_code=201)
async def external_create_sr(
body: ExternalSRCreate,
db: AsyncSession = Depends(get_db),
api_key=Depends(require_scope("write")),
):
import secrets as _s
sr_id = f"EXT-{_s.token_hex(4).upper()}"
sr = ServiceRequest(
sr_id=sr_id,
title=body.title,
description=body.description,
priority=body.priority,
status="RECEIVED",
requested_by=f"{body.requester_name} ({body.requester_email})",
sr_type="OTHER",
)
db.add(sr)
await db.commit()
await db.refresh(sr)
return {"id": sr.id, "sr_id": sr.sr_id, "title": sr.title, "status": sr.status, "message": "SR이 등록되었습니다."}