- 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>
262 lines
8.9 KiB
Python
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이 등록되었습니다."}
|