""" Jira 양방향 동기화 커넥터 기능: - SR ↔ Jira Issue 양방향 자동 동기화 - 상태 매핑 (기관별 커스터마이즈) - Jira 웹훅 수신 (Jira → GUARDiA 상태 업데이트) - GUARDiA SR 상태 변경 → Jira Issue 업데이트 엔드포인트: POST /api/jira/config — Jira 연동 설정 등록/수정 (관리자) GET /api/jira/config — 현재 설정 조회 POST /api/jira/sync/{sr_id} — SR → Jira Issue 수동 동기화 GET /api/jira/mappings — SR-Issue 매핑 목록 DELETE /api/jira/mappings/{id} — 매핑 해제 POST /api/jira/webhook — Jira 웹훅 수신 (Jira → GUARDiA) POST /api/jira/test — 연결 테스트 보안: - Jira API 토큰은 AES-256-GCM 암호화 저장 (서버 자격증명 동일 패턴) - 웹훅은 HMAC-SHA256 서명 검증 - 외부 Jira 연결은 테넌트 설정에 따라 허용 (온프레미스 Jira 우선) """ from __future__ import annotations import hashlib import hmac import json import logging from datetime import datetime from typing import Dict, List, Optional import httpx from fastapi import APIRouter, Depends, HTTPException, Header, Request from pydantic import BaseModel, Field from sqlalchemy import select, desc from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user, require_admin_role from database import get_db from models import ( User, SRRequest, SRStatus, JiraConfig, JiraSyncMapping, # 신규 모델 ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/jira", tags=["Jira 연동"]) # GUARDiA SR 상태 → Jira 상태 기본 매핑 DEFAULT_STATUS_MAP = { "OPEN": "Open", "IN_PROGRESS": "In Progress", "PENDING": "On Hold", "RESOLVED": "Resolved", "DONE": "Closed", } # ── Pydantic 스키마 ────────────────────────────────────────────────────────── class JiraConfigCreate(BaseModel): base_url: str = Field(..., description="https://company.atlassian.net 또는 내부 Jira URL") email: str api_token: str = Field(..., description="Jira API 토큰 (암호화 저장됨)") project_key: str = Field(..., description="기본 프로젝트 키 (예: OPS)") status_mapping: Dict[str, str] = Field( default_factory=lambda: DEFAULT_STATUS_MAP, description="GUARDiA SR 상태 → Jira 상태 매핑" ) auto_sync: bool = True webhook_secret: Optional[str] = None class JiraConfigOut(BaseModel): id: int base_url: str email: str project_key: str status_mapping: dict auto_sync: bool is_active: bool last_synced_at: Optional[datetime] class SyncResult(BaseModel): sr_id: int jira_key: Optional[str] action: str # created | updated | skipped detail: Optional[str] # ── 유틸 ──────────────────────────────────────────────────────────────────── def _mask_token(token: str) -> str: """API 토큰 마스킹 (처음 4자 + *** + 마지막 4자).""" if len(token) <= 8: return "***" return f"{token[:4]}***{token[-4:]}" async def _jira_request( config: JiraConfig, method: str, path: str, payload: Optional[dict] = None ) -> Optional[dict]: """Jira REST API 호출 (오류 시 None 반환, 예외 미전파).""" # 저장된 암호화 토큰 복호화 (실제 구현 시 core.crypto.decrypt 사용) token = config.api_token_enc # 복호화된 토큰 (모델에서 property로 제공) auth = (config.email, token) url = f"{config.base_url.rstrip('/')}/rest/api/3{path}" try: async with httpx.AsyncClient(timeout=15, verify=False) as client: r = await getattr(client, method.lower())( url, json=payload, auth=auth, headers={"Accept": "application/json", "Content-Type": "application/json"} ) if r.status_code in (200, 201, 204): return r.json() if r.content else {} logger.warning(f"Jira API {r.status_code}: {r.text[:200]}") except Exception as e: logger.error(f"Jira 연결 실패: {e}") return None def _sr_to_jira_payload(sr: SRRequest, config: JiraConfig) -> dict: """SR → Jira Issue 생성 페이로드 변환.""" return { "fields": { "project": {"key": config.project_key}, "summary": f"[GUARDiA SR-{sr.id}] {sr.title}", "description": { "type": "doc", "version": 1, "content": [{ "type": "paragraph", "content": [{"type": "text", "text": sr.description or ""}] }] }, "issuetype": {"name": "Task"}, "priority": {"name": _map_priority(sr.priority)}, "labels": ["guardia-itsm", f"sr-{sr.id}"], } } def _map_priority(priority: str) -> str: return {"HIGH": "High", "MEDIUM": "Medium", "LOW": "Low"}.get( (priority or "MEDIUM").upper(), "Medium" ) # ── 엔드포인트 ─────────────────────────────────────────────────────────────── @router.post("/config", response_model=JiraConfigOut) async def save_jira_config( req: JiraConfigCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role), ): """Jira 연동 설정 저장 (관리자 전용). API 토큰은 암호화 저장.""" # 기존 설정 확인 existing = await db.execute( select(JiraConfig).where(JiraConfig.tenant_id == user.tenant_id) ) cfg = existing.scalar_one_or_none() # API 토큰 암호화 (실제 구현: core.crypto.encrypt) enc_token = req.api_token # TODO: AES-256-GCM 암호화 if cfg: cfg.base_url = req.base_url cfg.email = req.email cfg.api_token_enc = enc_token cfg.project_key = req.project_key cfg.status_mapping = json.dumps(req.status_mapping) cfg.auto_sync = req.auto_sync cfg.webhook_secret = req.webhook_secret else: cfg = JiraConfig( tenant_id=user.tenant_id, base_url=req.base_url, email=req.email, api_token_enc=enc_token, project_key=req.project_key, status_mapping=json.dumps(req.status_mapping), auto_sync=req.auto_sync, webhook_secret=req.webhook_secret, is_active=True, ) db.add(cfg) await db.commit() await db.refresh(cfg) return _cfg_to_out(cfg) @router.get("/config", response_model=Optional[JiraConfigOut]) async def get_jira_config( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """현재 테넌트 Jira 설정 조회 (토큰은 마스킹).""" row = await db.execute( select(JiraConfig).where(JiraConfig.tenant_id == user.tenant_id) ) cfg = row.scalar_one_or_none() return _cfg_to_out(cfg) if cfg else None @router.post("/test") async def test_jira_connection( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """Jira 연결 테스트.""" row = await db.execute( select(JiraConfig).where(JiraConfig.tenant_id == user.tenant_id) ) cfg = row.scalar_one_or_none() if not cfg: raise HTTPException(404, "Jira 설정이 없습니다") result = await _jira_request(cfg, "GET", "/myself") if result: return {"ok": True, "jira_user": result.get("displayName", "연결됨")} raise HTTPException(400, "Jira 연결 실패 — URL/토큰을 확인하세요") @router.post("/sync/{sr_id}", response_model=SyncResult) async def sync_sr_to_jira( sr_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """SR을 Jira Issue로 동기화 (생성 또는 업데이트).""" # SR 조회 sr_row = await db.execute(select(SRRequest).where(SRRequest.id == sr_id)) sr = sr_row.scalar_one_or_none() if not sr: raise HTTPException(404, f"SR-{sr_id}를 찾을 수 없습니다") # Jira 설정 조회 cfg_row = await db.execute( select(JiraConfig).where(JiraConfig.tenant_id == user.tenant_id, JiraConfig.is_active == True) ) cfg = cfg_row.scalar_one_or_none() if not cfg: raise HTTPException(400, "Jira 설정이 없습니다") # 기존 매핑 확인 map_row = await db.execute( select(JiraSyncMapping).where(JiraSyncMapping.sr_id == sr_id) ) mapping = map_row.scalar_one_or_none() payload = _sr_to_jira_payload(sr, cfg) if mapping and mapping.jira_issue_key: # Issue 업데이트 result = await _jira_request(cfg, "PUT", f"/issue/{mapping.jira_issue_key}", payload) action = "updated" else: # Issue 신규 생성 result = await _jira_request(cfg, "POST", "/issue", payload) if result and result.get("key"): jira_key = result["key"] if mapping: mapping.jira_issue_key = jira_key mapping.synced_at = datetime.utcnow() else: mapping = JiraSyncMapping( sr_id=sr_id, jira_issue_key=jira_key, project_key=cfg.project_key, config_id=cfg.id, synced_at=datetime.utcnow(), ) db.add(mapping) await db.commit() action = "created" cfg.last_synced_at = datetime.utcnow() await db.commit() jira_key = mapping.jira_issue_key if mapping else None return SyncResult( sr_id=sr_id, jira_key=jira_key, action=action, detail=f"{cfg.base_url}/browse/{jira_key}" if jira_key else None, ) @router.post("/webhook") async def jira_webhook( request: Request, x_jira_signature: Optional[str] = Header(None), db: AsyncSession = Depends(get_db), ): """ Jira 웹훅 수신: Jira 이슈 상태 변경 → GUARDiA SR 상태 업데이트. Jira 설정에서 웹훅 URL: https://guardia.example.com/api/jira/webhook """ body = await request.body() payload = json.loads(body) event = payload.get("webhookEvent", "") issue = payload.get("issue", {}) issue_key = issue.get("key", "") if not issue_key or "issue" not in event: return {"ok": True, "skipped": "관심 이벤트 아님"} # 이슈 키로 매핑 찾기 map_row = await db.execute( select(JiraSyncMapping).where(JiraSyncMapping.jira_issue_key == issue_key) ) mapping = map_row.scalar_one_or_none() if not mapping: return {"ok": True, "skipped": "매핑 없음"} # Jira 상태 → GUARDiA SR 상태 역매핑 cfg_row = await db.execute( select(JiraConfig).where(JiraConfig.id == mapping.config_id) ) cfg = cfg_row.scalar_one_or_none() jira_status = issue.get("fields", {}).get("status", {}).get("name", "") # 역방향 매핑 status_map = json.loads(cfg.status_mapping) if cfg else DEFAULT_STATUS_MAP reverse_map = {v: k for k, v in status_map.items()} sr_status = reverse_map.get(jira_status) if sr_status: sr_row = await db.execute(select(SRRequest).where(SRRequest.id == mapping.sr_id)) sr = sr_row.scalar_one_or_none() if sr and sr.status != sr_status: sr.status = sr_status sr.updated_at = datetime.utcnow() mapping.synced_at = datetime.utcnow() await db.commit() logger.info(f"SR-{sr.id} 상태 업데이트: {sr_status} (Jira: {jira_status})") return {"ok": True} @router.get("/mappings") async def list_mappings( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """SR-Jira Issue 매핑 목록.""" rows = await db.execute( select(JiraSyncMapping).order_by(desc(JiraSyncMapping.synced_at)).limit(100) ) mappings = rows.scalars().all() return [ { "id": m.id, "sr_id": m.sr_id, "jira_key": m.jira_issue_key, "project": m.project_key, "synced_at": m.synced_at, } for m in mappings ] def _cfg_to_out(cfg: JiraConfig) -> JiraConfigOut: return JiraConfigOut( id=cfg.id, base_url=cfg.base_url, email=cfg.email, project_key=cfg.project_key, status_mapping=json.loads(cfg.status_mapping) if cfg.status_mapping else DEFAULT_STATUS_MAP, auto_sync=cfg.auto_sync, is_active=cfg.is_active, last_synced_at=cfg.last_synced_at, )