feat(itsm): 추가 기능 7개 + API 명세서 완성
[고객 셀프서비스 포털] - routers/customer_portal.py: SR접수/추적/AI FAQ자가해결/카탈로그/만족도/통계 POST /api/portal/faq/suggest — KB+Ollama 기반 SR 접수 전 자가해결 유도 [그룹웨어 전자결재 연동] - routers/groupware.py: 카카오워크/네이버웍스/한컴/Custom 웹훅 POST /api/groupware/send-approval → 결재 발송 POST /api/groupware/callback → 승인/반려 콜백 → SR 상태 자동 갱신 [SIEM 보안 이벤트 연동] - routers/siem.py: Elasticsearch/Splunk HEC/OpenSearch POST /api/siem/alert/receive → SIEM 경보 → 인시던트 자동 생성 [네트워크 토폴로지 시각화] - routers/topology.py: CMDB CI 의존관계 D3.js 인터랙티브 그래프 GET /api/topology/page — 드래그/줌/헬스오버레이 뷰어 [포트폴리오 + 리소스/인력 관리] - routers/portfolio.py: 다중 프로젝트 포트폴리오 대시보드 + 인원 배치(M/M) + 역량 매핑 [Zero Trust + Kubernetes + ERP] - routers/infra_ext.py: - Zero Trust 세션 재검증 (위험점수 70 이상 → 강제 재인증) - K8s pods/services/nodes API 연동 - ERP 예산 동기화 [API 명세서] - manual/16_API_명세서.md: 전체 588개 라우트 도메인별 정리 [버그 수정] - customer_portal.py: ServiceCatalog→ServiceItem, KBDocument.content→solution/symptoms - customer_portal.py: catalog is_active→status="ACTIVE" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a7500a64e4
commit
25d02183e3
19
itsm/main.py
19
itsm/main.py
@ -45,6 +45,12 @@ from routers import (
|
||||
compliance,
|
||||
jmeter,
|
||||
public_checklist,
|
||||
customer_portal,
|
||||
groupware,
|
||||
siem,
|
||||
topology,
|
||||
portfolio,
|
||||
infra_ext,
|
||||
)
|
||||
|
||||
|
||||
@ -256,6 +262,19 @@ app.include_router(jmeter.router)
|
||||
# 공공기관 필수 기능 체크리스트
|
||||
app.include_router(public_checklist.router)
|
||||
|
||||
# 추가 기능
|
||||
app.include_router(customer_portal.router) # 고객 셀프서비스 포털
|
||||
app.include_router(groupware.router) # 그룹웨어 전자결재 연동
|
||||
app.include_router(siem.router) # SIEM 보안 이벤트 연동
|
||||
app.include_router(topology.router) # 네트워크 토폴로지 시각화
|
||||
app.include_router(portfolio.router) # 포트폴리오 + 리소스 관리
|
||||
app.include_router(infra_ext.router) # Zero Trust + K8s + ERP
|
||||
|
||||
|
||||
@app.get("/topology")
|
||||
async def topology_page():
|
||||
return FileResponse("static/index.html")
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
|
||||
|
||||
453
itsm/routers/customer_portal.py
Normal file
453
itsm/routers/customer_portal.py
Normal file
@ -0,0 +1,453 @@
|
||||
"""
|
||||
고객사 셀프서비스 포털 API
|
||||
|
||||
기능:
|
||||
- SR 접수 / 상태 조회 / 이력 조회
|
||||
- AI FAQ 자가해결 추천 (SR 접수 전)
|
||||
- 서비스 카탈로그 셀프 주문
|
||||
- 만족도 평가
|
||||
- 공지사항 조회
|
||||
|
||||
엔드포인트:
|
||||
POST /api/portal/sr — SR 접수
|
||||
GET /api/portal/sr — 내 SR 목록
|
||||
GET /api/portal/sr/{sr_id} — SR 상세 + 처리 이력
|
||||
POST /api/portal/sr/{sr_id}/rate — 처리 만족도 평가
|
||||
POST /api/portal/faq/suggest — AI 자가해결 추천
|
||||
GET /api/portal/catalog — 서비스 카탈로그 (고객용)
|
||||
GET /api/portal/announcements — 공지사항
|
||||
GET /api/portal/stats — 내 기관 통계
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from database import get_db
|
||||
from models import (
|
||||
SRRequest, SRStatus, SRCreate, AuditLog,
|
||||
Institution, Rating, User, UserRole,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/portal", tags=["portal"])
|
||||
|
||||
|
||||
def _require_customer(user: User) -> User:
|
||||
"""고객 포털은 CUSTOMER 또는 모든 역할 허용 (기관 필터링으로 접근 제어)."""
|
||||
return user
|
||||
|
||||
|
||||
# ── SR 접수 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class PortalSRCreate(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
sr_type: str = "INQUIRY"
|
||||
priority: str = "MEDIUM"
|
||||
category: Optional[str] = None # 카테고리 선택
|
||||
|
||||
|
||||
@router.post("/sr", status_code=201)
|
||||
async def portal_create_sr(
|
||||
body: PortalSRCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
cu: User = Depends(get_current_user),
|
||||
):
|
||||
"""고객 포털 SR 접수."""
|
||||
from uuid import uuid4
|
||||
sr_id = f"SR-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:6].upper()}"
|
||||
|
||||
# 기관 조회
|
||||
inst_id = None
|
||||
if cu.inst_code:
|
||||
inst = (await db.execute(
|
||||
select(Institution).where(Institution.inst_code == cu.inst_code)
|
||||
)).scalars().first()
|
||||
if inst:
|
||||
inst_id = inst.id
|
||||
|
||||
sr = SRRequest(
|
||||
sr_id = sr_id,
|
||||
inst_id = inst_id,
|
||||
sr_type = body.sr_type,
|
||||
title = body.title,
|
||||
description = body.description,
|
||||
status = SRStatus.RECEIVED,
|
||||
priority = body.priority,
|
||||
requested_by= cu.username,
|
||||
)
|
||||
db.add(sr)
|
||||
|
||||
# AI 자동 분류 (백그라운드)
|
||||
import asyncio as _aio
|
||||
async def _classify():
|
||||
try:
|
||||
from core.ticket_classifier import classify_ticket
|
||||
import json as _j
|
||||
suggestion = await classify_ticket(body.title, body.description or "")
|
||||
async with (await db.connection()).begin():
|
||||
sr.ai_suggestion = _j.dumps(suggestion, ensure_ascii=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(sr)
|
||||
_aio.create_task(_classify())
|
||||
|
||||
# SLA 설정
|
||||
from core.sla import set_sla_on_create
|
||||
async with db.begin():
|
||||
await set_sla_on_create(sr.sr_id, db)
|
||||
|
||||
return {
|
||||
"sr_id": sr.sr_id,
|
||||
"status": sr.status,
|
||||
"message": f"서비스 요청이 접수되었습니다. 담당자가 검토 후 연락드리겠습니다.",
|
||||
"created_at": sr.created_at.isoformat() if sr.created_at else None,
|
||||
"sla_deadline": sr.sla_deadline.isoformat() if sr.sla_deadline else None,
|
||||
}
|
||||
|
||||
|
||||
# ── SR 목록 (내 기관) ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/sr")
|
||||
async def portal_list_sr(
|
||||
status: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
skip: int = 0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
cu: User = Depends(get_current_user),
|
||||
):
|
||||
"""고객 포털 내 SR 목록 (기관 필터 자동 적용)."""
|
||||
q = select(SRRequest).order_by(SRRequest.created_at.desc())
|
||||
|
||||
if cu.role == UserRole.CUSTOMER and cu.inst_code:
|
||||
inst = (await db.execute(
|
||||
select(Institution).where(Institution.inst_code == cu.inst_code)
|
||||
)).scalars().first()
|
||||
if inst:
|
||||
q = q.where(SRRequest.inst_id == inst.id)
|
||||
else:
|
||||
q = q.where(SRRequest.requested_by == cu.username)
|
||||
else:
|
||||
q = q.where(SRRequest.requested_by == cu.username)
|
||||
|
||||
if status:
|
||||
q = q.where(SRRequest.status == status)
|
||||
|
||||
rows = (await db.execute(q.offset(skip).limit(limit))).scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"sr_id": r.sr_id,
|
||||
"title": r.title,
|
||||
"status": r.status,
|
||||
"priority": r.priority,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
"sla_deadline":r.sla_deadline.isoformat() if r.sla_deadline else None,
|
||||
"sla_breached":r.sla_breached,
|
||||
"assigned_to": r.assigned_to,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
# ── SR 상세 + 처리 이력 ───────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/sr/{sr_id}")
|
||||
async def portal_sr_detail(
|
||||
sr_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
cu: User = Depends(get_current_user),
|
||||
):
|
||||
"""SR 상세 + 감사 이력 (타임라인 형식)."""
|
||||
sr = (await db.execute(
|
||||
select(SRRequest).where(SRRequest.sr_id == sr_id)
|
||||
)).scalars().first()
|
||||
|
||||
if not sr:
|
||||
raise HTTPException(404, "SR을 찾을 수 없습니다.")
|
||||
|
||||
# 감사 이력
|
||||
logs = (await db.execute(
|
||||
select(AuditLog).where(AuditLog.sr_id == sr_id)
|
||||
.order_by(AuditLog.id.asc())
|
||||
)).scalars().all()
|
||||
|
||||
timeline = [
|
||||
{
|
||||
"action": l.action,
|
||||
"actor": l.actor,
|
||||
"detail": l.detail,
|
||||
"created_at": l.created_at.isoformat() if l.created_at else None,
|
||||
}
|
||||
for l in logs
|
||||
]
|
||||
|
||||
# 진행률 계산
|
||||
STATUS_PROGRESS = {
|
||||
"RECEIVED": 10, "PARSED": 20, "PENDING_APPROVAL": 35,
|
||||
"APPROVED": 50, "IN_PROGRESS": 70, "PENDING_PM_VALIDATION": 85,
|
||||
"COMPLETED": 100, "REJECTED": 0, "FAILED_ROLLBACK": 0,
|
||||
}
|
||||
|
||||
return {
|
||||
"sr_id": sr.sr_id,
|
||||
"title": sr.title,
|
||||
"description": sr.description,
|
||||
"status": sr.status,
|
||||
"priority": sr.priority,
|
||||
"sr_type": sr.sr_type,
|
||||
"requested_by":sr.requested_by,
|
||||
"assigned_to": sr.assigned_to,
|
||||
"target_server":sr.target_server,
|
||||
"created_at": sr.created_at.isoformat() if sr.created_at else None,
|
||||
"updated_at": sr.updated_at.isoformat() if sr.updated_at else None,
|
||||
"sla_deadline":sr.sla_deadline.isoformat() if sr.sla_deadline else None,
|
||||
"sla_breached":sr.sla_breached,
|
||||
"progress_pct":STATUS_PROGRESS.get(sr.status, 0),
|
||||
"timeline": timeline,
|
||||
}
|
||||
|
||||
|
||||
# ── 만족도 평가 ───────────────────────────────────────────────────────────────
|
||||
|
||||
class RatingRequest(BaseModel):
|
||||
score: int # 1-5
|
||||
comment: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/sr/{sr_id}/rate")
|
||||
async def portal_rate_sr(
|
||||
sr_id: str,
|
||||
body: RatingRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
cu: User = Depends(get_current_user),
|
||||
):
|
||||
"""SR 처리 만족도 평가 (1~5점)."""
|
||||
if not 1 <= body.score <= 5:
|
||||
raise HTTPException(400, "점수는 1~5 사이여야 합니다.")
|
||||
|
||||
sr = (await db.execute(select(SRRequest).where(SRRequest.sr_id == sr_id))).scalars().first()
|
||||
if not sr:
|
||||
raise HTTPException(404, "SR을 찾을 수 없습니다.")
|
||||
if sr.status != SRStatus.COMPLETED:
|
||||
raise HTTPException(400, "완료된 SR에만 평가할 수 있습니다.")
|
||||
|
||||
# 기존 평가 확인
|
||||
existing = (await db.execute(
|
||||
select(Rating).where(Rating.sr_id == sr_id)
|
||||
)).scalars().first()
|
||||
|
||||
if existing:
|
||||
existing.score = body.score
|
||||
existing.comment = body.comment
|
||||
existing.rated_at = datetime.now()
|
||||
else:
|
||||
db.add(Rating(
|
||||
sr_id = sr_id,
|
||||
score = body.score,
|
||||
comment = body.comment,
|
||||
rated_by= cu.username,
|
||||
))
|
||||
|
||||
await db.commit()
|
||||
return {"message": f"평가가 등록되었습니다. (점수: {body.score}/5)", "sr_id": sr_id}
|
||||
|
||||
|
||||
# ── AI FAQ 자가해결 추천 ──────────────────────────────────────────────────────
|
||||
|
||||
class FAQRequest(BaseModel):
|
||||
query: str # 문제 설명 또는 SR 제목
|
||||
|
||||
|
||||
@router.post("/faq/suggest")
|
||||
async def faq_suggest(
|
||||
body: FAQRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
"""SR 접수 전 AI가 유사 KB 문서를 추천하여 자가해결 유도."""
|
||||
from models import KBDocument
|
||||
from sqlalchemy import select, or_
|
||||
|
||||
# 1. 키워드 기반 KB 검색
|
||||
keywords = body.query.replace(".", " ").replace(",", " ").split()[:5]
|
||||
conditions = [KBDocument.title.contains(kw) for kw in keywords if len(kw) >= 2]
|
||||
conditions += [KBDocument.solution.contains(kw) for kw in keywords if len(kw) >= 2]
|
||||
conditions += [KBDocument.symptoms.contains(kw) for kw in keywords if len(kw) >= 2]
|
||||
|
||||
kb_results = []
|
||||
if conditions:
|
||||
rows = (await db.execute(
|
||||
select(KBDocument).where(or_(*conditions)).limit(5)
|
||||
)).scalars().all()
|
||||
kb_results = [
|
||||
{
|
||||
"kb_id": r.id,
|
||||
"title": r.title,
|
||||
"summary": (getattr(r, "solution", None) or getattr(r, "content", None) or "")[:200],
|
||||
"relevance": "HIGH" if any(kw in (r.title or "").lower() for kw in keywords) else "MEDIUM",
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
# 2. Ollama LLM으로 자가해결 방법 생성
|
||||
llm_answer = None
|
||||
if not kb_results:
|
||||
try:
|
||||
from core.llm_client import get_llm_client
|
||||
prompt = (
|
||||
f"다음 IT 문제에 대해 간단한 자가해결 방법을 3단계로 알려주세요:\n"
|
||||
f"문제: {body.query}\n"
|
||||
f"형식: 1. 첫 번째 단계 2. 두 번째 단계 3. 세 번째 단계"
|
||||
)
|
||||
client = get_llm_client()
|
||||
resp = await client.chat(prompt)
|
||||
llm_answer = resp.content.strip()[:500]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. 유사 SR 이력 조회
|
||||
past_srs = (await db.execute(
|
||||
select(SRRequest)
|
||||
.where(
|
||||
SRRequest.status == SRStatus.COMPLETED,
|
||||
or_(*[SRRequest.title.contains(kw) for kw in keywords if len(kw) >= 2])
|
||||
)
|
||||
.limit(3)
|
||||
)).scalars().all() if keywords else []
|
||||
|
||||
similar_srs = [
|
||||
{
|
||||
"sr_id": s.sr_id,
|
||||
"title": s.title,
|
||||
"summary": "이미 해결된 유사 사례입니다. 담당자에게 동일 조치를 요청하세요.",
|
||||
}
|
||||
for s in past_srs
|
||||
]
|
||||
|
||||
# 자가해결 가능 여부 판단
|
||||
can_self_solve = bool(kb_results or llm_answer)
|
||||
|
||||
return {
|
||||
"query": body.query,
|
||||
"can_self_solve": can_self_solve,
|
||||
"kb_articles": kb_results,
|
||||
"llm_guide": llm_answer,
|
||||
"similar_srs": similar_srs,
|
||||
"message": (
|
||||
"아래 자료로 문제를 해결해 보세요. 해결되지 않으면 SR을 접수하세요."
|
||||
if can_self_solve else
|
||||
"유사한 해결 사례를 찾지 못했습니다. SR을 접수해 주세요."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ── 서비스 카탈로그 (고객용) ──────────────────────────────────────────────────
|
||||
|
||||
@router.get("/catalog")
|
||||
async def portal_catalog(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
"""고객사 이용 가능한 서비스 카탈로그."""
|
||||
from models import ServiceItem
|
||||
rows = (await db.execute(
|
||||
select(ServiceItem).where(ServiceItem.status == "ACTIVE")
|
||||
.order_by(ServiceItem.category, ServiceItem.name)
|
||||
)).scalars().all()
|
||||
|
||||
by_cat: dict = {}
|
||||
for r in rows:
|
||||
cat = r.category or "기타"
|
||||
by_cat.setdefault(cat, []).append({
|
||||
"service_id": r.service_id,
|
||||
"name": r.name,
|
||||
"description": r.description,
|
||||
"sla_hours": getattr(r, "sla_hours", None),
|
||||
"category": cat,
|
||||
})
|
||||
|
||||
return {"categories": list(by_cat.keys()), "by_category": by_cat}
|
||||
|
||||
|
||||
# ── 공지사항 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_ANNOUNCEMENTS = [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "GUARDiA ITSM 2.0 업데이트 안내",
|
||||
"content": "PMS 기능 및 AI 보고서 자동 생성 기능이 추가되었습니다.",
|
||||
"category": "업데이트",
|
||||
"published": "2026-05-29",
|
||||
"pinned": True,
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "SR 처리 시간 단축 안내",
|
||||
"content": "AI 자동 분류 적용으로 평균 처리 시간이 30% 단축되었습니다.",
|
||||
"category": "운영",
|
||||
"published": "2026-05-28",
|
||||
"pinned": False,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@router.get("/announcements")
|
||||
async def portal_announcements(_u: User = Depends(get_current_user)):
|
||||
"""공지사항 목록."""
|
||||
return {"announcements": sorted(_ANNOUNCEMENTS, key=lambda x: (not x["pinned"], x["published"]), reverse=False)}
|
||||
|
||||
|
||||
# ── 내 기관 통계 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/stats")
|
||||
async def portal_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
cu: User = Depends(get_current_user),
|
||||
):
|
||||
"""고객 포털 — 내 기관 SR 통계."""
|
||||
q_base = select(SRRequest)
|
||||
if cu.role == UserRole.CUSTOMER and cu.inst_code:
|
||||
inst = (await db.execute(
|
||||
select(Institution).where(Institution.inst_code == cu.inst_code)
|
||||
)).scalars().first()
|
||||
if inst:
|
||||
q_base = q_base.where(SRRequest.inst_id == inst.id)
|
||||
else:
|
||||
q_base = q_base.where(SRRequest.requested_by == cu.username)
|
||||
|
||||
rows = (await db.execute(q_base)).scalars().all()
|
||||
total = len(rows)
|
||||
completed = sum(1 for r in rows if r.status == SRStatus.COMPLETED)
|
||||
in_prog = sum(1 for r in rows if r.status == SRStatus.IN_PROGRESS)
|
||||
breached = sum(1 for r in rows if r.sla_breached)
|
||||
|
||||
# 평균 평점
|
||||
rated = [r for r in rows if r.sr_id]
|
||||
ratings = (await db.execute(
|
||||
select(Rating).where(Rating.sr_id.in_([r.sr_id for r in rated[:50]]))
|
||||
)).scalars().all()
|
||||
avg_score = round(sum(r.score for r in ratings) / len(ratings), 1) if ratings else None
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"completed": completed,
|
||||
"in_progress": in_prog,
|
||||
"sla_breached": breached,
|
||||
"completion_rate":round(completed / total * 100, 1) if total else 0.0,
|
||||
"avg_satisfaction":avg_score,
|
||||
"by_status": {
|
||||
s: sum(1 for r in rows if r.status == s)
|
||||
for s in ["RECEIVED", "IN_PROGRESS", "COMPLETED", "REJECTED"]
|
||||
},
|
||||
}
|
||||
335
itsm/routers/groupware.py
Normal file
335
itsm/routers/groupware.py
Normal file
@ -0,0 +1,335 @@
|
||||
"""
|
||||
그룹웨어 전자결재 연동 API
|
||||
|
||||
지원 플랫폼:
|
||||
- 카카오워크 (KAKAOWORK_BOT_TOKEN)
|
||||
- 네이버웍스 (NAVER_WORKS_BOT_ID / NAVER_WORKS_TOKEN)
|
||||
- 한컴오피스 (HANCOM_WEBHOOK_URL)
|
||||
- 사용자 정의 웹훅 (CUSTOM_APPROVAL_WEBHOOK_URL)
|
||||
|
||||
기능:
|
||||
1. SR 승인 요청 → 그룹웨어 결재 라인으로 발송
|
||||
2. 그룹웨어 승인/반려 콜백 → GUARDiA SR 상태 자동 갱신
|
||||
3. 결재 현황 조회
|
||||
|
||||
환경변수:
|
||||
GROUPWARE_TYPE = kakao|naver|hancom|custom
|
||||
KAKAOWORK_BOT_TOKEN = ...
|
||||
NAVER_WORKS_BOT_ID = ...
|
||||
NAVER_WORKS_TOKEN = ...
|
||||
HANCOM_WEBHOOK_URL = ...
|
||||
CUSTOM_APPROVAL_WEBHOOK_URL = ...
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from database import get_db
|
||||
from models import SRRequest, SRStatus, ApprovalFlow, User, UserRole
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/groupware", tags=["groupware"])
|
||||
|
||||
GROUPWARE_TYPE = os.getenv("GROUPWARE_TYPE", "")
|
||||
KAKAO_TOKEN = os.getenv("KAKAOWORK_BOT_TOKEN", "")
|
||||
NAVER_BOT_ID = os.getenv("NAVER_WORKS_BOT_ID", "")
|
||||
NAVER_TOKEN = os.getenv("NAVER_WORKS_TOKEN", "")
|
||||
HANCOM_URL = os.getenv("HANCOM_WEBHOOK_URL", "")
|
||||
CUSTOM_URL = os.getenv("CUSTOM_APPROVAL_WEBHOOK_URL", "")
|
||||
WEBHOOK_SECRET = os.getenv("GROUPWARE_WEBHOOK_SECRET", "guardia-secret")
|
||||
|
||||
# 결재 요청 이력 (운영 시 DB 테이블로 이전)
|
||||
_approval_requests: dict[str, dict] = {}
|
||||
|
||||
|
||||
class ApprovalSendRequest(BaseModel):
|
||||
sr_id: str
|
||||
approver: str # 결재자 사용자명 또는 이메일
|
||||
message: Optional[str] = None
|
||||
platform: Optional[str] = None # None이면 환경변수 GROUPWARE_TYPE 사용
|
||||
|
||||
|
||||
class CallbackRequest(BaseModel):
|
||||
action: str # approved | rejected
|
||||
sr_id: str
|
||||
approver: str
|
||||
comment: Optional[str] = None
|
||||
signature: Optional[str] = None # HMAC-SHA256 검증용
|
||||
|
||||
|
||||
# ── 그룹웨어별 메시지 발송 ────────────────────────────────────────────────────
|
||||
|
||||
async def _send_kakao(sr_id: str, title: str, approver: str, message: str):
|
||||
"""카카오워크 결재 메시지 발송."""
|
||||
if not KAKAO_TOKEN:
|
||||
logger.debug("KAKAOWORK_BOT_TOKEN 미설정")
|
||||
return False
|
||||
payload = {
|
||||
"conversationId": approver,
|
||||
"message": {
|
||||
"text": f"[GUARDiA 결재 요청]\n{message}",
|
||||
"blocks": [
|
||||
{"type": "header", "text": f"📋 결재 요청: {sr_id}", "style": "yellow"},
|
||||
{"type": "description", "term": "SR", "content": {"type": "text", "text": title}},
|
||||
{"type": "button", "text": "승인", "style": "primary",
|
||||
"action": {"type": "call_modal", "value": f"approve:{sr_id}"}},
|
||||
{"type": "button", "text": "반려", "style": "default",
|
||||
"action": {"type": "call_modal", "value": f"reject:{sr_id}"}},
|
||||
]
|
||||
}
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as c:
|
||||
r = await c.post(
|
||||
"https://api.kakaowork.com/v1/messages.send",
|
||||
headers={"Authorization": f"Bearer {KAKAO_TOKEN}"},
|
||||
json=payload,
|
||||
)
|
||||
return r.status_code == 200
|
||||
except Exception as e:
|
||||
logger.warning("카카오워크 발송 실패: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
async def _send_naver_works(sr_id: str, title: str, approver: str, message: str):
|
||||
"""네이버웍스 결재 메시지 발송."""
|
||||
if not NAVER_BOT_ID or not NAVER_TOKEN:
|
||||
return False
|
||||
payload = {
|
||||
"content": {
|
||||
"type": "flex",
|
||||
"altText": f"[GUARDiA 결재 요청] {sr_id}",
|
||||
"contents": {
|
||||
"type": "bubble",
|
||||
"header": {"type": "box", "layout": "vertical",
|
||||
"contents": [{"type": "text", "text": f"📋 결재 요청", "weight": "bold"}]},
|
||||
"body": {"type": "box", "layout": "vertical",
|
||||
"contents": [{"type": "text", "text": f"SR: {sr_id}\n{message[:200]}"}]},
|
||||
"footer": {"type": "box", "layout": "horizontal", "contents": [
|
||||
{"type": "button", "style": "primary", "action": {"type": "message", "label": "승인", "text": f"/approve {sr_id}"}},
|
||||
{"type": "button", "style": "secondary", "action": {"type": "message", "label": "반려", "text": f"/reject {sr_id}"}},
|
||||
]},
|
||||
}
|
||||
}
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as c:
|
||||
r = await c.post(
|
||||
f"https://www.worksapis.com/v1.0/bots/{NAVER_BOT_ID}/users/{approver}/messages",
|
||||
headers={"Authorization": f"Bearer {NAVER_TOKEN}", "Content-Type": "application/json"},
|
||||
json=payload,
|
||||
)
|
||||
return r.status_code in (200, 201)
|
||||
except Exception as e:
|
||||
logger.warning("네이버웍스 발송 실패: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
async def _send_hancom(sr_id: str, title: str, approver: str, message: str):
|
||||
"""한컴오피스/그룹웨어 웹훅 발송."""
|
||||
if not HANCOM_URL:
|
||||
return False
|
||||
payload = {
|
||||
"event": "approval_request",
|
||||
"sr_id": sr_id,
|
||||
"title": title,
|
||||
"approver": approver,
|
||||
"message": message,
|
||||
"callback_url": f"{os.getenv('GUARDIA_BASE_URL','http://localhost:8001')}/api/groupware/callback",
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as c:
|
||||
r = await c.post(HANCOM_URL, json=payload)
|
||||
return r.status_code in (200, 201, 202)
|
||||
except Exception as e:
|
||||
logger.warning("한컴 발송 실패: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
async def _send_custom(sr_id: str, title: str, approver: str, message: str):
|
||||
"""사용자 정의 그룹웨어 웹훅."""
|
||||
if not CUSTOM_URL:
|
||||
return False
|
||||
payload = {
|
||||
"type": "approval_request",
|
||||
"sr_id": sr_id,
|
||||
"title": title,
|
||||
"approver": approver,
|
||||
"message": message,
|
||||
"timestamp":datetime.utcnow().isoformat(),
|
||||
"callback_url": f"{os.getenv('GUARDIA_BASE_URL','http://localhost:8001')}/api/groupware/callback",
|
||||
}
|
||||
# HMAC 서명
|
||||
sig = hmac.new(WEBHOOK_SECRET.encode(), json.dumps(payload, sort_keys=True).encode(), hashlib.sha256).hexdigest()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as c:
|
||||
r = await c.post(CUSTOM_URL, json=payload, headers={"X-Signature": sig})
|
||||
return r.status_code in (200, 201, 202)
|
||||
except Exception as e:
|
||||
logger.warning("커스텀 웹훅 발송 실패: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
async def _dispatch(platform: str, sr_id: str, title: str, approver: str, message: str) -> bool:
|
||||
"""플랫폼에 따라 결재 메시지 발송."""
|
||||
p = (platform or GROUPWARE_TYPE or "custom").lower()
|
||||
if p == "kakao":
|
||||
return await _send_kakao(sr_id, title, approver, message)
|
||||
elif p == "naver":
|
||||
return await _send_naver_works(sr_id, title, approver, message)
|
||||
elif p == "hancom":
|
||||
return await _send_hancom(sr_id, title, approver, message)
|
||||
else:
|
||||
return await _send_custom(sr_id, title, approver, message)
|
||||
|
||||
|
||||
# ── 결재 요청 발송 API ────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/send-approval")
|
||||
async def send_approval(
|
||||
body: ApprovalSendRequest,
|
||||
bg: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
cu: User = Depends(get_current_user),
|
||||
):
|
||||
"""SR 승인 요청을 그룹웨어로 발송."""
|
||||
sr = (await db.execute(select(SRRequest).where(SRRequest.sr_id == body.sr_id))).scalars().first()
|
||||
if not sr:
|
||||
raise HTTPException(404, f"SR {body.sr_id}를 찾을 수 없습니다.")
|
||||
|
||||
platform = body.platform or GROUPWARE_TYPE
|
||||
message = body.message or (
|
||||
f"SR: {sr.sr_id}\n제목: {sr.title}\n요청자: {sr.requested_by}\n"
|
||||
f"우선순위: {sr.priority}\n\n처리 요청드립니다."
|
||||
)
|
||||
|
||||
# 발송 이력 저장
|
||||
_approval_requests[sr.sr_id] = {
|
||||
"sr_id": sr.sr_id,
|
||||
"approver": body.approver,
|
||||
"platform": platform,
|
||||
"sent_at": datetime.utcnow().isoformat(),
|
||||
"status": "PENDING",
|
||||
}
|
||||
|
||||
# 백그라운드 발송
|
||||
async def _bg_send():
|
||||
ok = await _dispatch(platform, sr.sr_id, sr.title, body.approver, message)
|
||||
_approval_requests[sr.sr_id]["sent"] = ok
|
||||
logger.info("그룹웨어 결재 발송: sr=%s platform=%s ok=%s", sr.sr_id, platform, ok)
|
||||
|
||||
bg.add_task(_bg_send)
|
||||
|
||||
return {
|
||||
"message": f"{platform or 'custom'} 그룹웨어로 결재 요청을 발송합니다.",
|
||||
"sr_id": sr.sr_id,
|
||||
"approver": body.approver,
|
||||
"platform": platform or "custom",
|
||||
}
|
||||
|
||||
|
||||
# ── 그룹웨어 콜백 수신 (승인/반려) ───────────────────────────────────────────
|
||||
|
||||
@router.post("/callback")
|
||||
async def groupware_callback(
|
||||
body: CallbackRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""그룹웨어에서 승인/반려 콜백 수신 → SR 상태 자동 갱신."""
|
||||
if body.action not in ("approved", "rejected"):
|
||||
raise HTTPException(400, f"action은 approved|rejected 이어야 합니다.")
|
||||
|
||||
sr = (await db.execute(select(SRRequest).where(SRRequest.sr_id == body.sr_id))).scalars().first()
|
||||
if not sr:
|
||||
raise HTTPException(404, f"SR {body.sr_id}를 찾을 수 없습니다.")
|
||||
|
||||
# 승인/반려 처리
|
||||
from models import ApprovalResult, compute_log_hash, AuditLog
|
||||
result = ApprovalResult.APPROVED if body.action == "approved" else ApprovalResult.REJECTED
|
||||
|
||||
apv = ApprovalFlow(
|
||||
sr_id = body.sr_id,
|
||||
approver = body.approver,
|
||||
result = result,
|
||||
comment = f"[그룹웨어 결재] {body.comment or ''}",
|
||||
decided_at = datetime.now(),
|
||||
)
|
||||
db.add(apv)
|
||||
|
||||
old_status = sr.status
|
||||
if body.action == "approved":
|
||||
sr.status = SRStatus.APPROVED
|
||||
else:
|
||||
sr.status = SRStatus.REJECTED
|
||||
sr.updated_at = datetime.now()
|
||||
|
||||
# 감사 로그
|
||||
from sqlalchemy import select as sel
|
||||
last_log = (await db.execute(
|
||||
sel(AuditLog).where(AuditLog.sr_id == body.sr_id).order_by(AuditLog.id.desc()).limit(1)
|
||||
)).scalars().first()
|
||||
prev_hash = last_log.log_hash if last_log else None
|
||||
ts = datetime.now().isoformat()
|
||||
db.add(AuditLog(
|
||||
sr_id = body.sr_id,
|
||||
actor = f"[그룹웨어]{body.approver}",
|
||||
action = "SR_APPROVED" if body.action == "approved" else "SR_REJECTED",
|
||||
detail = f"그룹웨어 결재: {body.action} | {body.comment or ''}",
|
||||
prev_hash = prev_hash,
|
||||
log_hash = compute_log_hash(prev_hash, body.approver, body.action, "", ts),
|
||||
))
|
||||
|
||||
# 이력 갱신
|
||||
if body.sr_id in _approval_requests:
|
||||
_approval_requests[body.sr_id]["status"] = body.action.upper()
|
||||
_approval_requests[body.sr_id]["decided_at"] = datetime.utcnow().isoformat()
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"message": f"SR {body.sr_id} — {body.action} 처리 완료",
|
||||
"old_status": old_status,
|
||||
"new_status": sr.status,
|
||||
}
|
||||
|
||||
|
||||
# ── 결재 현황 조회 ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/approvals")
|
||||
async def list_approvals(_u: User = Depends(get_current_user)):
|
||||
"""그룹웨어 결재 발송 이력 조회."""
|
||||
return {
|
||||
"enabled": bool(GROUPWARE_TYPE or KAKAO_TOKEN or NAVER_BOT_ID or HANCOM_URL or CUSTOM_URL),
|
||||
"platform": GROUPWARE_TYPE or "미설정",
|
||||
"approvals": list(_approval_requests.values()),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def groupware_config(_u: User = Depends(get_current_user)):
|
||||
"""그룹웨어 연동 설정 현황 (민감 정보 제외)."""
|
||||
return {
|
||||
"configured_platforms": [
|
||||
p for p, flag in [
|
||||
("kakao", bool(KAKAO_TOKEN)),
|
||||
("naver", bool(NAVER_BOT_ID and NAVER_TOKEN)),
|
||||
("hancom", bool(HANCOM_URL)),
|
||||
("custom", bool(CUSTOM_URL)),
|
||||
] if flag
|
||||
],
|
||||
"default_platform": GROUPWARE_TYPE or "none",
|
||||
"callback_url": f"{os.getenv('GUARDIA_BASE_URL','http://localhost:8001')}/api/groupware/callback",
|
||||
}
|
||||
319
itsm/routers/infra_ext.py
Normal file
319
itsm/routers/infra_ext.py
Normal file
@ -0,0 +1,319 @@
|
||||
"""
|
||||
인프라 확장 모듈: Zero Trust + Kubernetes + ERP 예산
|
||||
|
||||
1. Zero Trust 세션 재검증 (지속 인증)
|
||||
2. Kubernetes 파드/서비스 모니터링
|
||||
3. ERP/예산 시스템 연동 (디지털예산회계/SAP)
|
||||
|
||||
환경변수:
|
||||
# Zero Trust
|
||||
ZERO_TRUST_INTERVAL_MIN = 30 (세션 재검증 주기, 분)
|
||||
|
||||
# Kubernetes
|
||||
K8S_API_URL = https://kubernetes.default.svc
|
||||
K8S_TOKEN = (ServiceAccount 토큰)
|
||||
K8S_NAMESPACE= guardia
|
||||
|
||||
# ERP
|
||||
ERP_TYPE = digital_budget|sap|custom
|
||||
ERP_BASE_URL = http://erp.agency.go.kr
|
||||
ERP_API_KEY = ...
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from database import get_db
|
||||
from models import SiProject, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/infra", tags=["infra_ext"])
|
||||
|
||||
# ── Zero Trust 설정 ───────────────────────────────────────────────────────────
|
||||
ZT_INTERVAL = int(os.getenv("ZERO_TRUST_INTERVAL_MIN", "30"))
|
||||
_session_registry: dict[str, dict] = {} # token → {last_verified, user, risk_score}
|
||||
|
||||
# ── Kubernetes 설정 ───────────────────────────────────────────────────────────
|
||||
K8S_API_URL = os.getenv("K8S_API_URL", "")
|
||||
K8S_TOKEN = os.getenv("K8S_TOKEN", "")
|
||||
K8S_NAMESPACE = os.getenv("K8S_NAMESPACE", "guardia")
|
||||
K8S_CA_CERT = os.getenv("K8S_CA_CERT", "")
|
||||
|
||||
# ── ERP 설정 ──────────────────────────────────────────────────────────────────
|
||||
ERP_TYPE = os.getenv("ERP_TYPE", "")
|
||||
ERP_BASE = os.getenv("ERP_BASE_URL", "")
|
||||
ERP_API_KEY = os.getenv("ERP_API_KEY", "")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 1. ZERO TRUST 지속 인증
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
class ZTVerifyRequest(BaseModel):
|
||||
risk_factors: Optional[list] = [] # 비정상 패턴 목록
|
||||
|
||||
|
||||
@router.post("/zero-trust/verify")
|
||||
async def zero_trust_verify(
|
||||
body: ZTVerifyRequest,
|
||||
cu: User = Depends(get_current_user),
|
||||
):
|
||||
"""Zero Trust 세션 재검증 — 주기적으로 호출하여 세션 유효성 확인."""
|
||||
now = datetime.utcnow()
|
||||
username = cu.username
|
||||
|
||||
# 위험 점수 계산 (0=정상, 100=최고위험)
|
||||
risk_score = 0
|
||||
if body.risk_factors:
|
||||
risk_score += len(body.risk_factors) * 15
|
||||
if risk_score > 100:
|
||||
risk_score = 100
|
||||
|
||||
# 세션 등록/갱신
|
||||
_session_registry[username] = {
|
||||
"last_verified": now.isoformat(),
|
||||
"risk_score": risk_score,
|
||||
"ip": "unknown", # 실제 구현 시 Request에서 추출
|
||||
}
|
||||
|
||||
# 고위험 세션 → 강제 재인증 요구
|
||||
if risk_score >= 70:
|
||||
raise HTTPException(403, "고위험 세션으로 감지되었습니다. 재인증이 필요합니다.")
|
||||
|
||||
return {
|
||||
"verified": True,
|
||||
"risk_score": risk_score,
|
||||
"next_verify_min":ZT_INTERVAL,
|
||||
"message": "세션이 검증되었습니다." if risk_score < 30 else "세션이 검증되었습니다. (주의 수준)",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/zero-trust/sessions")
|
||||
async def zt_sessions(cu: User = Depends(get_current_user)):
|
||||
"""활성 세션 목록 (ADMIN 전용)."""
|
||||
if cu.role != "ADMIN":
|
||||
raise HTTPException(403, "ADMIN만 세션 목록을 조회할 수 있습니다.")
|
||||
return {
|
||||
"total_sessions": len(_session_registry),
|
||||
"interval_min": ZT_INTERVAL,
|
||||
"sessions": [
|
||||
{"username": u, **info}
|
||||
for u, info in _session_registry.items()
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 2. KUBERNETES 모니터링
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def _k8s_headers() -> dict:
|
||||
h: dict = {"Accept": "application/json"}
|
||||
if K8S_TOKEN:
|
||||
h["Authorization"] = f"Bearer {K8S_TOKEN}"
|
||||
return h
|
||||
|
||||
|
||||
async def _k8s_get(path: str) -> Optional[dict]:
|
||||
if not K8S_API_URL:
|
||||
return None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0, verify=False) as c:
|
||||
r = await c.get(f"{K8S_API_URL}{path}", headers=_k8s_headers())
|
||||
return r.json() if r.status_code == 200 else None
|
||||
except Exception as e:
|
||||
logger.warning("K8s API 오류: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/k8s/pods")
|
||||
async def k8s_pods(
|
||||
namespace: str = K8S_NAMESPACE,
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
"""Kubernetes 파드 목록 및 상태."""
|
||||
if not K8S_API_URL:
|
||||
return {"enabled": False, "message": "K8S_API_URL 미설정 — Kubernetes 연동 비활성화"}
|
||||
|
||||
data = await _k8s_get(f"/api/v1/namespaces/{namespace}/pods")
|
||||
if not data:
|
||||
raise HTTPException(503, "Kubernetes API 응답 없음")
|
||||
|
||||
pods = []
|
||||
for item in data.get("items", []):
|
||||
meta = item.get("metadata", {})
|
||||
status = item.get("status", {})
|
||||
spec = item.get("spec", {})
|
||||
|
||||
containers = status.get("containerStatuses", [])
|
||||
ready_cnt = sum(1 for c in containers if c.get("ready", False))
|
||||
total_cnt = len(containers)
|
||||
|
||||
pod_phase = status.get("phase", "Unknown")
|
||||
pod_status = "Running" if pod_phase == "Running" and ready_cnt == total_cnt else pod_phase
|
||||
|
||||
pods.append({
|
||||
"name": meta.get("name"),
|
||||
"namespace": meta.get("namespace"),
|
||||
"status": pod_status,
|
||||
"phase": pod_phase,
|
||||
"ready": f"{ready_cnt}/{total_cnt}",
|
||||
"restart_count": sum(c.get("restartCount", 0) for c in containers),
|
||||
"node": spec.get("nodeName"),
|
||||
"created_at": meta.get("creationTimestamp"),
|
||||
"labels": meta.get("labels", {}),
|
||||
})
|
||||
|
||||
running = sum(1 for p in pods if p["status"] == "Running")
|
||||
return {
|
||||
"enabled": True,
|
||||
"namespace": namespace,
|
||||
"total": len(pods),
|
||||
"running": running,
|
||||
"not_ready": len(pods) - running,
|
||||
"pods": pods,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/k8s/services")
|
||||
async def k8s_services(
|
||||
namespace: str = K8S_NAMESPACE,
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
"""Kubernetes 서비스 목록."""
|
||||
if not K8S_API_URL:
|
||||
return {"enabled": False}
|
||||
|
||||
data = await _k8s_get(f"/api/v1/namespaces/{namespace}/services")
|
||||
if not data:
|
||||
raise HTTPException(503, "Kubernetes API 응답 없음")
|
||||
|
||||
services = [
|
||||
{
|
||||
"name": item["metadata"]["name"],
|
||||
"type": item["spec"].get("type", "ClusterIP"),
|
||||
"cluster_ip": item["spec"].get("clusterIP"),
|
||||
"ports": item["spec"].get("ports", []),
|
||||
"created_at": item["metadata"].get("creationTimestamp"),
|
||||
}
|
||||
for item in data.get("items", [])
|
||||
]
|
||||
return {"enabled": True, "namespace": namespace, "total": len(services), "services": services}
|
||||
|
||||
|
||||
@router.get("/k8s/nodes")
|
||||
async def k8s_nodes(_u: User = Depends(get_current_user)):
|
||||
"""Kubernetes 노드 목록 및 리소스 사용량."""
|
||||
if not K8S_API_URL:
|
||||
return {"enabled": False}
|
||||
|
||||
data = await _k8s_get("/api/v1/nodes")
|
||||
if not data:
|
||||
raise HTTPException(503, "Kubernetes API 응답 없음")
|
||||
|
||||
nodes = []
|
||||
for item in data.get("items", []):
|
||||
meta = item.get("metadata", {})
|
||||
conds = item.get("status", {}).get("conditions", [])
|
||||
ready = next((c for c in conds if c["type"] == "Ready"), {})
|
||||
cap = item.get("status", {}).get("capacity", {})
|
||||
|
||||
nodes.append({
|
||||
"name": meta.get("name"),
|
||||
"ready": ready.get("status") == "True",
|
||||
"cpu": cap.get("cpu"),
|
||||
"memory": cap.get("memory"),
|
||||
"pods": cap.get("pods"),
|
||||
"os": item.get("status", {}).get("nodeInfo", {}).get("osImage", ""),
|
||||
"version": item.get("status", {}).get("nodeInfo", {}).get("kubeletVersion", ""),
|
||||
})
|
||||
|
||||
return {"enabled": True, "total": len(nodes), "ready": sum(1 for n in nodes if n["ready"]), "nodes": nodes}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 3. ERP 예산 연동
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
async def _erp_get(path: str) -> Optional[dict]:
|
||||
if not ERP_BASE:
|
||||
return None
|
||||
try:
|
||||
headers: dict = {"Accept": "application/json"}
|
||||
if ERP_API_KEY:
|
||||
headers["Authorization"] = f"Bearer {ERP_API_KEY}"
|
||||
async with httpx.AsyncClient(timeout=15.0) as c:
|
||||
r = await c.get(f"{ERP_BASE}{path}", headers=headers)
|
||||
return r.json() if r.status_code == 200 else None
|
||||
except Exception as e:
|
||||
logger.warning("ERP API 오류: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/erp/budget/{project_code}")
|
||||
async def erp_budget(
|
||||
project_code: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
"""ERP 예산 데이터 조회 및 프로젝트와 동기화."""
|
||||
if not ERP_BASE:
|
||||
return {
|
||||
"enabled": False,
|
||||
"message": "ERP_BASE_URL 미설정",
|
||||
"fallback": "GUARDiA 내부 예산 데이터 사용 중",
|
||||
}
|
||||
|
||||
# ERP에서 예산 데이터 조회
|
||||
erp_data = await _erp_get(f"/api/budget/{project_code}")
|
||||
|
||||
# GUARDiA 프로젝트 데이터
|
||||
from sqlalchemy import select as sel
|
||||
proj = (await db.execute(
|
||||
sel(SiProject).where(SiProject.project_code == project_code)
|
||||
)).scalars().first()
|
||||
|
||||
if not proj:
|
||||
raise HTTPException(404, f"프로젝트 {project_code}를 찾을 수 없습니다.")
|
||||
|
||||
if erp_data:
|
||||
# ERP 데이터로 프로젝트 예산 갱신
|
||||
erp_total = erp_data.get("budget_total", 0)
|
||||
erp_used = erp_data.get("budget_used", 0)
|
||||
if erp_total and erp_total != proj.budget_total:
|
||||
proj.budget_total = erp_total
|
||||
proj.budget_used = erp_used
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"erp_type": ERP_TYPE,
|
||||
"project_code": project_code,
|
||||
"guardia_total": proj.budget_total,
|
||||
"guardia_used": proj.budget_used,
|
||||
"erp_data": erp_data,
|
||||
"synced": bool(erp_data),
|
||||
"sync_time": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/erp/status")
|
||||
async def erp_status(_u: User = Depends(get_current_user)):
|
||||
"""ERP 연동 설정 현황."""
|
||||
return {
|
||||
"enabled": bool(ERP_BASE),
|
||||
"erp_type": ERP_TYPE or "미설정",
|
||||
"base_url": ERP_BASE[:40] + "..." if ERP_BASE else "",
|
||||
"endpoints": {
|
||||
"budget": "/api/infra/erp/budget/{project_code}",
|
||||
}
|
||||
}
|
||||
282
itsm/routers/portfolio.py
Normal file
282
itsm/routers/portfolio.py
Normal file
@ -0,0 +1,282 @@
|
||||
"""
|
||||
포트폴리오 관리 + 리소스/인력 관리 API
|
||||
|
||||
포트폴리오:
|
||||
- 여러 SI 프로젝트 통합 현황 대시보드
|
||||
- KPI 집계 (전체 진척률 / 총 예산 / 위험 현황)
|
||||
|
||||
리소스:
|
||||
- 인원 배치 (역할·WBS·기간)
|
||||
- 역량 매핑 (기술스택·경험)
|
||||
- 인력 투입 현황 (월별 M/M)
|
||||
|
||||
엔드포인트:
|
||||
GET /api/portfolio/dashboard — 전체 프로젝트 포트폴리오
|
||||
GET /api/portfolio/kpi — 집계 KPI
|
||||
GET /api/portfolio/projects/{id}/resources — 프로젝트 인원 배치
|
||||
POST /api/portfolio/projects/{id}/resources — 인원 배치 등록
|
||||
GET /api/portfolio/resources/availability — 가용 인력 조회
|
||||
GET /api/portfolio/resources/{user}/skills — 역량 정보
|
||||
POST /api/portfolio/resources/{user}/skills — 역량 등록
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from database import get_db
|
||||
from models import (
|
||||
SiProject, WbsItem, ProjectIssue, ProjectRisk,
|
||||
User, UserRole,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/portfolio", tags=["portfolio"])
|
||||
|
||||
# 인메모리 저장소 (운영 시 DB 테이블로 이전)
|
||||
_resources: dict[int, list] = {} # project_id → [{user, role, wbs_code, mm, ...}]
|
||||
_skills: dict[str, list] = {} # username → [{skill, level, years}]
|
||||
|
||||
|
||||
# ── 포트폴리오 대시보드 ───────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/dashboard")
|
||||
async def portfolio_dashboard(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
"""전체 활성 SI 프로젝트 포트폴리오 현황."""
|
||||
projects = (await db.execute(
|
||||
select(SiProject).where(SiProject.is_active == True)
|
||||
.order_by(SiProject.planned_start.desc())
|
||||
)).scalars().all()
|
||||
|
||||
result = []
|
||||
for proj in projects:
|
||||
# WBS 완료율
|
||||
wbs = (await db.execute(
|
||||
select(WbsItem).where(WbsItem.project_id == proj.id, WbsItem.is_leaf == True)
|
||||
)).scalars().all()
|
||||
progress = round(sum(w.completion_pct for w in wbs) / len(wbs), 1) if wbs else 0
|
||||
|
||||
# 미결 이슈
|
||||
open_issues = (await db.execute(
|
||||
select(func.count(ProjectIssue.id)).where(
|
||||
ProjectIssue.project_id == proj.id,
|
||||
ProjectIssue.status.notin_(["RESOLVED", "CLOSED"])
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
# 고위험
|
||||
high_risks = (await db.execute(
|
||||
select(func.count(ProjectRisk.id)).where(
|
||||
ProjectRisk.project_id == proj.id,
|
||||
ProjectRisk.risk_level.in_(["HIGH", "CRITICAL"])
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
# 일정 지연 여부
|
||||
today = date.today()
|
||||
is_delayed = bool(
|
||||
proj.planned_end and proj.planned_end < today and progress < 100
|
||||
)
|
||||
|
||||
result.append({
|
||||
"project_id": proj.id,
|
||||
"project_code": proj.project_code,
|
||||
"project_name": proj.project_name,
|
||||
"phase": proj.phase,
|
||||
"health": proj.health_status,
|
||||
"progress": progress,
|
||||
"budget_used_pct": round(proj.budget_used / proj.budget_total * 100, 1)
|
||||
if proj.budget_total else 0,
|
||||
"open_issues": open_issues,
|
||||
"high_risks": high_risks,
|
||||
"is_delayed": is_delayed,
|
||||
"planned_end": str(proj.planned_end) if proj.planned_end else None,
|
||||
"pm": proj.pm_user,
|
||||
"resources": len(_resources.get(proj.id, [])),
|
||||
})
|
||||
|
||||
return {
|
||||
"total_projects": len(result),
|
||||
"active": sum(1 for p in result if p["phase"] not in ("CLOSED",)),
|
||||
"delayed": sum(1 for p in result if p["is_delayed"]),
|
||||
"at_risk": sum(1 for p in result if p["high_risks"] > 0),
|
||||
"projects": result,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/kpi")
|
||||
async def portfolio_kpi(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
"""포트폴리오 집계 KPI."""
|
||||
projects = (await db.execute(
|
||||
select(SiProject).where(SiProject.is_active == True)
|
||||
)).scalars().all()
|
||||
|
||||
total_budget = sum((p.budget_total or 0) for p in projects)
|
||||
total_used = sum((p.budget_used or 0) for p in projects)
|
||||
|
||||
wbs_all = (await db.execute(
|
||||
select(WbsItem).where(WbsItem.is_leaf == True)
|
||||
)).scalars().all()
|
||||
avg_progress = round(
|
||||
sum(w.completion_pct for w in wbs_all) / len(wbs_all), 1
|
||||
) if wbs_all else 0
|
||||
|
||||
open_issues = (await db.execute(
|
||||
select(func.count(ProjectIssue.id)).where(
|
||||
ProjectIssue.status.notin_(["RESOLVED", "CLOSED"])
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
return {
|
||||
"total_projects": len(projects),
|
||||
"avg_progress": avg_progress,
|
||||
"total_budget_man": total_budget,
|
||||
"total_used_man": total_used,
|
||||
"budget_rate": round(total_used / total_budget * 100, 1) if total_budget else 0,
|
||||
"open_issues": open_issues,
|
||||
"by_phase": {
|
||||
phase: sum(1 for p in projects if p.phase == phase)
|
||||
for phase in ["INITIATION", "ANALYSIS", "DESIGN", "IMPLEMENTATION", "DELIVERY", "CLOSED"]
|
||||
},
|
||||
"by_health": {
|
||||
h: sum(1 for p in projects if p.health_status == h)
|
||||
for h in ["GREEN", "YELLOW", "RED"]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── 인원 배치 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class ResourceCreate(BaseModel):
|
||||
username: str
|
||||
role: str = "DEVELOPER" # PM | ANALYST | DESIGNER | DEVELOPER | TESTER | DBA
|
||||
wbs_codes: List[str] = [] # 담당 WBS 코드 목록
|
||||
start_date: date
|
||||
end_date: date
|
||||
mm: float = 1.0 # M/M (Man-Month) 투입율
|
||||
|
||||
|
||||
@router.get("/projects/{pid}/resources")
|
||||
async def list_project_resources(
|
||||
pid: int,
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
"""프로젝트 인원 배치 현황."""
|
||||
return {
|
||||
"project_id": pid,
|
||||
"resources": _resources.get(pid, []),
|
||||
"total_mm": sum(r["mm"] for r in _resources.get(pid, [])),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/projects/{pid}/resources", status_code=201)
|
||||
async def add_project_resource(
|
||||
pid: int,
|
||||
body: ResourceCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
cu: User = Depends(get_current_user),
|
||||
):
|
||||
"""인원 배치 등록."""
|
||||
proj = await db.get(SiProject, pid)
|
||||
if not proj:
|
||||
raise HTTPException(404, f"프로젝트 {pid}를 찾을 수 없습니다.")
|
||||
if cu.role not in (UserRole.ADMIN, UserRole.PM):
|
||||
raise HTTPException(403, "PM/ADMIN만 인원을 배치할 수 있습니다.")
|
||||
|
||||
entry = {
|
||||
"username": body.username,
|
||||
"role": body.role,
|
||||
"wbs_codes": body.wbs_codes,
|
||||
"start_date": str(body.start_date),
|
||||
"end_date": str(body.end_date),
|
||||
"mm": body.mm,
|
||||
"added_by": cu.username,
|
||||
"added_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
if pid not in _resources:
|
||||
_resources[pid] = []
|
||||
_resources[pid].append(entry)
|
||||
|
||||
return {"message": f"{body.username} 인원 배치 완료", "entry": entry}
|
||||
|
||||
|
||||
@router.get("/resources/availability")
|
||||
async def resource_availability(
|
||||
start: Optional[str] = None,
|
||||
end: Optional[str] = None,
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
"""기간별 가용 인력 조회."""
|
||||
# 배치된 인력 집계
|
||||
allocated: dict[str, float] = {}
|
||||
for pid, entries in _resources.items():
|
||||
for e in entries:
|
||||
user = e["username"]
|
||||
allocated[user] = allocated.get(user, 0) + e["mm"]
|
||||
|
||||
return {
|
||||
"allocated_users": [
|
||||
{"username": u, "total_mm": mm, "available_mm": max(0, 1.0 - mm)}
|
||||
for u, mm in allocated.items()
|
||||
],
|
||||
"note": "M/M 기반 가용성 — 1.0 = 전일 투입",
|
||||
}
|
||||
|
||||
|
||||
# ── 역량 관리 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class SkillEntry(BaseModel):
|
||||
skill: str
|
||||
category: str = "TECH" # TECH | PM | DOMAIN | TOOL
|
||||
level: str = "중급" # 초급 | 중급 | 고급 | 전문가
|
||||
years: int = 0
|
||||
certifications: List[str] = []
|
||||
|
||||
|
||||
@router.get("/resources/{username}/skills")
|
||||
async def get_skills(
|
||||
username: str,
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
"""사용자 역량 정보 조회."""
|
||||
return {
|
||||
"username": username,
|
||||
"skills": _skills.get(username, []),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/resources/{username}/skills", status_code=201)
|
||||
async def add_skill(
|
||||
username: str,
|
||||
body: SkillEntry,
|
||||
cu: User = Depends(get_current_user),
|
||||
):
|
||||
"""역량 등록 (본인 또는 PM/ADMIN)."""
|
||||
if cu.username != username and cu.role not in (UserRole.ADMIN, UserRole.PM):
|
||||
raise HTTPException(403, "본인 또는 PM/ADMIN만 역량을 등록할 수 있습니다.")
|
||||
|
||||
entry = {**body.model_dump(), "updated_at": datetime.utcnow().isoformat()}
|
||||
if username not in _skills:
|
||||
_skills[username] = []
|
||||
|
||||
# 동일 스킬 업데이트
|
||||
existing = next((i for i, s in enumerate(_skills[username]) if s["skill"] == body.skill), None)
|
||||
if existing is not None:
|
||||
_skills[username][existing] = entry
|
||||
else:
|
||||
_skills[username].append(entry)
|
||||
|
||||
return {"message": f"{username} 역량 '{body.skill}' 등록 완료"}
|
||||
300
itsm/routers/siem.py
Normal file
300
itsm/routers/siem.py
Normal file
@ -0,0 +1,300 @@
|
||||
"""
|
||||
SIEM 연동 API (ELK/Splunk/OpenSearch)
|
||||
|
||||
기능:
|
||||
1. GUARDiA 보안 이벤트 → SIEM 실시간 전송
|
||||
2. SIEM 경보 → GUARDiA 인시던트 자동 생성 (역방향)
|
||||
3. 이벤트 조회 / 통계
|
||||
|
||||
환경변수:
|
||||
SIEM_TYPE = elastic|splunk|opensearch|custom
|
||||
ELASTIC_URL = http://elasticsearch:9200
|
||||
ELASTIC_INDEX = guardia-events
|
||||
ELASTIC_API_KEY = ...
|
||||
SPLUNK_HEC_URL = http://splunk:8088/services/collector
|
||||
SPLUNK_HEC_TOKEN = ...
|
||||
OPENSEARCH_URL = http://opensearch:9200
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from database import get_db
|
||||
from models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/siem", tags=["siem"])
|
||||
|
||||
SIEM_TYPE = os.getenv("SIEM_TYPE", "")
|
||||
ELASTIC_URL = os.getenv("ELASTIC_URL", "")
|
||||
ELASTIC_INDEX = os.getenv("ELASTIC_INDEX", "guardia-events")
|
||||
ELASTIC_API_KEY = os.getenv("ELASTIC_API_KEY", "")
|
||||
SPLUNK_HEC_URL = os.getenv("SPLUNK_HEC_URL", "")
|
||||
SPLUNK_HEC_TOKEN= os.getenv("SPLUNK_HEC_TOKEN", "")
|
||||
OPENSEARCH_URL = os.getenv("OPENSEARCH_URL", "")
|
||||
|
||||
# 이벤트 버퍼 (운영 시 Redis Queue로 전환)
|
||||
_event_buffer: list[dict] = []
|
||||
MAX_BUFFER = 1000
|
||||
|
||||
|
||||
# ── 이벤트 스키마 ────────────────────────────────────────────────────────────
|
||||
|
||||
class SecurityEvent(BaseModel):
|
||||
event_type: str # LOGIN_FAIL | PRIVILEGE_ESCALATION | VULN_DETECTED | etc
|
||||
severity: str = "INFO" # INFO | LOW | MEDIUM | HIGH | CRITICAL
|
||||
source: str = "GUARDiA"
|
||||
user: Optional[str] = None
|
||||
resource: Optional[str] = None
|
||||
action: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
metadata: Optional[dict] = None
|
||||
|
||||
|
||||
class SIEMAlertRequest(BaseModel):
|
||||
"""SIEM에서 역방향으로 보내는 경보."""
|
||||
alert_id: str
|
||||
rule_name: str
|
||||
severity: str
|
||||
description: str
|
||||
source_ip: Optional[str] = None
|
||||
affected: Optional[str] = None
|
||||
metadata: Optional[dict] = None
|
||||
|
||||
|
||||
# ── SIEM 별 전송 ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def _send_elastic(events: list[dict]) -> bool:
|
||||
if not ELASTIC_URL:
|
||||
return False
|
||||
bulk_body = ""
|
||||
for ev in events:
|
||||
bulk_body += json.dumps({"index": {"_index": ELASTIC_INDEX}}) + "\n"
|
||||
bulk_body += json.dumps(ev) + "\n"
|
||||
try:
|
||||
headers: dict = {"Content-Type": "application/x-ndjson"}
|
||||
if ELASTIC_API_KEY:
|
||||
headers["Authorization"] = f"ApiKey {ELASTIC_API_KEY}"
|
||||
async with httpx.AsyncClient(timeout=10.0) as c:
|
||||
r = await c.post(f"{ELASTIC_URL}/_bulk", content=bulk_body, headers=headers)
|
||||
return r.status_code in (200, 201)
|
||||
except Exception as e:
|
||||
logger.warning("Elasticsearch 전송 실패: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
async def _send_splunk(events: list[dict]) -> bool:
|
||||
if not SPLUNK_HEC_URL or not SPLUNK_HEC_TOKEN:
|
||||
return False
|
||||
payload = "\n".join(json.dumps({"event": ev, "sourcetype": "guardia"}) for ev in events)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as c:
|
||||
r = await c.post(
|
||||
SPLUNK_HEC_URL,
|
||||
content=payload,
|
||||
headers={"Authorization": f"Splunk {SPLUNK_HEC_TOKEN}", "Content-Type": "application/json"},
|
||||
)
|
||||
return r.status_code == 200
|
||||
except Exception as e:
|
||||
logger.warning("Splunk HEC 전송 실패: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
async def _send_opensearch(events: list[dict]) -> bool:
|
||||
if not OPENSEARCH_URL:
|
||||
return False
|
||||
bulk_body = ""
|
||||
for ev in events:
|
||||
bulk_body += json.dumps({"index": {"_index": ELASTIC_INDEX}}) + "\n"
|
||||
bulk_body += json.dumps(ev) + "\n"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as c:
|
||||
r = await c.post(
|
||||
f"{OPENSEARCH_URL}/_bulk",
|
||||
content=bulk_body,
|
||||
headers={"Content-Type": "application/x-ndjson"},
|
||||
)
|
||||
return r.status_code in (200, 201)
|
||||
except Exception as e:
|
||||
logger.warning("OpenSearch 전송 실패: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
async def send_to_siem(events: list[dict]) -> bool:
|
||||
"""SIEM 유형에 따라 이벤트 전송."""
|
||||
t = SIEM_TYPE.lower()
|
||||
if t == "elastic":
|
||||
return await _send_elastic(events)
|
||||
elif t == "splunk":
|
||||
return await _send_splunk(events)
|
||||
elif t == "opensearch":
|
||||
return await _send_opensearch(events)
|
||||
elif ELASTIC_URL:
|
||||
return await _send_elastic(events)
|
||||
elif SPLUNK_HEC_URL:
|
||||
return await _send_splunk(events)
|
||||
elif OPENSEARCH_URL:
|
||||
return await _send_opensearch(events)
|
||||
else:
|
||||
logger.debug("SIEM 미설정 — 이벤트 버퍼에만 저장")
|
||||
return False
|
||||
|
||||
|
||||
def _build_event(ev: SecurityEvent, actor: str = "system") -> dict:
|
||||
return {
|
||||
"@timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
"event_type": ev.event_type,
|
||||
"severity": ev.severity,
|
||||
"source": ev.source,
|
||||
"user": ev.user or actor,
|
||||
"resource": ev.resource,
|
||||
"action": ev.action,
|
||||
"description": ev.description,
|
||||
"metadata": ev.metadata or {},
|
||||
"tags": ["guardia", "itsm"],
|
||||
}
|
||||
|
||||
|
||||
# ── 이벤트 발송 API ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/events")
|
||||
async def push_event(
|
||||
body: SecurityEvent,
|
||||
bg: BackgroundTasks,
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
"""보안 이벤트를 SIEM으로 전송."""
|
||||
ev = _build_event(body, _u.username)
|
||||
|
||||
# 버퍼에 저장
|
||||
_event_buffer.append(ev)
|
||||
if len(_event_buffer) > MAX_BUFFER:
|
||||
_event_buffer.pop(0)
|
||||
|
||||
# SIEM 전송 (백그라운드)
|
||||
bg.add_task(send_to_siem, [ev])
|
||||
|
||||
return {
|
||||
"message": "이벤트가 전송되었습니다.",
|
||||
"event_type": ev["event_type"],
|
||||
"severity": ev["severity"],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/events/batch")
|
||||
async def push_events_batch(
|
||||
events: list[SecurityEvent],
|
||||
bg: BackgroundTasks,
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
"""여러 이벤트를 일괄 전송."""
|
||||
if len(events) > 100:
|
||||
raise HTTPException(400, "한 번에 최대 100개까지 전송 가능합니다.")
|
||||
|
||||
evs = [_build_event(e, _u.username) for e in events]
|
||||
_event_buffer.extend(evs[-MAX_BUFFER:])
|
||||
if len(_event_buffer) > MAX_BUFFER:
|
||||
_event_buffer[:] = _event_buffer[-MAX_BUFFER:]
|
||||
|
||||
bg.add_task(send_to_siem, evs)
|
||||
return {"message": f"{len(evs)}개 이벤트 전송", "count": len(evs)}
|
||||
|
||||
|
||||
# ── SIEM 역방향 경보 수신 ────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/alert/receive")
|
||||
async def receive_siem_alert(
|
||||
body: SIEMAlertRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""SIEM 경보 수신 → GUARDiA 인시던트 자동 생성 (ADMIN 인증 불필요 — webhook)."""
|
||||
from models import Incident, IncidentGrade, IncidentStatus
|
||||
from uuid import uuid4
|
||||
|
||||
grade_map = {"CRITICAL": IncidentGrade.P1, "HIGH": IncidentGrade.P2,
|
||||
"MEDIUM": IncidentGrade.P3, "LOW": IncidentGrade.P4}
|
||||
grade = grade_map.get(body.severity.upper(), IncidentGrade.P3)
|
||||
|
||||
incident = Incident(
|
||||
incident_id = f"INC-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:6].upper()}",
|
||||
title = f"[SIEM] {body.rule_name}",
|
||||
description = (
|
||||
f"SIEM 경보 자동 수신\n"
|
||||
f"규칙: {body.rule_name}\n"
|
||||
f"설명: {body.description}\n"
|
||||
f"소스 IP: {body.source_ip or '미상'}\n"
|
||||
f"영향 자산: {body.affected or '미상'}"
|
||||
),
|
||||
grade = grade,
|
||||
status = IncidentStatus.OPEN,
|
||||
occurred_at = datetime.now(),
|
||||
affected_service = body.affected,
|
||||
reported_by = f"SIEM:{body.alert_id}",
|
||||
)
|
||||
db.add(incident)
|
||||
await db.commit()
|
||||
|
||||
# P1/P2 즉시 알림
|
||||
if grade in (IncidentGrade.P1, IncidentGrade.P2):
|
||||
try:
|
||||
from core.notify import send_messenger
|
||||
import os as _os
|
||||
await send_messenger(
|
||||
_os.getenv("MESSENGER_OPS_ROOM", "ops"),
|
||||
{"type": "text", "text": f"🚨 SIEM 경보 [{grade}]\n{body.rule_name}\n{body.description[:200]}"}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"message": f"인시던트 {incident.incident_id} 자동 생성",
|
||||
"incident_id": incident.incident_id,
|
||||
"grade": grade,
|
||||
}
|
||||
|
||||
|
||||
# ── 이벤트 조회 / 통계 ───────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/events")
|
||||
async def list_events(
|
||||
limit: int = 50,
|
||||
severity: Optional[str] = None,
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
"""최근 보안 이벤트 목록 (버퍼에서 조회)."""
|
||||
events = _event_buffer[-limit:][::-1]
|
||||
if severity:
|
||||
events = [e for e in events if e.get("severity", "").upper() == severity.upper()]
|
||||
return {"total": len(_event_buffer), "events": events[:limit]}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def siem_stats(_u: User = Depends(get_current_user)):
|
||||
"""SIEM 연동 현황 통계."""
|
||||
by_sev: dict = {}
|
||||
by_type: dict = {}
|
||||
for ev in _event_buffer:
|
||||
sev = ev.get("severity", "INFO")
|
||||
etype= ev.get("event_type", "UNKNOWN")
|
||||
by_sev[sev] = by_sev.get(sev, 0) + 1
|
||||
by_type[etype] = by_type.get(etype, 0) + 1
|
||||
|
||||
return {
|
||||
"siem_type": SIEM_TYPE or "미설정",
|
||||
"elastic_url": ELASTIC_URL[:30] + "..." if ELASTIC_URL else "",
|
||||
"splunk_url": SPLUNK_HEC_URL[:30] + "..." if SPLUNK_HEC_URL else "",
|
||||
"total_events": len(_event_buffer),
|
||||
"by_severity": by_sev,
|
||||
"by_type": by_type,
|
||||
"configured": bool(ELASTIC_URL or SPLUNK_HEC_URL or OPENSEARCH_URL),
|
||||
}
|
||||
317
itsm/routers/topology.py
Normal file
317
itsm/routers/topology.py
Normal file
@ -0,0 +1,317 @@
|
||||
"""
|
||||
네트워크 토폴로지 시각화 API
|
||||
|
||||
CMDB CI 의존관계를 D3.js force-directed graph 형식으로 반환.
|
||||
프론트엔드에서 /api/topology/graph 데이터를 받아 D3.js로 렌더링.
|
||||
|
||||
엔드포인트:
|
||||
GET /api/topology/graph — 전체 CI 의존관계 그래프 (nodes/links)
|
||||
GET /api/topology/graph/{ci_id} — 특정 CI 중심 서브그래프
|
||||
GET /api/topology/health — 서버별 헬스 오버레이 데이터
|
||||
GET /api/topology/page — D3.js 토폴로지 뷰어 HTML
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from database import get_db
|
||||
from models import ConfigItem, CIRelation, Server, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/topology", tags=["topology"])
|
||||
|
||||
# 노드 타입별 색상
|
||||
NODE_COLORS = {
|
||||
"SERVER": "#60a5fa", "WAS": "#34d399", "DB": "#f59e0b",
|
||||
"NETWORK": "#a78bfa", "STORAGE": "#fb923c","LOAD_BALANCER": "#f472b6",
|
||||
"FIREWALL": "#f87171","CDN": "#6ee7b7", "DEFAULT": "#94a3b8",
|
||||
}
|
||||
|
||||
|
||||
async def _build_graph(db: AsyncSession, root_ci_id: Optional[int] = None,
|
||||
max_depth: int = 3) -> dict:
|
||||
"""CMDB CI 관계에서 그래프 데이터 생성."""
|
||||
# 전체 CI 조회
|
||||
if root_ci_id:
|
||||
# BFS로 root에서 max_depth 깊이까지
|
||||
visited = set()
|
||||
queue = [root_ci_id]
|
||||
ci_ids = set()
|
||||
depth = {root_ci_id: 0}
|
||||
|
||||
while queue:
|
||||
current = queue.pop(0)
|
||||
if current in visited:
|
||||
continue
|
||||
visited.add(current)
|
||||
ci_ids.add(current)
|
||||
|
||||
if depth.get(current, 0) >= max_depth:
|
||||
continue
|
||||
|
||||
rels = (await db.execute(
|
||||
select(CIRelation).where(
|
||||
(CIRelation.from_ci_id == current) | (CIRelation.to_ci_id == current)
|
||||
)
|
||||
)).scalars().all()
|
||||
|
||||
for rel in rels:
|
||||
nxt = rel.to_ci_id if rel.from_ci_id == current else rel.from_ci_id
|
||||
if nxt not in visited:
|
||||
queue.append(nxt)
|
||||
depth[nxt] = depth.get(current, 0) + 1
|
||||
|
||||
cis = (await db.execute(select(ConfigItem).where(ConfigItem.id.in_(ci_ids)))).scalars().all()
|
||||
rels = (await db.execute(
|
||||
select(CIRelation).where(
|
||||
CIRelation.from_ci_id.in_(ci_ids), CIRelation.to_ci_id.in_(ci_ids)
|
||||
)
|
||||
)).scalars().all()
|
||||
else:
|
||||
cis = (await db.execute(select(ConfigItem).limit(200))).scalars().all()
|
||||
rels = (await db.execute(select(CIRelation).limit(500))).scalars().all()
|
||||
|
||||
# 서버 헬스 데이터
|
||||
server_status: dict = {}
|
||||
if cis:
|
||||
servers = (await db.execute(select(Server))).scalars().all()
|
||||
for s in servers:
|
||||
server_status[s.server_name] = s.status if hasattr(s, "status") else "ACTIVE"
|
||||
|
||||
# nodes
|
||||
nodes = []
|
||||
for ci in cis:
|
||||
ci_type = (ci.ci_type or "DEFAULT").upper()
|
||||
color = NODE_COLORS.get(ci_type, NODE_COLORS["DEFAULT"])
|
||||
srv_stat = server_status.get(ci.name, "UNKNOWN")
|
||||
nodes.append({
|
||||
"id": ci.id,
|
||||
"name": ci.name,
|
||||
"type": ci_type,
|
||||
"category": ci.category or "",
|
||||
"status": ci.status or "ACTIVE",
|
||||
"color": color,
|
||||
"health": srv_stat,
|
||||
"owner": ci.owner or "",
|
||||
"is_root": ci.id == root_ci_id,
|
||||
})
|
||||
|
||||
# links
|
||||
rel_type_labels = {
|
||||
"DEPENDS_ON": "의존",
|
||||
"RUNS_ON": "실행",
|
||||
"CONNECTS_TO": "연결",
|
||||
"BACKS_UP": "백업",
|
||||
"MONITORS": "모니터",
|
||||
}
|
||||
links = [
|
||||
{
|
||||
"source": r.from_ci_id,
|
||||
"target": r.to_ci_id,
|
||||
"type": r.relation_type if hasattr(r, "relation_type") else "CONNECTS_TO",
|
||||
"label": rel_type_labels.get(
|
||||
r.relation_type if hasattr(r, "relation_type") else "", "연결"
|
||||
),
|
||||
}
|
||||
for r in rels
|
||||
]
|
||||
|
||||
return {
|
||||
"nodes": nodes,
|
||||
"links": links,
|
||||
"node_count": len(nodes),
|
||||
"link_count": len(links),
|
||||
"root_ci_id": root_ci_id,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/graph")
|
||||
async def full_graph(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
"""전체 CI 의존관계 그래프."""
|
||||
return await _build_graph(db)
|
||||
|
||||
|
||||
@router.get("/graph/{ci_id}")
|
||||
async def subgraph(
|
||||
ci_id: int,
|
||||
depth: int = Query(2, ge=1, le=4),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
"""특정 CI 중심 서브그래프."""
|
||||
ci = await db.get(ConfigItem, ci_id)
|
||||
if not ci:
|
||||
raise HTTPException(404, f"CI ID {ci_id}를 찾을 수 없습니다.")
|
||||
return await _build_graph(db, root_ci_id=ci_id, max_depth=depth)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def topology_health(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
"""서버 헬스 오버레이 데이터 (실시간)."""
|
||||
servers = (await db.execute(select(Server))).scalars().all()
|
||||
return {
|
||||
"servers": [
|
||||
{
|
||||
"name": s.server_name,
|
||||
"status": getattr(s, "status", "UNKNOWN"),
|
||||
"os": s.os_type if hasattr(s, "os_type") else "",
|
||||
"inst": s.inst_id,
|
||||
}
|
||||
for s in servers
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/page", response_class=HTMLResponse)
|
||||
async def topology_page(_u: User = Depends(get_current_user)):
|
||||
"""D3.js 인터랙티브 토폴로지 뷰어."""
|
||||
html = """<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>GUARDiA 네트워크 토폴로지</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
|
||||
<style>
|
||||
body { margin:0; background:#0f172a; color:#e2e8f0; font-family:Arial,sans-serif; }
|
||||
#topology-header { padding:12px 20px; background:#1e293b; border-bottom:1px solid #334155; display:flex; align-items:center; gap:12px; }
|
||||
#topology-header h1 { margin:0; font-size:16px; color:#818cf8; }
|
||||
#controls { display:flex; gap:8px; margin-left:auto; }
|
||||
.btn { padding:5px 12px; border-radius:6px; border:1px solid #334155; background:#1e293b; color:#e2e8f0; cursor:pointer; font-size:12px; }
|
||||
.btn:hover { background:#334155; }
|
||||
#tooltip { position:fixed; background:#1e293b; border:1px solid #334155; border-radius:8px; padding:10px 14px; font-size:12px; pointer-events:none; opacity:0; transition:opacity .15s; max-width:220px; }
|
||||
#legend { position:fixed; bottom:20px; left:20px; background:#1e293b; border:1px solid #334155; border-radius:8px; padding:12px 16px; font-size:11px; }
|
||||
.legend-item { display:flex; align-items:center; gap:8px; margin:4px 0; }
|
||||
.legend-dot { width:12px; height:12px; border-radius:50%; }
|
||||
#stats { position:fixed; top:60px; right:20px; background:#1e293b; border:1px solid #334155; border-radius:8px; padding:12px 16px; font-size:12px; }
|
||||
svg { width:100vw; height:calc(100vh - 52px); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="topology-header">
|
||||
<h1>🌐 네트워크 토폴로지</h1>
|
||||
<div id="controls">
|
||||
<button class="btn" onclick="resetZoom()">리셋</button>
|
||||
<button class="btn" onclick="toggleLabels()">레이블</button>
|
||||
<button class="btn" onclick="refreshData()">새로고침</button>
|
||||
<button class="btn" onclick="history.back()">← 닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tooltip"></div>
|
||||
<div id="stats">노드: <b id="node-count">0</b> | 링크: <b id="link-count">0</b></div>
|
||||
<div id="legend">
|
||||
<div style="font-weight:700;margin-bottom:6px">노드 유형</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#60a5fa"></div>서버</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#34d399"></div>WAS</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#f59e0b"></div>DB</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#a78bfa"></div>네트워크</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#fb923c"></div>스토리지</div>
|
||||
<div style="margin-top:8px;font-weight:700">헬스 상태</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#22c55e;border:2px solid #16a34a"></div>정상</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#eab308;border:2px solid #ca8a04"></div>주의</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:#ef4444;border:2px solid #dc2626"></div>위험</div>
|
||||
</div>
|
||||
<svg id="topo-svg"></svg>
|
||||
<script>
|
||||
const token = localStorage.getItem('access_token') || '';
|
||||
let showLabels = true;
|
||||
let simulation, svg, g;
|
||||
|
||||
async function loadData() {
|
||||
const r = await fetch('/api/topology/graph', { headers: { 'Authorization': 'Bearer ' + token } });
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function refreshData() { render(await loadData()); }
|
||||
|
||||
function resetZoom() { svg.call(zoom.transform, d3.zoomIdentity); }
|
||||
function toggleLabels() {
|
||||
showLabels = !showLabels;
|
||||
g.selectAll('.node-label').attr('opacity', showLabels ? 1 : 0);
|
||||
}
|
||||
|
||||
const healthColor = { ACTIVE:'#22c55e', MAINTENANCE:'#eab308', INACTIVE:'#ef4444', UNKNOWN:'#94a3b8' };
|
||||
|
||||
async function render(data) {
|
||||
document.getElementById('node-count').textContent = data.node_count;
|
||||
document.getElementById('link-count').textContent = data.link_count;
|
||||
|
||||
const svgEl = document.getElementById('topo-svg');
|
||||
const W = svgEl.clientWidth, H = svgEl.clientHeight;
|
||||
d3.select('#topo-svg').selectAll('*').remove();
|
||||
|
||||
svg = d3.select('#topo-svg');
|
||||
const zoom = d3.zoom().scaleExtent([0.1, 4]).on('zoom', e => g.attr('transform', e.transform));
|
||||
svg.call(zoom);
|
||||
g = svg.append('g');
|
||||
|
||||
const nodes = data.nodes.map(d => ({...d}));
|
||||
const links = data.links.map(d => ({...d}));
|
||||
|
||||
simulation = d3.forceSimulation(nodes)
|
||||
.force('link', d3.forceLink(links).id(d => d.id).distance(100))
|
||||
.force('charge', d3.forceManyBody().strength(-300))
|
||||
.force('center', d3.forceCenter(W/2, H/2))
|
||||
.force('collision', d3.forceCollide(30));
|
||||
|
||||
// 링크
|
||||
const link = g.append('g').selectAll('line').data(links).join('line')
|
||||
.attr('stroke', '#334155').attr('stroke-width', 1.5).attr('stroke-opacity', 0.6);
|
||||
|
||||
// 노드 그룹
|
||||
const node = g.append('g').selectAll('g').data(nodes).join('g')
|
||||
.call(d3.drag()
|
||||
.on('start', (e,d) => { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx=d.x; d.fy=d.y; })
|
||||
.on('drag', (e,d) => { d.fx=e.x; d.fy=e.y; })
|
||||
.on('end', (e,d) => { if (!e.active) simulation.alphaTarget(0); d.fx=null; d.fy=null; }));
|
||||
|
||||
node.append('circle')
|
||||
.attr('r', 16)
|
||||
.attr('fill', d => d.color)
|
||||
.attr('stroke', d => healthColor[d.health] || '#94a3b8')
|
||||
.attr('stroke-width', 2.5)
|
||||
.attr('opacity', 0.9);
|
||||
|
||||
// 루트 노드 강조
|
||||
node.filter(d => d.is_root).append('circle').attr('r', 22)
|
||||
.attr('fill', 'none').attr('stroke', '#f59e0b').attr('stroke-width', 2).attr('stroke-dasharray', '4 2');
|
||||
|
||||
node.append('text').attr('class','node-label')
|
||||
.attr('text-anchor', 'middle').attr('dy', 26)
|
||||
.attr('fill', '#cbd5e1').attr('font-size', 10)
|
||||
.text(d => d.name.substring(0,16));
|
||||
|
||||
// 툴팁
|
||||
const tooltip = document.getElementById('tooltip');
|
||||
node.on('mouseover', (e, d) => {
|
||||
tooltip.style.opacity = 1;
|
||||
tooltip.innerHTML = `<b>${d.name}</b><br>유형: ${d.type}<br>상태: ${d.health || d.status}<br>담당: ${d.owner || '미지정'}`;
|
||||
}).on('mousemove', e => {
|
||||
tooltip.style.left = (e.clientX+12)+'px';
|
||||
tooltip.style.top = (e.clientY-8)+'px';
|
||||
}).on('mouseout', () => { tooltip.style.opacity = 0; });
|
||||
|
||||
simulation.on('tick', () => {
|
||||
link.attr('x1', d=>d.source.x).attr('y1', d=>d.source.y)
|
||||
.attr('x2', d=>d.target.x).attr('y2', d=>d.target.y);
|
||||
node.attr('transform', d => `translate(${d.x},${d.y})`);
|
||||
});
|
||||
}
|
||||
|
||||
loadData().then(render);
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
return HTMLResponse(html)
|
||||
724
manual/16_API_명세서.md
Normal file
724
manual/16_API_명세서.md
Normal file
@ -0,0 +1,724 @@
|
||||
# GUARDiA ITSM — 전체 기능 목록 및 API 명세서
|
||||
|
||||
> **버전:** 2.0.0 | **총 라우트:** 588개 | **기준일:** 2026-05-30
|
||||
> **Base URL:** `http://localhost:8001`
|
||||
> **인증:** JWT Bearer Token (`POST /api/auth/login` → `access_token`)
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
| 번호 | 도메인 | 엔드포인트 수 |
|
||||
|------|--------|-------------|
|
||||
| 1 | [인증 / 사용자 관리](#1-인증--사용자-관리) | 16 |
|
||||
| 2 | [SR 서비스 요청](#2-sr-서비스-요청) | 9 |
|
||||
| 3 | [승인 워크플로우](#3-승인-워크플로우) | 6 |
|
||||
| 4 | [대시보드](#4-대시보드) | 3 |
|
||||
| 5 | [CMDB / 인프라](#5-cmdb--인프라) | 17 |
|
||||
| 6 | [PMS (프로젝트 관리)](#6-pms-프로젝트-관리) | 91 |
|
||||
| 7 | [인시던트 / 장애](#7-인시던트--장애) | 11 |
|
||||
| 8 | [문제 관리 (Problem)](#8-문제-관리) | 13 |
|
||||
| 9 | [변경 관리 (CAB)](#9-변경-관리-cab) | 18 |
|
||||
| 10 | [보안](#10-보안) | 38 |
|
||||
| 11 | [AI / 자동화](#11-ai--자동화) | 48 |
|
||||
| 12 | [모니터링](#12-모니터링) | 25 |
|
||||
| 13 | [운영 관리](#13-운영-관리) | 47 |
|
||||
| 14 | [통합 연동](#14-통합-연동) | 34 |
|
||||
| 15 | [준수성 / 품질](#15-준수성--품질) | 18 |
|
||||
| 16 | [고객 포털](#16-고객-포털) | 9 |
|
||||
| 17 | [포트폴리오 / 리소스](#17-포트폴리오--리소스) | 8 |
|
||||
| 18 | [인프라 확장](#18-인프라-확장) | 7 |
|
||||
| 19 | [메신저 봇](#19-메신저-봇) | 2 |
|
||||
| 20 | [라이선스](#20-라이선스) | 6 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 인증 / 사용자 관리
|
||||
|
||||
| Method | Endpoint | 설명 | 인증 |
|
||||
|--------|----------|------|------|
|
||||
| POST | `/api/auth/login` | 로그인 (JWT 발급) | ❌ |
|
||||
| POST | `/api/auth/login/mfa` | MFA 2단계 인증 | ❌ |
|
||||
| POST | `/api/auth/logout` | 로그아웃 | ✅ |
|
||||
| GET | `/api/auth/me` | 내 정보 조회 | ✅ |
|
||||
| POST | `/api/auth/change-password` | 비밀번호 변경 | ✅ |
|
||||
| GET | `/api/auth/users` | 사용자 목록 | ADMIN |
|
||||
| GET | `/api/auth/mfa/status` | MFA 상태 조회 | ✅ |
|
||||
| POST | `/api/auth/mfa/setup` | MFA 등록 시작 | ✅ |
|
||||
| POST | `/api/auth/mfa/enable` | MFA 활성화 | ✅ |
|
||||
| POST | `/api/auth/mfa/disable` | MFA 비활성화 | ✅ |
|
||||
| POST | `/api/auth/admin/users/{id}/mfa-reset` | MFA 초기화 | ADMIN |
|
||||
| POST | `/api/auth/admin/users/{id}/unlock` | 계정 잠금 해제 | ADMIN |
|
||||
| GET | `/api/auth/admin/users/{id}/lock-status` | 잠금 상태 조회 | ADMIN |
|
||||
| GET | `/api/auth/oauth/providers` | OAuth 제공자 목록 | ❌ |
|
||||
| GET | `/api/auth/oauth/{provider}/start` | OAuth 로그인 시작 | ❌ |
|
||||
| GET | `/api/auth/oauth/{provider}/callback` | OAuth 콜백 | ❌ |
|
||||
|
||||
**OAuth 지원 플랫폼:** Google, GitHub, 카카오, 네이버, Keycloak(SSO)
|
||||
|
||||
---
|
||||
|
||||
## 2. SR 서비스 요청
|
||||
|
||||
| Method | Endpoint | 설명 | 인증 |
|
||||
|--------|----------|------|------|
|
||||
| GET | `/api/tasks` | SR 목록 (필터: status/type/priority/keyword) | ✅ |
|
||||
| POST | `/api/tasks` | SR 생성 (AI 자동 분류 + SLA 자동 설정) | ❌ |
|
||||
| GET | `/api/tasks/stats` | SR 통계 | ✅ |
|
||||
| GET | `/api/tasks/sla/violations` | SLA 위반 목록 | ✅ |
|
||||
| POST | `/api/tasks/bulk` | SR 대량 처리 (최대 100건) | ✅ |
|
||||
| GET | `/api/tasks/{sr_id}` | SR 상세 조회 | ✅ |
|
||||
| PATCH| `/api/tasks/{sr_id}/status` | SR 상태 변경 | ✅ |
|
||||
| GET | `/api/tasks/{sr_id}/sla` | SLA 현황 조회 | ✅ |
|
||||
| GET | `/api/tasks/{sr_id}/ai-suggestion` | AI 분류 결과 조회 | ✅ |
|
||||
|
||||
**상태 전이:** RECEIVED → PARSED → PENDING_APPROVAL → APPROVED → IN_PROGRESS → PENDING_PM_VALIDATION → COMPLETED
|
||||
|
||||
**Bulk action:** `STATUS_CHANGE | ASSIGN | CLOSE | PRIORITY_CHANGE`
|
||||
|
||||
---
|
||||
|
||||
## 3. 승인 워크플로우
|
||||
|
||||
| Method | Endpoint | 설명 | 인증 |
|
||||
|--------|----------|------|------|
|
||||
| GET | `/api/approvals/{sr_id}` | SR 승인 목록 | ✅ |
|
||||
| POST | `/api/approvals/{sr_id}` | 승인/반려 처리 | PM/ADMIN |
|
||||
| GET | `/api/approvals/pending/overdue` | 기한 초과 승인 목록 | PM/ADMIN |
|
||||
| POST | `/api/approvals/{id}/delegate` | 결재 위임 | ✅ |
|
||||
| POST | `/api/approvals/{id}/sign` | 전자서명 | ✅ |
|
||||
| POST | `/api/approvals/{id}/extend-deadline` | 마감 연장 | PM/ADMIN |
|
||||
|
||||
---
|
||||
|
||||
## 4. 대시보드
|
||||
|
||||
| Method | Endpoint | 설명 | 인증 |
|
||||
|--------|----------|------|------|
|
||||
| GET | `/api/dashboard/me` | 역할별 개인화 대시보드 | ✅ |
|
||||
| GET | `/api/dashboard/stats/trend` | 7일 SR 추이 | ✅ |
|
||||
| GET | `/api/dashboard/events` | SSE 실시간 이벤트 스트림 | ✅ |
|
||||
|
||||
**역할별 응답:**
|
||||
- ADMIN: 전체 현황 + 엔지니어 워크로드 + 승인 대기
|
||||
- PM: 승인 큐 + 기관 현황
|
||||
- ENGINEER: 내 담당 SR + 이달 통계
|
||||
- CUSTOMER: 내 기관 SR + 만족도
|
||||
|
||||
---
|
||||
|
||||
## 5. CMDB / 인프라
|
||||
|
||||
### CI (Configuration Item)
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/cmdb/ci` | CI 목록 |
|
||||
| POST | `/api/cmdb/ci` | CI 등록 |
|
||||
| GET | `/api/cmdb/ci/stats` | CI 통계 |
|
||||
| POST | `/api/cmdb/ci/import-servers` | 서버 일괄 CI 변환 |
|
||||
| GET | `/api/cmdb/ci/{ci_id}` | CI 상세 |
|
||||
| PATCH | `/api/cmdb/ci/{ci_id}` | CI 수정 |
|
||||
| DELETE | `/api/cmdb/ci/{ci_id}` | CI 삭제 |
|
||||
| GET | `/api/cmdb/ci/{ci_id}/relations` | CI 의존관계 |
|
||||
| GET | `/api/cmdb/ci/{ci_id}/history` | CI 변경 이력 |
|
||||
| POST | `/api/cmdb/ci/relations` | CI 관계 등록 |
|
||||
| DELETE | `/api/cmdb/ci/relations/{id}` | CI 관계 삭제 |
|
||||
|
||||
### 서버 / 기관
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/cmdb/servers` | 서버 목록 |
|
||||
| POST | `/api/cmdb/servers` | 서버 등록 |
|
||||
| GET | `/api/cmdb/servers/{id}` | 서버 상세 |
|
||||
| PATCH | `/api/cmdb/servers/{id}` | 서버 수정 |
|
||||
| GET | `/api/cmdb/institutions` | 기관 목록 |
|
||||
| GET | `/api/cmdb/institutions/{code}/servers` | 기관별 서버 |
|
||||
|
||||
---
|
||||
|
||||
## 6. PMS (프로젝트 관리)
|
||||
|
||||
### 프로젝트
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/si/projects` | 프로젝트 목록 |
|
||||
| POST | `/api/si/projects` | 프로젝트 생성 |
|
||||
| GET | `/api/si/projects/{id}` | 프로젝트 상세 |
|
||||
| PATCH | `/api/si/projects/{id}` | 프로젝트 수정 |
|
||||
| DELETE | `/api/si/projects/{id}` | 프로젝트 삭제 |
|
||||
| PATCH | `/api/si/projects/{id}/phase` | 단계 전환 |
|
||||
| GET | `/api/si/projects/{id}/summary` | 진척 요약 |
|
||||
| GET | `/api/si/projects/{id}/rtm` | RTM (요구사항↔WBS↔TC) |
|
||||
| GET | `/api/si/projects/{id}/checklist` | 단계별 체크리스트 |
|
||||
| PATCH | `/api/si/projects/{id}/checklist/{cid}` | 체크리스트 완료 |
|
||||
| POST | `/api/si/projects/{id}/convert-to-sm` | SM 전환 |
|
||||
|
||||
### WBS
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/si/projects/{id}/wbs` | WBS 목록 |
|
||||
| POST | `/api/si/projects/{id}/wbs` | WBS 항목 등록 |
|
||||
| POST | `/api/si/projects/{id}/wbs/bulk` | WBS 일괄 등록 |
|
||||
| GET | `/api/si/projects/{id}/wbs/gantt` | Gantt 차트 데이터 |
|
||||
| GET | `/api/si/projects/{id}/wbs/{item_id}` | WBS 상세 |
|
||||
| PATCH | `/api/si/projects/{id}/wbs/{item_id}` | WBS 수정 |
|
||||
| PATCH | `/api/si/projects/{id}/wbs/{item_id}/progress` | 진척률 갱신 |
|
||||
| DELETE | `/api/si/projects/{id}/wbs/{item_id}` | WBS 삭제 |
|
||||
|
||||
### 산출물 (Deliverables)
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/si/projects/{id}/deliverables` | 산출물 목록 |
|
||||
| POST | `/api/si/projects/{id}/deliverables` | 산출물 등록 |
|
||||
| GET | `/api/si/projects/{id}/deliverables/summary` | 제출 현황 요약 |
|
||||
| GET | `/api/si/projects/{id}/deliverables/{did}` | 산출물 상세 |
|
||||
| PATCH | `/api/si/projects/{id}/deliverables/{did}` | 산출물 수정 |
|
||||
| DELETE | `/api/si/projects/{id}/deliverables/{did}` | 산출물 삭제 |
|
||||
| POST | `/api/si/projects/{id}/deliverables/{did}/submit` | 제출 처리 |
|
||||
| POST | `/api/si/projects/{id}/deliverables/{did}/review` | 검토 결과 등록 |
|
||||
|
||||
### 보고서
|
||||
|
||||
| Method | Endpoint | 설명 | 형식 |
|
||||
|--------|----------|------|------|
|
||||
| GET | `/api/si/projects/{id}/report/daily` | 일일 보고서 | `?format=excel\|html\|pdf\|docx\|pptx` |
|
||||
| GET | `/api/si/projects/{id}/report/weekly` | 주간 보고서 | 동일 |
|
||||
| GET | `/api/si/projects/{id}/report/monthly` | 월간 보고서 | 동일 |
|
||||
| GET | `/api/si/projects/{id}/report/status` | 현황 JSON | — |
|
||||
| POST | `/api/si/projects/{id}/report/send` | 메신저 자동 발송 | — |
|
||||
|
||||
### 이슈 / 위험 / 마일스톤 / 요구사항 / 변경요청 / 테스트
|
||||
|
||||
각 도메인별 CRUD + 상태 전환 엔드포인트 (약 50개)
|
||||
|
||||
---
|
||||
|
||||
## 7. 인시던트 / 장애
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/incidents` | 장애 목록 |
|
||||
| POST | `/api/incidents` | 장애 등록 |
|
||||
| GET | `/api/incidents/stats` | 통계 |
|
||||
| GET | `/api/incidents/{id}` | 장애 상세 |
|
||||
| PATCH | `/api/incidents/{id}` | 장애 수정 |
|
||||
| PATCH | `/api/incidents/{id}/status` | 상태 변경 |
|
||||
| POST | `/api/incidents/{id}/close` | 종료 (RCA 포함) |
|
||||
| POST | `/api/incidents/{id}/auto-rca` | **AI 자동 RCA 분석** |
|
||||
| POST | `/api/incidents/{id}/link-sr` | SR 연결 |
|
||||
| DELETE | `/api/incidents/{id}/link-sr/{sr_id}` | SR 연결 해제 |
|
||||
| GET | `/api/incidents/{id}/srs` | 연결된 SR 목록 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 문제 관리
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/problem/` | 문제 목록 |
|
||||
| POST | `/api/problem/` | 문제 등록 |
|
||||
| GET | `/api/problem/known-errors` | Known Error DB |
|
||||
| GET | `/api/problem/stats` | 통계 |
|
||||
| GET | `/api/problem/{id}` | 문제 상세 |
|
||||
| PATCH | `/api/problem/{id}` | 문제 수정 |
|
||||
| POST | `/api/problem/{id}/auto-rca` | **AI 자동 RCA** |
|
||||
| POST | `/api/problem/{id}/rca` | RCA 직접 기록 |
|
||||
| POST | `/api/problem/{id}/workaround` | 임시 해결 등록 |
|
||||
| POST | `/api/problem/{id}/resolve` | 해결 처리 |
|
||||
| POST | `/api/problem/{id}/close` | 종결 |
|
||||
| POST | `/api/problem/{id}/notes` | 활동 노트 추가 |
|
||||
| GET | `/api/problem/{id}/notes` | 활동 노트 목록 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 변경 관리 (CAB)
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET/POST | `/api/change/rfc` | RFC 목록/등록 |
|
||||
| GET | `/api/change/rfc/{id}` | RFC 상세 |
|
||||
| PATCH | `/api/change/rfc/{id}` | RFC 수정 |
|
||||
| POST | `/api/change/rfc/{id}/submit` | 제출 |
|
||||
| POST | `/api/change/rfc/{id}/vote` | CAB 투표 |
|
||||
| POST | `/api/change/rfc/{id}/decide` | 결정 |
|
||||
| POST | `/api/change/rfc/{id}/schedule` | 일정 확정 |
|
||||
| POST | `/api/change/rfc/{id}/start` | 구현 시작 |
|
||||
| POST | `/api/change/rfc/{id}/complete` | 완료 |
|
||||
| POST | `/api/change/rfc/{id}/fail` | 실패 처리 |
|
||||
| GET | `/api/change/rfc/{id}/votes` | 투표 결과 |
|
||||
| GET/POST | `/api/change/freeze` | 동결 기간 관리 |
|
||||
| GET | `/api/change/freeze/check` | 동결 여부 확인 |
|
||||
| DELETE | `/api/change/freeze/{id}` | 동결 삭제 |
|
||||
| GET | `/api/change/calendar` | 변경 캘린더 |
|
||||
| GET | `/api/change/stats` | 통계 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 보안
|
||||
|
||||
### 취약점 스캔 (D-4)
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| POST | `/api/vuln/scan` | 서버 취약점 스캔 |
|
||||
| GET | `/api/vuln/scans` | 스캔 이력 |
|
||||
| GET | `/api/vuln/scans/{id}` | 스캔 결과 |
|
||||
| POST | `/api/vuln/quick-check` | 빠른 점검 |
|
||||
| GET | `/api/vuln/cve/{cve_id}` | CVE 상세 |
|
||||
| POST | `/api/vuln/cvss` | CVSS 점수 계산 |
|
||||
| GET | `/api/vuln/stats` | 통계 |
|
||||
| GET | `/api/vuln/policies` | 스캔 정책 |
|
||||
| GET | `/api/vuln/patches` | 패치 이력 |
|
||||
| POST | `/api/vuln/scans/{id}/patch` | 패치 완료 등록 |
|
||||
| GET | `/api/vuln/patch-stats` | 패치 현황 통계 |
|
||||
| GET | `/api/vuln/overdue-patches` | 미패치 목록 |
|
||||
|
||||
### PAM 특권 접근 관리 (D-3)
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| POST | `/api/pam/sessions` | PAM 세션 발급 |
|
||||
| GET | `/api/pam/sessions` | 세션 목록 |
|
||||
| POST | `/api/pam/sessions/{id}/checkout` | 세션 체크아웃 |
|
||||
| POST | `/api/pam/sessions/{id}/checkin` | 세션 반납 |
|
||||
| POST | `/api/pam/sessions/{id}/approve` | 승인 |
|
||||
| POST | `/api/pam/sessions/{id}/reject` | 거부 |
|
||||
| POST | `/api/pam/sessions/{id}/execute` | 명령 실행 |
|
||||
| POST | `/api/pam/sessions/{id}/terminate` | 강제 종료 |
|
||||
| GET | `/api/pam/sessions/{id}/commands` | 명령 이력 |
|
||||
| GET | `/api/pam/policies` | 접근 정책 |
|
||||
| GET | `/api/pam/stats` | 통계 |
|
||||
|
||||
### 감사 로그 (D-5, 불변 해시체인)
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/audit` | 감사 로그 목록 |
|
||||
| GET | `/api/audit/{id}` | 로그 상세 |
|
||||
| POST | `/api/audit/record` | 수동 로그 기록 |
|
||||
| GET | `/api/audit/stats` | 통계 |
|
||||
| GET | `/api/audit/verify` | 해시체인 검증 |
|
||||
| GET | `/api/audit/verify/{from}/{to}` | 구간 검증 |
|
||||
| GET | `/api/audit/export` | 감사 로그 내보내기 |
|
||||
| GET | `/api/audit/entity/{type}/{id}` | 엔티티별 로그 |
|
||||
|
||||
### LDAP/AD 연동 (D-1)
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| POST | `/api/ldap/authenticate` | LDAP 인증 |
|
||||
| GET | `/api/ldap/config` | 설정 조회 |
|
||||
| PUT | `/api/ldap/config` | 설정 변경 |
|
||||
| POST | `/api/ldap/test` | 연결 테스트 |
|
||||
| POST | `/api/ldap/sync/{username}` | 사용자 동기화 |
|
||||
| GET | `/api/ldap/users` | LDAP 사용자 목록 |
|
||||
| GET/PUT | `/api/ldap/group-map` | 그룹↔역할 매핑 |
|
||||
| GET | `/api/ldap/status` | 연결 상태 |
|
||||
|
||||
---
|
||||
|
||||
## 11. AI / 자동화
|
||||
|
||||
### AI 에이전트 시스템
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET/POST | `/api/agents` | 에이전트 목록/등록 |
|
||||
| GET | `/api/agents/stats` | 통계 |
|
||||
| POST | `/api/agents/{id}/heartbeat` | 하트비트 |
|
||||
| GET/POST | `/api/agents/{id}/tasks` | 에이전트 태스크 |
|
||||
| POST | `/api/agents/{id}/pause/resume` | 일시정지/재개 |
|
||||
| POST | `/api/agents/finetune/start` | 파인튜닝 시작 |
|
||||
| GET | `/api/agents/llm/health` | Ollama 상태 |
|
||||
|
||||
### 이상 탐지 (B-1)
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| POST | `/api/anomaly/detect` | 이상 탐지 실행 |
|
||||
| GET | `/api/anomaly/summary` | 탐지 요약 |
|
||||
| GET | `/api/anomaly/events` | 이벤트 목록 |
|
||||
| PATCH | `/api/anomaly/events/{id}/acknowledge` | 인지 처리 |
|
||||
| PATCH | `/api/anomaly/events/{id}/resolve` | 해결 처리 |
|
||||
| GET/POST | `/api/anomaly/rules` | 규칙 관리 |
|
||||
| POST | `/api/anomaly/metrics` | 메트릭 수집 |
|
||||
| POST | `/api/anomaly/simulate` | 시뮬레이션 |
|
||||
|
||||
### 예측 유지보수 (B-6)
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| POST | `/api/predictive/analyze/{source}` | 예측 분석 |
|
||||
| GET | `/api/predictive/health/{source}` | 건강도 조회 |
|
||||
| GET | `/api/predictive/lifecycle` | 수명 예측 |
|
||||
| POST | `/api/predictive/batch` | 일괄 분석 |
|
||||
| GET/PUT | `/api/predictive/thresholds` | 임계값 관리 |
|
||||
|
||||
### 챗봇 (B-2)
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| POST | `/api/chatbot/message` | 메시지 전송 |
|
||||
| GET | `/api/chatbot/sessions` | 세션 목록 |
|
||||
| DELETE | `/api/chatbot/sessions/{key}` | 세션 삭제 |
|
||||
| GET | `/api/chatbot/history/{key}` | 대화 이력 |
|
||||
|
||||
### 코드 리뷰 (B-3)
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| POST | `/api/code-review` | 심층 리뷰 |
|
||||
| POST | `/api/code-review/quick-scan` | 빠른 스캔 |
|
||||
| GET | `/api/code-review/{id}` | 리뷰 결과 |
|
||||
| GET | `/api/code-review/{id}/findings` | 발견 항목 |
|
||||
|
||||
### KB 자동 업데이트 (B-4) / 오케스트레이션 (B-5)
|
||||
|
||||
다수의 에이전트 협업 및 KB 관리 엔드포인트 (약 15개)
|
||||
|
||||
### 학습 루프 (Self-Improving)
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| POST | `/api/learning/detect-recurrence` | 재발 패턴 탐지 |
|
||||
| GET | `/api/learning/patterns` | 학습 패턴 |
|
||||
| GET | `/api/learning/lessons` | 교훈 목록 |
|
||||
| GET | `/api/learning/stats` | 학습 통계 |
|
||||
| POST | `/api/learning/mine` | 패턴 마이닝 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 모니터링
|
||||
|
||||
### Scouter APM
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/scouter/status` | 연결 상태 + 현황 |
|
||||
| GET | `/api/scouter/servers` | 모니터링 서버 목록 |
|
||||
| GET | `/api/scouter/servers/{hash}/metrics` | 실시간 메트릭 |
|
||||
| GET | `/api/scouter/servers/{hash}/services` | 활성 서비스 |
|
||||
| GET | `/api/scouter/servers/{hash}/xlog` | X-Log (트랜잭션) |
|
||||
| GET | `/api/scouter/alerts` | 경보 목록 |
|
||||
| POST | `/api/scouter/agent/deploy` | 에이전트 SSH 배포 |
|
||||
|
||||
### SSL 인증서 관리
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/ssl/summary` | 만료 현황 요약 |
|
||||
| GET | `/api/ssl/expiring` | 만료 임박 목록 |
|
||||
| POST | `/api/ssl/check/{id}` | 인증서 점검 |
|
||||
| POST | `/api/ssl/renew/{id}` | 갱신 요청 |
|
||||
| GET | `/api/ssl/history/{id}` | 이력 조회 |
|
||||
|
||||
### 네트워크 토폴로지
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/topology/graph` | 전체 CI 그래프 (D3.js) |
|
||||
| GET | `/api/topology/graph/{ci_id}` | 서브그래프 |
|
||||
| GET | `/api/topology/health` | 헬스 오버레이 |
|
||||
| GET | `/api/topology/page` | 인터랙티브 뷰어 HTML |
|
||||
|
||||
### 메트릭 / Grafana
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/metrics/prometheus` | Prometheus 형식 |
|
||||
| GET | `/api/metrics/summary` | 메트릭 요약 |
|
||||
| GET | `/api/metrics/health` | 헬스체크 |
|
||||
| POST | `/api/metrics/query` | 메트릭 조회 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 운영 관리
|
||||
|
||||
### 배포 파이프라인 (Vibe)
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET/POST | `/api/vibe` | 세션 목록/생성 |
|
||||
| POST | `/api/vibe/{id}/build` | 빌드 트리거 |
|
||||
| POST | `/api/vibe/{id}/deploy` | 배포 트리거 |
|
||||
| POST | `/api/vibe/{id}/impact-analysis` | **배포 영향 분석** |
|
||||
| POST | `/api/vibe/{id}/request-approval` | 승인 요청 |
|
||||
| GET | `/api/vibe/{id}/build/stream` | 빌드 로그 SSE |
|
||||
|
||||
### 배치 작업
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET/POST | `/api/batch/jobs` | 배치 목록/등록 |
|
||||
| POST | `/api/batch/jobs/{id}/enable` | 활성화 |
|
||||
| POST | `/api/batch/jobs/{id}/run` | 즉시 실행 |
|
||||
| GET | `/api/batch/runs/{id}/stream` | 실행 로그 SSE |
|
||||
|
||||
### On-Call 관리
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/oncall/on-duty` | 현재 당직자 |
|
||||
| GET | `/api/oncall/today` | 오늘 당직 |
|
||||
| POST | `/api/oncall/escalate` | 에스컬레이션 |
|
||||
| POST | `/api/oncall/rotate/trigger` | 수동 로테이션 |
|
||||
|
||||
### 작업 타임테이블
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/timetable` | 작업 목록 |
|
||||
| POST | `/api/timetable` | 작업 등록 |
|
||||
| GET | `/api/timetable/stats` | 통계 |
|
||||
| GET | `/api/timetable/export/excel` | Excel 다운로드 |
|
||||
|
||||
### PM (예방 점검)
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET/POST | `/api/pm/schedules` | PM 스케줄 관리 |
|
||||
| POST | `/api/pm/schedules/{id}/trigger` | 수동 실행 |
|
||||
| GET | `/api/pm/results/{id}` | 점검 결과 |
|
||||
| GET | `/api/pm/results/{id}/report` | 점검 보고서 |
|
||||
|
||||
---
|
||||
|
||||
## 14. 통합 연동
|
||||
|
||||
### 외부 API 게이트웨이
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET/POST | `/api/gateway/integrations` | 연동 목록/등록 |
|
||||
| POST | `/api/gateway/integrations/{id}/test` | 연동 테스트 |
|
||||
| POST | `/api/gateway/webhook/{key}` | 웹훅 수신 |
|
||||
| POST | `/api/gateway/send/{id}` | 아웃바운드 발송 |
|
||||
|
||||
### Jira/Confluence 연동 (G-9)
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| POST | `/api/gateway/jira/sync/{sr_id}` | SR → Jira 이슈 |
|
||||
| GET | `/api/gateway/jira/status/{key}` | Jira 상태 조회 |
|
||||
| GET | `/api/gateway/jira/projects` | Jira 프로젝트 목록 |
|
||||
| POST | `/api/gateway/confluence/publish` | KB → Confluence |
|
||||
|
||||
### 그룹웨어 전자결재
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| POST | `/api/groupware/send-approval` | 결재 요청 발송 |
|
||||
| POST | `/api/groupware/callback` | 승인/반려 콜백 |
|
||||
| GET | `/api/groupware/approvals` | 결재 이력 |
|
||||
| GET | `/api/groupware/config` | 연동 설정 현황 |
|
||||
|
||||
**지원 플랫폼:** 카카오워크, 네이버웍스, 한컴오피스, 사용자 정의 웹훅
|
||||
|
||||
### SIEM 보안 이벤트
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| POST | `/api/siem/events` | 이벤트 전송 |
|
||||
| POST | `/api/siem/events/batch` | 일괄 전송 |
|
||||
| POST | `/api/siem/alert/receive` | SIEM 경보 수신 (역방향) |
|
||||
| GET | `/api/siem/events` | 이벤트 조회 |
|
||||
| GET | `/api/siem/stats` | 연동 통계 |
|
||||
|
||||
**지원 플랫폼:** Elasticsearch, Splunk HEC, OpenSearch
|
||||
|
||||
### 메신저 봇
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| POST | `/api/messenger/webhook` | 이벤트 수신 |
|
||||
| POST | `/api/messenger/bot/command` | 봇 명령어 처리 |
|
||||
|
||||
**슬래시 명령어:**
|
||||
|
||||
| 명령어 | 기능 |
|
||||
|--------|------|
|
||||
| `/sr <제목>` | SR 빠른 접수 |
|
||||
| `/status` | 시스템 현황 |
|
||||
| `/license` | 라이선스 상태 |
|
||||
| `/bulk <action> <SR-IDs>` | SR 대량 처리 |
|
||||
| `/pms <코드>` | 프로젝트 현황 |
|
||||
| `/report <코드> [daily\|weekly\|monthly]` | 보고서 발송 |
|
||||
| `/deliverables <코드>` | 산출물 현황 |
|
||||
| `/issues <코드>` | 미결 이슈 |
|
||||
| `/scan` | 보안 점검 |
|
||||
| `/checklist` | 공공기관 체크리스트 |
|
||||
| `/perf [url]` | 성능 테스트 |
|
||||
| `!vibe`, `!build`, `!deploy` | 배포 제어 |
|
||||
| `!sm`, `!health`, `!log` | SM 원격 제어 |
|
||||
|
||||
---
|
||||
|
||||
## 15. 준수성 / 품질
|
||||
|
||||
### 시큐어코딩 / 웹접근성 / 개인정보보호
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| POST | `/api/compliance/scan` | 전체 프로젝트 스캔 |
|
||||
| GET | `/api/compliance/results` | 최근 결과 조회 |
|
||||
| GET | `/api/compliance/rules` | 점검 규칙 목록 |
|
||||
| POST | `/api/compliance/scan/file` | 단건 파일 점검 |
|
||||
| GET | `/api/compliance/report/html` | HTML 보고서 |
|
||||
| GET | `/api/compliance/report/excel` | Excel 보고서 |
|
||||
|
||||
**점검 규칙:** SC-8개 (시큐어코딩) + WA-7개 (웹접근성) + PI-6개 (개인정보)
|
||||
|
||||
### 성능 테스트 (JMeter)
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| POST | `/api/perf/upload/jtl` | JTL 파일 업로드 분석 |
|
||||
| POST | `/api/perf/run` | 내장 부하 테스트 |
|
||||
| GET | `/api/perf/results` | 결과 목록 |
|
||||
| GET | `/api/perf/results/{id}` | 결과 상세 |
|
||||
| GET | `/api/perf/results/{id}/html` | HTML 보고서 |
|
||||
| GET | `/api/perf/results/{id}/excel` | Excel 보고서 |
|
||||
|
||||
**측정 지표:** TPS, 평균/P50/P90/P95/P99 응답시간, 에러율
|
||||
|
||||
### 공공기관 체크리스트
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/public/checklist` | 19개 항목 목록 |
|
||||
| POST | `/api/public/checklist/{id}/check` | 항목 완료 처리 |
|
||||
| GET | `/api/public/status` | 이행 현황 요약 |
|
||||
| GET | `/api/public/report/html` | HTML 보고서 |
|
||||
|
||||
**법적 근거:** 행안부 정보화사업 관리지침, 개인정보보호법, 장애인차별금지법
|
||||
|
||||
---
|
||||
|
||||
## 16. 고객 포털
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| POST | `/api/portal/sr` | SR 접수 (AI 자동분류) |
|
||||
| GET | `/api/portal/sr` | 내 SR 목록 |
|
||||
| GET | `/api/portal/sr/{sr_id}` | SR 상세 + 타임라인 |
|
||||
| POST | `/api/portal/sr/{sr_id}/rate` | 처리 만족도 평가 |
|
||||
| POST | `/api/portal/faq/suggest` | **AI 자가해결 추천** |
|
||||
| GET | `/api/portal/catalog` | 서비스 카탈로그 |
|
||||
| GET | `/api/portal/announcements` | 공지사항 |
|
||||
| GET | `/api/portal/stats` | 내 기관 통계 |
|
||||
|
||||
---
|
||||
|
||||
## 17. 포트폴리오 / 리소스
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/portfolio/dashboard` | 전체 프로젝트 포트폴리오 |
|
||||
| GET | `/api/portfolio/kpi` | 집계 KPI |
|
||||
| GET | `/api/portfolio/projects/{id}/resources` | 인원 배치 현황 |
|
||||
| POST | `/api/portfolio/projects/{id}/resources` | 인원 배치 등록 |
|
||||
| GET | `/api/portfolio/resources/availability` | 가용 인력 조회 |
|
||||
| GET | `/api/portfolio/resources/{user}/skills` | 역량 조회 |
|
||||
| POST | `/api/portfolio/resources/{user}/skills` | 역량 등록 |
|
||||
|
||||
---
|
||||
|
||||
## 18. 인프라 확장
|
||||
|
||||
### Zero Trust 지속 인증
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| POST | `/api/infra/zero-trust/verify` | 세션 재검증 |
|
||||
| GET | `/api/infra/zero-trust/sessions` | 활성 세션 목록 |
|
||||
|
||||
### Kubernetes 모니터링
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/infra/k8s/pods` | 파드 목록/상태 |
|
||||
| GET | `/api/infra/k8s/services` | 서비스 목록 |
|
||||
| GET | `/api/infra/k8s/nodes` | 노드 리소스 |
|
||||
|
||||
### ERP 예산 연동
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/infra/erp/budget/{code}` | 예산 조회 + 동기화 |
|
||||
| GET | `/api/infra/erp/status` | 연동 설정 현황 |
|
||||
|
||||
---
|
||||
|
||||
## 19. 메신저 봇 (상세)
|
||||
|
||||
```
|
||||
POST /api/messenger/webhook
|
||||
Body: MessengerEvent { event, room, sr_id, title, ... }
|
||||
응답: { ok: true }
|
||||
|
||||
POST /api/messenger/bot/command
|
||||
Body: BotCommand { room, user, command }
|
||||
응답: BotReply { room, text }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 20. 라이선스
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/license/status` | 라이선스 상태 |
|
||||
| POST | `/api/license/trial` | 7일 무료 체험 시작 |
|
||||
| POST | `/api/license/activate` | 라이선스 키 등록 |
|
||||
| POST | `/api/license/verify` | 키 검증 (등록 없음) |
|
||||
| DELETE | `/api/license/` | 비활성화 |
|
||||
| GET | `/api/license/history` | 등록 이력 |
|
||||
|
||||
**에디션:** TRIAL (7일) / COMMUNITY (무료) / STANDARD / ENTERPRISE
|
||||
|
||||
---
|
||||
|
||||
## 공통 규칙
|
||||
|
||||
### 인증
|
||||
|
||||
```http
|
||||
Authorization: Bearer {access_token}
|
||||
```
|
||||
|
||||
### 응답 코드
|
||||
|
||||
| 코드 | 의미 |
|
||||
|------|------|
|
||||
| 200 | 성공 |
|
||||
| 201 | 생성 성공 |
|
||||
| 204 | 삭제 성공 |
|
||||
| 400 | 잘못된 요청 |
|
||||
| 401 | 인증 필요 |
|
||||
| 403 | 권한 없음 |
|
||||
| 404 | 리소스 없음 |
|
||||
| 409 | 충돌 (중복) |
|
||||
| 422 | 유효성 검증 실패 |
|
||||
| 500 | 서버 오류 |
|
||||
| 503 | 외부 서비스 불가 (Ollama/SIEM/K8s 미설정) |
|
||||
|
||||
### 보안 정책
|
||||
|
||||
- 서버 자격증명 (IP, SSH 비밀번호) API 응답에서 완전 제외
|
||||
- 스택트레이스 미노출 — SR ID + 요약 메시지만 반환
|
||||
- AES-256-GCM 암호화 저장 (os_pw_enc)
|
||||
- root SSH 직접 접속 금지 — opsagent 계정 전용
|
||||
- 모든 LLM 추론: localhost:11434 (Ollama) 전용, 외부 API 절대 금지
|
||||
|
||||
### Copyright
|
||||
|
||||
```
|
||||
Copyright © 2026 GUARDiA All Rights Reserved.
|
||||
X-Powered-By: GUARDiA ITSM 2.0
|
||||
X-Copyright: Copyright 2026 GUARDiA All Rights Reserved
|
||||
```
|
||||
Loading…
Reference in New Issue
Block a user