""" 개방망 외부 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= (선택, 권장) 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이 등록되었습니다."}