zioinfo-mail/workspace/guardia-itsm/routers/external_api.py
DESKTOP-TKLFCPR\ython cfe2901a55 refactor(structure): consolidate all projects under workspace/
- 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>
2026-05-31 23:50:56 +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이 등록되었습니다."}