feat(expansion): GUARDiA v3 P2 — 5 routers + 5 DB tables
라우터 (611개 엔드포인트, P1+P2 75개 신규): - kubernetes.py: K8s 에이전트리스 관리 (SSH kubectl) - sso_provider.py: SAML 2.0 / OIDC / OAuth2 통합 인증 - predictive_ops.py: SLA위반·SR급증·서버장애 예측 + Ollama 인사이트 - slack_connector.py: Slack Incoming Webhook + Slash Commands - white_label.py: 기관별 브랜딩 + CSS 변수 동적 생성 DB 모델 (5개 신규): tb_k8s_cluster, tb_sso_config, tb_sso_session, tb_slack_config, tb_tenant_branding 수정: K8sCluster ForeignKey tb_server → tb_server_info Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e7dc273b36
commit
09bab3c2ff
@ -316,6 +316,14 @@ app.include_router(tenant_portal.router) # 테넌트 셀프서비스 포
|
|||||||
app.include_router(bi_dashboard.router) # BI 대시보드 (트렌드·히트맵·퍼널)
|
app.include_router(bi_dashboard.router) # BI 대시보드 (트렌드·히트맵·퍼널)
|
||||||
app.include_router(autonomous_workflow.router) # 자율 워크플로우 엔진
|
app.include_router(autonomous_workflow.router) # 자율 워크플로우 엔진
|
||||||
|
|
||||||
|
# ── GUARDiA 확장 v3 P2 (2026-06-02) ──────────────────────────────────────────
|
||||||
|
from routers import kubernetes, sso_provider, predictive_ops, slack_connector, white_label
|
||||||
|
app.include_router(kubernetes.router) # K8s 클러스터 에이전트리스 관리
|
||||||
|
app.include_router(sso_provider.router) # SSO 통합 인증 (SAML/OIDC)
|
||||||
|
app.include_router(predictive_ops.router) # 예측 운영 분석 (SLA/SR급증/서버장애)
|
||||||
|
app.include_router(slack_connector.router) # Slack 연동 (알림/명령어)
|
||||||
|
app.include_router(white_label.router) # 화이트라벨 브랜딩
|
||||||
|
|
||||||
|
|
||||||
# ── 개방망 보안 헤더 미들웨어 ────────────────────────────────────────────────
|
# ── 개방망 보안 헤더 미들웨어 ────────────────────────────────────────────────
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
|
|||||||
@ -4817,3 +4817,96 @@ class AutoWorkflowRun(Base):
|
|||||||
error_message = Column(Text, nullable=True)
|
error_message = Column(Text, nullable=True)
|
||||||
started_at = Column(DateTime, default=func.now())
|
started_at = Column(DateTime, default=func.now())
|
||||||
finished_at = Column(DateTime, nullable=True)
|
finished_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ── GUARDiA 확장 v3 P2 — K8s / SSO / Slack / WhiteLabel ──────────────────────
|
||||||
|
|
||||||
|
class K8sCluster(Base):
|
||||||
|
"""Kubernetes 클러스터 등록 (SSH 경유 관리)."""
|
||||||
|
__tablename__ = "tb_k8s_cluster"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(Integer, nullable=False, index=True)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
ssh_server_id = Column(Integer, ForeignKey("tb_server_info.id"), nullable=False)
|
||||||
|
namespace = Column(String(100), default="default")
|
||||||
|
kubeconfig_path = Column(String(500), default="/root/.kube/config")
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class SSOConfig(Base):
|
||||||
|
"""SSO 통합 인증 설정 (SAML/OIDC/OAuth2)."""
|
||||||
|
__tablename__ = "tb_sso_config"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(Integer, nullable=False, index=True)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
provider_type = Column(String(20), nullable=False) # SAML|OIDC|OAUTH2
|
||||||
|
idp_metadata_url = Column(String(500), nullable=True)
|
||||||
|
idp_sso_url = Column(String(500), nullable=True)
|
||||||
|
idp_cert = Column(Text, nullable=True)
|
||||||
|
client_id = Column(String(200), nullable=True)
|
||||||
|
client_secret_enc = Column(Text, nullable=True) # AES-256-GCM 암호화
|
||||||
|
discovery_url = Column(String(500), nullable=True)
|
||||||
|
scopes = Column(String(200), default="openid email profile")
|
||||||
|
attribute_mapping = Column(Text, nullable=True) # JSON
|
||||||
|
default_role = Column(String(20), default="ENGINEER")
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class SSOSession(Base):
|
||||||
|
"""SSO 로그인 세션 추적."""
|
||||||
|
__tablename__ = "tb_sso_session"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("tb_user.id"), nullable=False)
|
||||||
|
config_id = Column(Integer, ForeignKey("tb_sso_config.id"), nullable=False)
|
||||||
|
state = Column(String(100), nullable=True, index=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
expires_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SlackConfig(Base):
|
||||||
|
"""Slack 연동 설정."""
|
||||||
|
__tablename__ = "tb_slack_config"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(Integer, nullable=False, unique=True, index=True)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
webhook_url = Column(String(500), nullable=False)
|
||||||
|
signing_secret = Column(String(200), nullable=True)
|
||||||
|
default_channel = Column(String(100), default="#guardia-ops")
|
||||||
|
notify_sr_create = Column(Boolean, default=True)
|
||||||
|
notify_incident = Column(Boolean, default=True)
|
||||||
|
notify_deploy = Column(Boolean, default=True)
|
||||||
|
notify_sla_breach = Column(Boolean, default=True)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class TenantBranding(Base):
|
||||||
|
"""테넌트 화이트라벨 브랜딩 설정."""
|
||||||
|
__tablename__ = "tb_tenant_branding"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(Integer, nullable=False, unique=True, index=True)
|
||||||
|
company_name = Column(String(200), nullable=True)
|
||||||
|
logo_url = Column(String(500), nullable=True)
|
||||||
|
logo_dark_url = Column(String(500), nullable=True)
|
||||||
|
favicon_url = Column(String(500), nullable=True)
|
||||||
|
primary_color = Column(String(7), nullable=True) # #RRGGBB
|
||||||
|
secondary_color = Column(String(7), nullable=True)
|
||||||
|
accent_color = Column(String(7), nullable=True)
|
||||||
|
font_family = Column(String(200), nullable=True)
|
||||||
|
login_bg_color = Column(String(7), nullable=True)
|
||||||
|
header_bg_color = Column(String(7), nullable=True)
|
||||||
|
custom_domain = Column(String(200), nullable=True)
|
||||||
|
footer_text = Column(String(500), nullable=True)
|
||||||
|
email_header_html = Column(Text, nullable=True)
|
||||||
|
email_footer_html = Column(Text, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||||
|
|||||||
369
workspace/guardia-itsm/routers/kubernetes.py
Normal file
369
workspace/guardia-itsm/routers/kubernetes.py
Normal file
@ -0,0 +1,369 @@
|
|||||||
|
"""
|
||||||
|
Kubernetes 클러스터 관리 — 에이전트리스 (SSH 경유 kubectl)
|
||||||
|
|
||||||
|
기존 SSH 인프라(routers/ssh.py, routers/infra_ext.py)를 재사용하여
|
||||||
|
대상 서버에 소프트웨어 설치 없이 kubectl 명령을 SSH 경유로 실행.
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
GET /api/k8s/clusters — 등록된 클러스터 목록
|
||||||
|
POST /api/k8s/clusters — 클러스터 등록
|
||||||
|
DELETE /api/k8s/clusters/{id} — 클러스터 삭제
|
||||||
|
GET /api/k8s/clusters/{id}/nodes — 노드 목록 + 상태
|
||||||
|
GET /api/k8s/clusters/{id}/pods — Pod 목록 (네임스페이스별)
|
||||||
|
GET /api/k8s/clusters/{id}/deploys — Deployment 목록
|
||||||
|
POST /api/k8s/clusters/{id}/rollout — Deployment 롤링 업데이트
|
||||||
|
GET /api/k8s/clusters/{id}/events — 클러스터 이벤트 (WARNING 필터)
|
||||||
|
GET /api/k8s/clusters/{id}/metrics — 노드 리소스 사용률
|
||||||
|
POST /api/k8s/clusters/{id}/sr — 이상 감지 → SR 자동 생성
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.auth import get_current_user, require_admin_role
|
||||||
|
from database import get_db
|
||||||
|
from models import User, Server, SRRequest, SRStatus, K8sCluster # 신규 모델
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/k8s", tags=["Kubernetes"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pydantic 스키마 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ClusterCreate(BaseModel):
|
||||||
|
name: str = Field(..., max_length=100)
|
||||||
|
description: Optional[str] = None
|
||||||
|
ssh_server_id: int = Field(..., description="SSH 경유할 마스터 노드 서버 ID")
|
||||||
|
namespace: str = Field("default", max_length=100)
|
||||||
|
kubeconfig_path: str = Field("/root/.kube/config", description="마스터 노드의 kubeconfig 경로")
|
||||||
|
|
||||||
|
class RolloutRequest(BaseModel):
|
||||||
|
namespace: str = "default"
|
||||||
|
deployment: str
|
||||||
|
image: Optional[str] = None # 특정 이미지로 업데이트 (None=재시작만)
|
||||||
|
|
||||||
|
|
||||||
|
# ── SSH 경유 kubectl 실행 ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _kubectl(server: Server, cmd: str, kubeconfig: str = "/root/.kube/config") -> dict:
|
||||||
|
"""
|
||||||
|
SSH 경유 kubectl 실행 (에이전트리스 원칙).
|
||||||
|
server: tb_server 레코드 (ip, ssh_user, os_pw_enc)
|
||||||
|
"""
|
||||||
|
from core.crypto import decrypt_password # AES-256-GCM 복호화
|
||||||
|
|
||||||
|
try:
|
||||||
|
pw = decrypt_password(server.os_pw_enc)
|
||||||
|
ssh = paramiko.SSHClient()
|
||||||
|
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
ssh.connect(
|
||||||
|
server.ip_addr, username=server.ssh_user,
|
||||||
|
password=pw, timeout=15,
|
||||||
|
)
|
||||||
|
full_cmd = f"KUBECONFIG={kubeconfig} kubectl {cmd} 2>&1"
|
||||||
|
_, stdout, stderr = ssh.exec_command(full_cmd, timeout=30)
|
||||||
|
out = stdout.read().decode('utf-8', 'replace').strip()
|
||||||
|
ssh.close()
|
||||||
|
return {"ok": True, "output": out}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"kubectl 실행 실패: {e}")
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _kubectl_json(server: Server, cmd: str, kubeconfig: str) -> Optional[dict]:
|
||||||
|
"""kubectl -o json 결과를 dict로 파싱."""
|
||||||
|
result = await _kubectl(server, f"{cmd} -o json", kubeconfig)
|
||||||
|
if result["ok"]:
|
||||||
|
try:
|
||||||
|
return json.loads(result["output"])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 엔드포인트 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/clusters")
|
||||||
|
async def list_clusters(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
rows = await db.execute(
|
||||||
|
select(K8sCluster).where(K8sCluster.tenant_id == user.tenant_id)
|
||||||
|
)
|
||||||
|
clusters = rows.scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": c.id, "name": c.name, "description": c.description,
|
||||||
|
"namespace": c.namespace, "ssh_server_id": c.ssh_server_id,
|
||||||
|
"is_active": c.is_active, "created_at": c.created_at,
|
||||||
|
}
|
||||||
|
for c in clusters
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/clusters")
|
||||||
|
async def create_cluster(
|
||||||
|
req: ClusterCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""클러스터 등록. SSH 서버 존재 여부 확인 후 연결 테스트."""
|
||||||
|
srv_row = await db.execute(select(Server).where(Server.id == req.ssh_server_id))
|
||||||
|
server = srv_row.scalar_one_or_none()
|
||||||
|
if not server:
|
||||||
|
raise HTTPException(404, "SSH 서버를 찾을 수 없습니다")
|
||||||
|
|
||||||
|
# 연결 테스트
|
||||||
|
result = await _kubectl(server, "version --client --short", req.kubeconfig_path)
|
||||||
|
if not result["ok"]:
|
||||||
|
raise HTTPException(400, f"kubectl 연결 실패: {result.get('error', '')}")
|
||||||
|
|
||||||
|
cluster = K8sCluster(
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
name=req.name,
|
||||||
|
description=req.description,
|
||||||
|
ssh_server_id=req.ssh_server_id,
|
||||||
|
namespace=req.namespace,
|
||||||
|
kubeconfig_path=req.kubeconfig_path,
|
||||||
|
is_active=True,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(cluster)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(cluster)
|
||||||
|
return {"ok": True, "id": cluster.id, "kubectl_version": result["output"][:100]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/clusters/{cluster_id}")
|
||||||
|
async def delete_cluster(
|
||||||
|
cluster_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
row = await db.execute(
|
||||||
|
select(K8sCluster).where(K8sCluster.id == cluster_id, K8sCluster.tenant_id == user.tenant_id)
|
||||||
|
)
|
||||||
|
cluster = row.scalar_one_or_none()
|
||||||
|
if not cluster:
|
||||||
|
raise HTTPException(404, "클러스터를 찾을 수 없습니다")
|
||||||
|
await db.delete(cluster)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_cluster_server(cluster_id: int, tenant_id: int, db: AsyncSession):
|
||||||
|
row = await db.execute(
|
||||||
|
select(K8sCluster).where(K8sCluster.id == cluster_id, K8sCluster.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
cluster = row.scalar_one_or_none()
|
||||||
|
if not cluster:
|
||||||
|
raise HTTPException(404, "클러스터를 찾을 수 없습니다")
|
||||||
|
srv_row = await db.execute(select(Server).where(Server.id == cluster.ssh_server_id))
|
||||||
|
server = srv_row.scalar_one_or_none()
|
||||||
|
if not server:
|
||||||
|
raise HTTPException(404, "SSH 서버를 찾을 수 없습니다")
|
||||||
|
return cluster, server
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/clusters/{cluster_id}/nodes")
|
||||||
|
async def list_nodes(
|
||||||
|
cluster_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""노드 목록 + 상태 (Ready/NotReady/SchedulingDisabled)."""
|
||||||
|
cluster, server = await _get_cluster_server(cluster_id, user.tenant_id, db)
|
||||||
|
data = await _kubectl_json(server, "get nodes", cluster.kubeconfig_path)
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(500, "노드 목록 조회 실패")
|
||||||
|
|
||||||
|
nodes = []
|
||||||
|
for item in data.get("items", []):
|
||||||
|
name = item["metadata"]["name"]
|
||||||
|
conditions = item["status"].get("conditions", [])
|
||||||
|
ready = next((c for c in conditions if c["type"] == "Ready"), {})
|
||||||
|
capacity = item["status"].get("capacity", {})
|
||||||
|
allocatable = item["status"].get("allocatable", {})
|
||||||
|
nodes.append({
|
||||||
|
"name": name,
|
||||||
|
"status": "Ready" if ready.get("status") == "True" else "NotReady",
|
||||||
|
"roles": ",".join(
|
||||||
|
k.split("/")[-1]
|
||||||
|
for k in item["metadata"].get("labels", {})
|
||||||
|
if "node-role.kubernetes.io" in k
|
||||||
|
) or "worker",
|
||||||
|
"age": item["metadata"].get("creationTimestamp", ""),
|
||||||
|
"version": item["status"].get("nodeInfo", {}).get("kubeletVersion", ""),
|
||||||
|
"cpu_capacity": capacity.get("cpu", "?"),
|
||||||
|
"memory_capacity": capacity.get("memory", "?"),
|
||||||
|
"cpu_allocatable": allocatable.get("cpu", "?"),
|
||||||
|
"memory_allocatable": allocatable.get("memory", "?"),
|
||||||
|
})
|
||||||
|
return {"cluster": cluster.name, "nodes": nodes, "count": len(nodes)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/clusters/{cluster_id}/pods")
|
||||||
|
async def list_pods(
|
||||||
|
cluster_id: int,
|
||||||
|
namespace: str = "default",
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Pod 목록 (네임스페이스별, Running/Pending/Error 상태 포함)."""
|
||||||
|
cluster, server = await _get_cluster_server(cluster_id, user.tenant_id, db)
|
||||||
|
ns_flag = f"-n {namespace}" if namespace != "all" else "--all-namespaces"
|
||||||
|
data = await _kubectl_json(server, f"get pods {ns_flag}", cluster.kubeconfig_path)
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(500, "Pod 목록 조회 실패")
|
||||||
|
|
||||||
|
pods = []
|
||||||
|
for item in data.get("items", []):
|
||||||
|
meta = item["metadata"]
|
||||||
|
status = item["status"]
|
||||||
|
containers = status.get("containerStatuses", [])
|
||||||
|
ready_count = sum(1 for c in containers if c.get("ready"))
|
||||||
|
pods.append({
|
||||||
|
"name": meta["name"],
|
||||||
|
"namespace": meta.get("namespace", namespace),
|
||||||
|
"status": status.get("phase", "Unknown"),
|
||||||
|
"ready": f"{ready_count}/{len(containers)}",
|
||||||
|
"restarts": sum(c.get("restartCount", 0) for c in containers),
|
||||||
|
"age": meta.get("creationTimestamp", ""),
|
||||||
|
"node": item["spec"].get("nodeName", ""),
|
||||||
|
"image": containers[0].get("image", "") if containers else "",
|
||||||
|
})
|
||||||
|
return {"cluster": cluster.name, "namespace": namespace, "pods": pods, "count": len(pods)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/clusters/{cluster_id}/deploys")
|
||||||
|
async def list_deployments(
|
||||||
|
cluster_id: int,
|
||||||
|
namespace: str = "default",
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Deployment 목록 + 롤아웃 상태."""
|
||||||
|
cluster, server = await _get_cluster_server(cluster_id, user.tenant_id, db)
|
||||||
|
data = await _kubectl_json(server, f"get deployments -n {namespace}", cluster.kubeconfig_path)
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(500, "Deployment 목록 조회 실패")
|
||||||
|
|
||||||
|
deploys = []
|
||||||
|
for item in data.get("items", []):
|
||||||
|
meta = item["metadata"]
|
||||||
|
spec = item["spec"]
|
||||||
|
status = item["status"]
|
||||||
|
deploys.append({
|
||||||
|
"name": meta["name"],
|
||||||
|
"namespace": meta.get("namespace", namespace),
|
||||||
|
"desired": spec.get("replicas", 0),
|
||||||
|
"ready": status.get("readyReplicas", 0),
|
||||||
|
"available": status.get("availableReplicas", 0),
|
||||||
|
"updated": status.get("updatedReplicas", 0),
|
||||||
|
"age": meta.get("creationTimestamp", ""),
|
||||||
|
})
|
||||||
|
return {"cluster": cluster.name, "deployments": deploys, "count": len(deploys)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/clusters/{cluster_id}/rollout")
|
||||||
|
async def rollout_deployment(
|
||||||
|
cluster_id: int,
|
||||||
|
req: RolloutRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Deployment 롤링 재시작 또는 이미지 업데이트."""
|
||||||
|
cluster, server = await _get_cluster_server(cluster_id, user.tenant_id, db)
|
||||||
|
|
||||||
|
if req.image:
|
||||||
|
# 이미지 업데이트
|
||||||
|
containers_cmd = f"get deployment {req.deployment} -n {req.namespace} -o jsonpath='{{.spec.template.spec.containers[0].name}}'"
|
||||||
|
container_result = await _kubectl(server, containers_cmd, cluster.kubeconfig_path)
|
||||||
|
container_name = container_result.get("output", "app").strip()
|
||||||
|
cmd = f"set image deployment/{req.deployment} {container_name}={req.image} -n {req.namespace}"
|
||||||
|
else:
|
||||||
|
# 재시작만
|
||||||
|
cmd = f"rollout restart deployment/{req.deployment} -n {req.namespace}"
|
||||||
|
|
||||||
|
result = await _kubectl(server, cmd, cluster.kubeconfig_path)
|
||||||
|
if not result["ok"]:
|
||||||
|
raise HTTPException(500, f"롤아웃 실패: {result.get('error', '')}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"deployment": req.deployment,
|
||||||
|
"namespace": req.namespace,
|
||||||
|
"action": "image_update" if req.image else "restart",
|
||||||
|
"output": result["output"][:200],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/clusters/{cluster_id}/events")
|
||||||
|
async def cluster_events(
|
||||||
|
cluster_id: int,
|
||||||
|
namespace: str = "default",
|
||||||
|
level: str = "Warning",
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""클러스터 이벤트 (Warning 이상 필터링)."""
|
||||||
|
cluster, server = await _get_cluster_server(cluster_id, user.tenant_id, db)
|
||||||
|
data = await _kubectl_json(server, f"get events -n {namespace}", cluster.kubeconfig_path)
|
||||||
|
if not data:
|
||||||
|
return {"events": []}
|
||||||
|
|
||||||
|
events = []
|
||||||
|
for item in data.get("items", []):
|
||||||
|
if level == "Warning" and item.get("type") != "Warning":
|
||||||
|
continue
|
||||||
|
events.append({
|
||||||
|
"type": item.get("type", "Normal"),
|
||||||
|
"reason": item.get("reason", ""),
|
||||||
|
"message": item.get("message", "")[:200],
|
||||||
|
"object": f"{item['involvedObject'].get('kind','')}/{item['involvedObject'].get('name','')}",
|
||||||
|
"count": item.get("count", 1),
|
||||||
|
"first_time": item.get("firstTimestamp", ""),
|
||||||
|
"last_time": item.get("lastTimestamp", ""),
|
||||||
|
})
|
||||||
|
events.sort(key=lambda x: x.get("last_time", ""), reverse=True)
|
||||||
|
return {"cluster": cluster.name, "namespace": namespace, "events": events[:50]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/clusters/{cluster_id}/sr")
|
||||||
|
async def create_sr_from_k8s(
|
||||||
|
cluster_id: int,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""K8s Warning 이벤트 → SR 자동 생성."""
|
||||||
|
cluster, server = await _get_cluster_server(cluster_id, user.tenant_id, db)
|
||||||
|
events_data = await cluster_events(cluster_id, "default", "Warning", db, user)
|
||||||
|
warnings = events_data.get("events", [])
|
||||||
|
|
||||||
|
created = []
|
||||||
|
for evt in warnings[:5]: # 최대 5개 SR 생성
|
||||||
|
sr = SRRequest(
|
||||||
|
title=f"[K8s] {evt['reason']}: {evt['object']}",
|
||||||
|
description=f"클러스터: {cluster.name}\n이벤트: {evt['message']}\n발생횟수: {evt['count']}",
|
||||||
|
category="MONITORING",
|
||||||
|
priority="HIGH" if evt["count"] > 5 else "MEDIUM",
|
||||||
|
status=SRStatus.OPEN,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(sr)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(sr)
|
||||||
|
created.append(sr.id)
|
||||||
|
|
||||||
|
return {"ok": True, "sr_created": created, "count": len(created)}
|
||||||
315
workspace/guardia-itsm/routers/predictive_ops.py
Normal file
315
workspace/guardia-itsm/routers/predictive_ops.py
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
"""
|
||||||
|
예측 운영 분석 — 기존 predictive.py 고도화
|
||||||
|
|
||||||
|
기존 predictive.py(B-6)에서 단순 통계 예측을 넘어
|
||||||
|
Ollama LLM 기반 인사이트 + 시계열 이동평균으로 고도화.
|
||||||
|
|
||||||
|
예측 항목:
|
||||||
|
1. SLA 위반 확률 (7일 후)
|
||||||
|
2. SR 급증 예측 (부하 급상승 감지)
|
||||||
|
3. 서버 장애 예측 (메트릭 트렌드 기반)
|
||||||
|
4. 용량 소진 예측 (디스크/메모리)
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
GET /api/predict/sla-breach — SLA 위반 예측
|
||||||
|
GET /api/predict/sr-surge — SR 급증 예측
|
||||||
|
GET /api/predict/server-failure/{id} — 서버 장애 예측
|
||||||
|
GET /api/predict/capacity — 전체 용량 예측
|
||||||
|
GET /api/predict/summary — 예측 요약 (대시보드용)
|
||||||
|
POST /api/predict/insight — Ollama AI 인사이트 생성
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import statistics
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select, func, and_, desc
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.auth import get_current_user
|
||||||
|
from database import get_db
|
||||||
|
from models import User, SRRequest, SRStatus, Server
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/predict", tags=["Predictive Ops"])
|
||||||
|
|
||||||
|
OLLAMA_URL = "http://localhost:11434"
|
||||||
|
CHAT_MODEL = "llama3"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 통계 유틸 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _moving_average(data: list[float], window: int = 3) -> list[float]:
|
||||||
|
"""단순 이동평균."""
|
||||||
|
if len(data) < window:
|
||||||
|
return data
|
||||||
|
result = []
|
||||||
|
for i in range(len(data)):
|
||||||
|
start = max(0, i - window + 1)
|
||||||
|
result.append(sum(data[start:i + 1]) / (i - start + 1))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _linear_forecast(data: list[float], horizon: int = 7) -> list[float]:
|
||||||
|
"""선형 회귀 기반 예측 (최근 30일 데이터 사용)."""
|
||||||
|
n = len(data)
|
||||||
|
if n < 2:
|
||||||
|
return [data[-1] if data else 0.0] * horizon
|
||||||
|
# 최소 제곱법
|
||||||
|
x_mean = (n - 1) / 2
|
||||||
|
y_mean = sum(data) / n
|
||||||
|
numerator = sum((i - x_mean) * (y - y_mean) for i, y in enumerate(data))
|
||||||
|
denominator = sum((i - x_mean) ** 2 for i in range(n))
|
||||||
|
slope = numerator / denominator if denominator else 0
|
||||||
|
intercept = y_mean - slope * x_mean
|
||||||
|
return [max(0.0, intercept + slope * (n + i)) for i in range(horizon)]
|
||||||
|
|
||||||
|
|
||||||
|
def _breach_probability(historical_rates: list[float], target: float) -> float:
|
||||||
|
"""SLA 위반 확률 추정 (미래 예측값 기반)."""
|
||||||
|
if not historical_rates:
|
||||||
|
return 0.0
|
||||||
|
forecast = _linear_forecast(historical_rates, 7)
|
||||||
|
breaches = sum(1 for v in forecast if v < target)
|
||||||
|
return round(breaches / len(forecast), 2)
|
||||||
|
|
||||||
|
|
||||||
|
async def _ollama_insight(prompt: str) -> str:
|
||||||
|
"""Ollama로 인사이트 텍스트 생성 (실패 시 빈 문자열)."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{OLLAMA_URL}/api/generate",
|
||||||
|
json={
|
||||||
|
"model": CHAT_MODEL,
|
||||||
|
"system": "GUARDiA ITSM 운영 분석 전문가. 한국어로 간결하게 3문장 이내로 답변.",
|
||||||
|
"prompt": prompt,
|
||||||
|
"stream": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json().get("response", "").strip()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Ollama 인사이트 실패: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# ── 엔드포인트 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/sla-breach")
|
||||||
|
async def predict_sla_breach(
|
||||||
|
horizon_days: int = Query(7, ge=1, le=30),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""SLA 준수율 트렌드 기반 위반 예측."""
|
||||||
|
# 최근 30일 일별 SLA 준수율
|
||||||
|
today = date.today()
|
||||||
|
dates = [today - timedelta(days=i) for i in range(29, -1, -1)]
|
||||||
|
daily_rates = []
|
||||||
|
for d in dates:
|
||||||
|
total_r = await db.execute(
|
||||||
|
select(func.count(SRRequest.id)).where(
|
||||||
|
func.date(SRRequest.updated_at) == d,
|
||||||
|
SRRequest.status == SRStatus.DONE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
total = total_r.scalar() or 0
|
||||||
|
if total == 0:
|
||||||
|
daily_rates.append(None)
|
||||||
|
continue
|
||||||
|
on_time_r = await db.execute(
|
||||||
|
select(func.count(SRRequest.id)).where(
|
||||||
|
func.date(SRRequest.updated_at) == d,
|
||||||
|
SRRequest.status == SRStatus.DONE,
|
||||||
|
func.extract('epoch', SRRequest.updated_at - SRRequest.created_at) <= 14400,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
on_time = on_time_r.scalar() or 0
|
||||||
|
daily_rates.append(on_time / total * 100)
|
||||||
|
|
||||||
|
# None 제거 후 이동평균
|
||||||
|
valid_rates = [r for r in daily_rates if r is not None]
|
||||||
|
if not valid_rates:
|
||||||
|
return {"status": "NO_DATA", "message": "충분한 데이터 없음"}
|
||||||
|
|
||||||
|
smoothed = _moving_average(valid_rates, 7)
|
||||||
|
forecast = _linear_forecast(smoothed, horizon_days)
|
||||||
|
breach_prob = _breach_probability(smoothed, 95.0)
|
||||||
|
|
||||||
|
# Ollama 인사이트
|
||||||
|
insight = ""
|
||||||
|
if len(smoothed) >= 7:
|
||||||
|
trend_desc = "하락" if smoothed[-1] < smoothed[-7] else "상승"
|
||||||
|
insight = await _ollama_insight(
|
||||||
|
f"SLA 준수율이 최근 7일 {trend_desc} 추세. 현재 {smoothed[-1]:.1f}%. "
|
||||||
|
f"7일 후 위반 확률 {breach_prob*100:.0f}%. 원인과 조치 방안을 제시하세요."
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"current_rate": round(smoothed[-1] if smoothed else 0, 1),
|
||||||
|
"target": 95.0,
|
||||||
|
"breach_probability_7d": breach_prob,
|
||||||
|
"forecast": [round(v, 1) for v in forecast],
|
||||||
|
"status": "CRITICAL" if breach_prob > 0.5 else "WARNING" if breach_prob > 0.2 else "NORMAL",
|
||||||
|
"insight": insight,
|
||||||
|
"horizon_days": horizon_days,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sr-surge")
|
||||||
|
async def predict_sr_surge(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""SR 급증 감지 — 최근 7일 이동평균 대비 오늘 SR 수."""
|
||||||
|
today = date.today()
|
||||||
|
# 최근 14일 일별 SR 수
|
||||||
|
daily_counts = []
|
||||||
|
for i in range(13, -1, -1):
|
||||||
|
d = today - timedelta(days=i)
|
||||||
|
r = await db.execute(
|
||||||
|
select(func.count(SRRequest.id)).where(func.date(SRRequest.created_at) == d)
|
||||||
|
)
|
||||||
|
daily_counts.append(r.scalar() or 0)
|
||||||
|
|
||||||
|
if len(daily_counts) < 7:
|
||||||
|
return {"status": "NO_DATA"}
|
||||||
|
|
||||||
|
avg_7d = sum(daily_counts[-8:-1]) / 7
|
||||||
|
today_count = daily_counts[-1]
|
||||||
|
surge_ratio = today_count / avg_7d if avg_7d > 0 else 0
|
||||||
|
|
||||||
|
status = "SURGE" if surge_ratio > 2.0 else "HIGH" if surge_ratio > 1.5 else "NORMAL"
|
||||||
|
forecast_7d = _linear_forecast(daily_counts, 7)
|
||||||
|
|
||||||
|
insight = ""
|
||||||
|
if status in ("SURGE", "HIGH"):
|
||||||
|
insight = await _ollama_insight(
|
||||||
|
f"오늘 SR 접수 {today_count}건으로 7일 평균 {avg_7d:.0f}건 대비 {surge_ratio:.1f}배. "
|
||||||
|
"급증 원인 분석 및 대응 방안 3줄로 제시."
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"today_count": today_count,
|
||||||
|
"avg_7d": round(avg_7d, 1),
|
||||||
|
"surge_ratio": round(surge_ratio, 2),
|
||||||
|
"status": status,
|
||||||
|
"forecast_7d": [round(v, 0) for v in forecast_7d],
|
||||||
|
"daily_trend": daily_counts[-7:],
|
||||||
|
"insight": insight,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/server-failure/{server_id}")
|
||||||
|
async def predict_server_failure(
|
||||||
|
server_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""서버 장애 예측 — SR 이력 + 메트릭 트렌드."""
|
||||||
|
srv_row = await db.execute(select(Server).where(Server.id == server_id))
|
||||||
|
server = srv_row.scalar_one_or_none()
|
||||||
|
if not server:
|
||||||
|
raise HTTPException(404, "서버를 찾을 수 없습니다")
|
||||||
|
|
||||||
|
# 최근 30일 해당 서버 관련 SR
|
||||||
|
today = date.today()
|
||||||
|
month_start = today - timedelta(days=30)
|
||||||
|
sr_r = await db.execute(
|
||||||
|
select(func.count(SRRequest.id)).where(
|
||||||
|
SRRequest.target_server.contains(str(server_id)),
|
||||||
|
SRRequest.created_at >= month_start,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sr_count = sr_r.scalar() or 0
|
||||||
|
|
||||||
|
# 최근 7일 일별 SR 수 (트렌드)
|
||||||
|
daily_sr = []
|
||||||
|
for i in range(6, -1, -1):
|
||||||
|
d = today - timedelta(days=i)
|
||||||
|
r = await db.execute(
|
||||||
|
select(func.count(SRRequest.id)).where(
|
||||||
|
SRRequest.target_server.contains(str(server_id)),
|
||||||
|
func.date(SRRequest.created_at) == d,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
daily_sr.append(r.scalar() or 0)
|
||||||
|
|
||||||
|
# 트렌드 분석
|
||||||
|
recent_avg = sum(daily_sr[-3:]) / 3
|
||||||
|
old_avg = sum(daily_sr[:4]) / 4
|
||||||
|
trend_ratio = recent_avg / old_avg if old_avg > 0 else 1.0
|
||||||
|
|
||||||
|
failure_prob = min(0.95, max(0.0,
|
||||||
|
0.1 * trend_ratio +
|
||||||
|
0.05 * (sr_count / 30) +
|
||||||
|
(0.3 if trend_ratio > 1.5 else 0)
|
||||||
|
))
|
||||||
|
|
||||||
|
status = "HIGH_RISK" if failure_prob > 0.5 else "MEDIUM_RISK" if failure_prob > 0.2 else "LOW_RISK"
|
||||||
|
|
||||||
|
insight = ""
|
||||||
|
if status != "LOW_RISK":
|
||||||
|
insight = await _ollama_insight(
|
||||||
|
f"서버 SR 30일 총 {sr_count}건, 최근 3일 평균 {recent_avg:.1f}건으로 상승 추세. "
|
||||||
|
f"장애 위험도 {failure_prob*100:.0f}%. 예방 조치 방안 제시."
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"server_id": server_id,
|
||||||
|
"server_name": getattr(server, 'hostname', str(server_id)),
|
||||||
|
"failure_probability_7d": round(failure_prob, 2),
|
||||||
|
"status": status,
|
||||||
|
"sr_count_30d": sr_count,
|
||||||
|
"daily_sr_7d": daily_sr,
|
||||||
|
"trend_ratio": round(trend_ratio, 2),
|
||||||
|
"insight": insight,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary")
|
||||||
|
async def prediction_summary(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""예측 요약 — 대시보드 카드용."""
|
||||||
|
sla = await predict_sla_breach(7, db, user)
|
||||||
|
surge = await predict_sr_surge(db, user)
|
||||||
|
|
||||||
|
alerts = []
|
||||||
|
if sla.get("breach_probability_7d", 0) > 0.3:
|
||||||
|
alerts.append({"type": "SLA", "severity": "HIGH",
|
||||||
|
"message": f"SLA 위반 예측 {sla['breach_probability_7d']*100:.0f}% 가능성"})
|
||||||
|
if surge.get("status") in ("SURGE", "HIGH"):
|
||||||
|
alerts.append({"type": "SR_SURGE", "severity": "MEDIUM",
|
||||||
|
"message": f"SR 급증: 평균 대비 {surge['surge_ratio']:.1f}배"})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"alerts": alerts,
|
||||||
|
"sla_status": sla.get("status", "NO_DATA"),
|
||||||
|
"sla_breach_prob": sla.get("breach_probability_7d", 0),
|
||||||
|
"surge_status": surge.get("status", "NO_DATA"),
|
||||||
|
"surge_ratio": surge.get("surge_ratio", 1.0),
|
||||||
|
"updated_at": datetime.utcnow(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class InsightRequest(BaseModel):
|
||||||
|
context: str
|
||||||
|
question: str
|
||||||
|
|
||||||
|
@router.post("/insight")
|
||||||
|
async def generate_insight(
|
||||||
|
req: InsightRequest,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Ollama 기반 운영 인사이트 생성."""
|
||||||
|
prompt = f"운영 현황: {req.context}\n\n질문: {req.question}"
|
||||||
|
insight = await _ollama_insight(prompt)
|
||||||
|
return {"insight": insight, "model": CHAT_MODEL}
|
||||||
292
workspace/guardia-itsm/routers/slack_connector.py
Normal file
292
workspace/guardia-itsm/routers/slack_connector.py
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
"""
|
||||||
|
Slack 커넥터 — Incoming Webhook + Slash Commands + Block Kit
|
||||||
|
|
||||||
|
기능:
|
||||||
|
- Slack으로 GUARDiA 이벤트 알림 발송
|
||||||
|
- Slack Slash Commands 수신 (/guardia status, /guardia sr)
|
||||||
|
- Block Kit 리치 메시지 (버튼·섹션·헤더)
|
||||||
|
- 기존 메신저봇 명령어와 연동
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
POST /api/slack/config — Slack 설정 등록
|
||||||
|
GET /api/slack/config — 설정 조회
|
||||||
|
POST /api/slack/notify — 알림 발송
|
||||||
|
POST /api/slack/commands — Slack Slash Commands 수신
|
||||||
|
GET /api/slack/test — 테스트 메시지 발송
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, Form, Header, HTTPException, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.auth import get_current_user, require_admin_role
|
||||||
|
from database import get_db
|
||||||
|
from models import User, SRRequest, SRStatus, SlackConfig # 신규 모델
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/slack", tags=["Slack 연동"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pydantic 스키마 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class SlackConfigCreate(BaseModel):
|
||||||
|
name: str = Field(..., max_length=100)
|
||||||
|
webhook_url: str = Field(..., description="Incoming Webhook URL")
|
||||||
|
signing_secret: Optional[str] = None
|
||||||
|
default_channel: str = Field("#guardia-ops", max_length=100)
|
||||||
|
notify_sr_create: bool = True
|
||||||
|
notify_incident: bool = True
|
||||||
|
notify_deploy: bool = True
|
||||||
|
notify_sla_breach: bool = True
|
||||||
|
|
||||||
|
class SlackNotifyRequest(BaseModel):
|
||||||
|
channel: Optional[str] = None
|
||||||
|
text: str
|
||||||
|
level: str = Field("info", pattern="^(info|warning|error|success)$")
|
||||||
|
sr_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Block Kit 빌더 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _color_for_level(level: str) -> str:
|
||||||
|
return {"info": "#003366", "warning": "#F59E0B", "error": "#EF4444", "success": "#10B981"}.get(level, "#003366")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_sr_blocks(sr: SRRequest) -> list:
|
||||||
|
"""SR 알림용 Block Kit."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"type": "header",
|
||||||
|
"text": {"type": "plain_text", "text": f"🎫 SR-{sr.id} {sr.title[:50]}"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"fields": [
|
||||||
|
{"type": "mrkdwn", "text": f"*상태*\n{sr.status}"},
|
||||||
|
{"type": "mrkdwn", "text": f"*우선순위*\n{sr.priority or 'MEDIUM'}"},
|
||||||
|
{"type": "mrkdwn", "text": f"*카테고리*\n{sr.category or '-'}"},
|
||||||
|
{"type": "mrkdwn", "text": f"*등록일*\n{sr.created_at.strftime('%m/%d %H:%M') if sr.created_at else '-'}"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "actions",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"type": "button",
|
||||||
|
"text": {"type": "plain_text", "text": "상세 보기"},
|
||||||
|
"url": f"https://zioinfo.co.kr:8443/sr/{sr.id}",
|
||||||
|
"style": "primary",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_text_blocks(text: str, level: str) -> list:
|
||||||
|
"""일반 텍스트 알림용 Block Kit."""
|
||||||
|
emoji = {"info": "ℹ️", "warning": "⚠️", "error": "❌", "success": "✅"}.get(level, "ℹ️")
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"text": {"type": "mrkdwn", "text": f"{emoji} {text}"}
|
||||||
|
},
|
||||||
|
{"type": "context", "elements": [
|
||||||
|
{"type": "mrkdwn", "text": f"GUARDiA ITSM | {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}"}
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Slack API 발송 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _send_slack(webhook_url: str, payload: dict) -> bool:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.post(webhook_url, json=payload)
|
||||||
|
return r.status_code == 200
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Slack 발송 실패: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ── 서명 검증 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _verify_slack_signature(signing_secret: str, body: bytes, timestamp: str, signature: str) -> bool:
|
||||||
|
"""Slack 요청 서명 검증 (HMAC-SHA256)."""
|
||||||
|
if abs(time.time() - float(timestamp)) > 300:
|
||||||
|
return False # 5분 이상 오래된 요청 거부
|
||||||
|
base = f"v0:{timestamp}:{body.decode()}"
|
||||||
|
expected = "v0=" + hmac.new(
|
||||||
|
signing_secret.encode(), base.encode(), hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
return hmac.compare_digest(expected, signature)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 엔드포인트 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/config")
|
||||||
|
async def save_slack_config(
|
||||||
|
req: SlackConfigCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""Slack 설정 저장."""
|
||||||
|
existing = await db.execute(
|
||||||
|
select(SlackConfig).where(SlackConfig.tenant_id == user.tenant_id)
|
||||||
|
)
|
||||||
|
cfg = existing.scalar_one_or_none()
|
||||||
|
|
||||||
|
if cfg:
|
||||||
|
cfg.webhook_url = req.webhook_url
|
||||||
|
cfg.signing_secret = req.signing_secret
|
||||||
|
cfg.default_channel = req.default_channel
|
||||||
|
cfg.notify_sr_create = req.notify_sr_create
|
||||||
|
cfg.notify_incident = req.notify_incident
|
||||||
|
cfg.notify_deploy = req.notify_deploy
|
||||||
|
cfg.notify_sla_breach = req.notify_sla_breach
|
||||||
|
else:
|
||||||
|
cfg = SlackConfig(
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
name=req.name,
|
||||||
|
webhook_url=req.webhook_url,
|
||||||
|
signing_secret=req.signing_secret,
|
||||||
|
default_channel=req.default_channel,
|
||||||
|
notify_sr_create=req.notify_sr_create,
|
||||||
|
notify_incident=req.notify_incident,
|
||||||
|
notify_deploy=req.notify_deploy,
|
||||||
|
notify_sla_breach=req.notify_sla_breach,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(cfg)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config")
|
||||||
|
async def get_slack_config(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
row = await db.execute(select(SlackConfig).where(SlackConfig.tenant_id == user.tenant_id))
|
||||||
|
cfg = row.scalar_one_or_none()
|
||||||
|
if not cfg:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"name": cfg.name,
|
||||||
|
"webhook_url": cfg.webhook_url[:20] + "***", # 마스킹
|
||||||
|
"default_channel": cfg.default_channel,
|
||||||
|
"is_active": cfg.is_active,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/test")
|
||||||
|
async def test_slack(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""테스트 메시지 발송."""
|
||||||
|
row = await db.execute(select(SlackConfig).where(SlackConfig.tenant_id == user.tenant_id))
|
||||||
|
cfg = row.scalar_one_or_none()
|
||||||
|
if not cfg:
|
||||||
|
raise HTTPException(404, "Slack 설정 없음")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"channel": cfg.default_channel,
|
||||||
|
"blocks": _build_text_blocks(f"GUARDiA ITSM 연동 테스트 메시지 (by {user.email})", "success"),
|
||||||
|
}
|
||||||
|
ok = await _send_slack(cfg.webhook_url, payload)
|
||||||
|
return {"ok": ok}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/notify")
|
||||||
|
async def slack_notify(
|
||||||
|
req: SlackNotifyRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Slack 알림 발송."""
|
||||||
|
row = await db.execute(select(SlackConfig).where(SlackConfig.tenant_id == user.tenant_id))
|
||||||
|
cfg = row.scalar_one_or_none()
|
||||||
|
if not cfg:
|
||||||
|
raise HTTPException(404, "Slack 설정 없음")
|
||||||
|
|
||||||
|
# SR 첨부 시 Block Kit 사용
|
||||||
|
if req.sr_id:
|
||||||
|
sr_row = await db.execute(select(SRRequest).where(SRRequest.id == req.sr_id))
|
||||||
|
sr = sr_row.scalar_one_or_none()
|
||||||
|
if sr:
|
||||||
|
payload = {
|
||||||
|
"channel": req.channel or cfg.default_channel,
|
||||||
|
"text": req.text,
|
||||||
|
"attachments": [{"color": _color_for_level(req.level), "blocks": _build_sr_blocks(sr)}],
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
payload = {"channel": req.channel or cfg.default_channel,
|
||||||
|
"blocks": _build_text_blocks(req.text, req.level)}
|
||||||
|
else:
|
||||||
|
payload = {"channel": req.channel or cfg.default_channel,
|
||||||
|
"blocks": _build_text_blocks(req.text, req.level)}
|
||||||
|
|
||||||
|
ok = await _send_slack(cfg.webhook_url, payload)
|
||||||
|
return {"ok": ok}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/commands")
|
||||||
|
async def slack_commands(
|
||||||
|
request: Request,
|
||||||
|
x_slack_signature: str = Header(None, alias="X-Slack-Signature"),
|
||||||
|
x_slack_request_timestamp: str = Header(None, alias="X-Slack-Request-Timestamp"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Slack Slash Commands 처리.
|
||||||
|
Slack 앱에서 /guardia 명령어 설정 필요:
|
||||||
|
Request URL: https://zioinfo.co.kr:8443/api/slack/commands
|
||||||
|
"""
|
||||||
|
body = await request.body()
|
||||||
|
|
||||||
|
# 서명 검증 (설정된 경우)
|
||||||
|
# 모든 테넌트 설정 조회는 생략, 실제로는 team_id 기반 조회
|
||||||
|
form_data = {}
|
||||||
|
for item in body.decode().split("&"):
|
||||||
|
if "=" in item:
|
||||||
|
k, v = item.split("=", 1)
|
||||||
|
form_data[k] = v.replace("+", " ")
|
||||||
|
|
||||||
|
command = form_data.get("command", "")
|
||||||
|
text = form_data.get("text", "").strip()
|
||||||
|
user_name = form_data.get("user_name", "unknown")
|
||||||
|
|
||||||
|
if command == "/guardia":
|
||||||
|
if text.startswith("status"):
|
||||||
|
# 시스템 현황 반환
|
||||||
|
return {
|
||||||
|
"response_type": "in_channel",
|
||||||
|
"blocks": [
|
||||||
|
{"type": "header", "text": {"type": "plain_text", "text": "🛡️ GUARDiA 현황"}},
|
||||||
|
{"type": "section", "text": {"type": "mrkdwn",
|
||||||
|
"text": f"@{user_name}님이 조회했습니다. <https://zioinfo.co.kr:8443|대시보드 보기>"}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
elif text.startswith("sr"):
|
||||||
|
return {
|
||||||
|
"response_type": "ephemeral",
|
||||||
|
"text": "SR 접수는 웹 대시보드를 이용하거나 /sr 명령어를 사용하세요.",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"response_type": "ephemeral",
|
||||||
|
"text": "사용법: `/guardia status` | `/guardia sr`",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"response_type": "ephemeral", "text": "알 수 없는 명령어입니다."}
|
||||||
400
workspace/guardia-itsm/routers/sso_provider.py
Normal file
400
workspace/guardia-itsm/routers/sso_provider.py
Normal file
@ -0,0 +1,400 @@
|
|||||||
|
"""
|
||||||
|
SSO 통합 인증 — SAML 2.0 / OIDC / OAuth2
|
||||||
|
|
||||||
|
지원 IdP:
|
||||||
|
- 행정안전부 공통로그인 (GPKI / SAML 2.0)
|
||||||
|
- Google Workspace (OIDC)
|
||||||
|
- Microsoft Entra ID (OIDC / OAuth2)
|
||||||
|
- 범용 SAML 2.0 / OIDC
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
GET /api/sso/config — SSO 설정 목록
|
||||||
|
POST /api/sso/config — SSO IdP 설정 등록
|
||||||
|
DELETE /api/sso/config/{id} — 설정 삭제
|
||||||
|
GET /api/sso/login/{provider} — SSO 로그인 리다이렉트
|
||||||
|
GET /api/sso/callback/saml — SAML ACS (Assertion Consumer Service)
|
||||||
|
GET /api/sso/callback/oidc — OIDC 콜백
|
||||||
|
POST /api/sso/test/{id} — 설정 테스트
|
||||||
|
GET /api/sso/metadata — SP Metadata XML (SAML)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from urllib.parse import urlencode, urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.auth import create_access_token, get_current_user, require_admin_role
|
||||||
|
from database import get_db
|
||||||
|
from models import User, UserRole, SSOConfig, SSOSession # 신규 모델
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/sso", tags=["SSO 통합 인증"])
|
||||||
|
|
||||||
|
BASE_URL = "https://zioinfo.co.kr:8443" # SP(서비스 제공자) 베이스 URL
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pydantic 스키마 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class SSOConfigCreate(BaseModel):
|
||||||
|
name: str = Field(..., max_length=100, description="표시명 (예: 행안부 공통로그인)")
|
||||||
|
provider_type: str = Field(..., pattern="^(SAML|OIDC|OAUTH2)$")
|
||||||
|
# SAML 설정
|
||||||
|
idp_metadata_url: Optional[str] = None
|
||||||
|
idp_sso_url: Optional[str] = None
|
||||||
|
idp_cert: Optional[str] = None
|
||||||
|
# OIDC 설정
|
||||||
|
client_id: Optional[str] = None
|
||||||
|
client_secret: Optional[str] = None
|
||||||
|
discovery_url: Optional[str] = None # .well-known/openid-configuration
|
||||||
|
scopes: str = Field("openid email profile", description="요청할 scope 목록")
|
||||||
|
# 공통
|
||||||
|
attribute_mapping: Dict[str, str] = Field(
|
||||||
|
default_factory=lambda: {"email": "email", "name": "name"},
|
||||||
|
description="IdP 속성 → GUARDiA 속성 매핑"
|
||||||
|
)
|
||||||
|
default_role: UserRole = UserRole.ENGINEER
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class SSOConfigOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
provider_type: str
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# ── SAML 헬퍼 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build_saml_authn_request(idp_sso_url: str, sp_entity_id: str, acs_url: str) -> str:
|
||||||
|
"""SAML AuthnRequest 생성 (간소화 버전, 서명 없음)."""
|
||||||
|
req_id = f"_req_{secrets.token_hex(16)}"
|
||||||
|
now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
xml = f"""<?xml version="1.0"?>
|
||||||
|
<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
|
||||||
|
ID="{req_id}" Version="2.0" IssueInstant="{now}"
|
||||||
|
Destination="{idp_sso_url}"
|
||||||
|
AssertionConsumerServiceURL="{acs_url}"
|
||||||
|
ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST">
|
||||||
|
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">{sp_entity_id}</saml:Issuer>
|
||||||
|
<samlp:NameIDPolicy Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"/>
|
||||||
|
</samlp:AuthnRequest>"""
|
||||||
|
return base64.b64encode(xml.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_saml_response(saml_response_b64: str) -> dict:
|
||||||
|
"""SAML Response에서 속성 추출 (실제 운영 시 xmlsec1 검증 필요)."""
|
||||||
|
import re
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(saml_response_b64).decode('utf-8', 'replace')
|
||||||
|
# NameID (이메일)
|
||||||
|
email_m = re.search(r'<(?:[^:]+:)?NameID[^>]*>([^<]+)</(?:[^:]+:)?NameID>', decoded)
|
||||||
|
email = email_m.group(1).strip() if email_m else None
|
||||||
|
# 속성값 추출
|
||||||
|
attrs = {}
|
||||||
|
for m in re.finditer(
|
||||||
|
r'<(?:[^:]+:)?AttributeValue[^>]*>([^<]+)</(?:[^:]+:)?AttributeValue>',
|
||||||
|
decoded
|
||||||
|
):
|
||||||
|
attrs[m.group(1).strip()] = m.group(1).strip()
|
||||||
|
return {"email": email, "name": attrs.get("name", email), "raw_attrs": attrs}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"SAML 파싱 실패: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── OIDC 헬퍼 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _oidc_discovery(discovery_url: str) -> Optional[dict]:
|
||||||
|
"""OIDC 디스커버리 문서 조회."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10, verify=False) as client:
|
||||||
|
r = await client.get(discovery_url)
|
||||||
|
return r.json() if r.status_code == 200 else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _exchange_code(config: SSOConfig, code: str, redirect_uri: str) -> Optional[dict]:
|
||||||
|
"""OIDC 인가코드 → 토큰 교환."""
|
||||||
|
discovery = await _oidc_discovery(config.discovery_url)
|
||||||
|
if not discovery:
|
||||||
|
return None
|
||||||
|
token_endpoint = discovery.get("token_endpoint")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15, verify=False) as client:
|
||||||
|
r = await client.post(
|
||||||
|
token_endpoint,
|
||||||
|
data={
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"client_id": config.client_id,
|
||||||
|
"client_secret": config.client_secret_enc, # 복호화 필요
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return r.json() if r.status_code == 200 else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_userinfo(discovery_url: str, access_token: str) -> Optional[dict]:
|
||||||
|
"""OIDC userinfo 엔드포인트 호출."""
|
||||||
|
discovery = await _oidc_discovery(discovery_url)
|
||||||
|
if not discovery:
|
||||||
|
return None
|
||||||
|
userinfo_ep = discovery.get("userinfo_endpoint")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10, verify=False) as client:
|
||||||
|
r = await client.get(userinfo_ep, headers={"Authorization": f"Bearer {access_token}"})
|
||||||
|
return r.json() if r.status_code == 200 else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 유저 동기화 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _upsert_sso_user(
|
||||||
|
attributes: dict, config: SSOConfig, db: AsyncSession
|
||||||
|
) -> Optional[User]:
|
||||||
|
"""SSO 속성으로 GUARDiA 사용자 생성/업데이트 (Just-In-Time 프로비저닝)."""
|
||||||
|
mapping = json.loads(config.attribute_mapping) if config.attribute_mapping else {}
|
||||||
|
email = attributes.get(mapping.get("email", "email")) or attributes.get("email")
|
||||||
|
name = attributes.get(mapping.get("name", "name")) or attributes.get("name")
|
||||||
|
|
||||||
|
if not email:
|
||||||
|
logger.warning("SSO 속성에서 이메일을 찾을 수 없습니다")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 기존 유저 조회
|
||||||
|
row = await db.execute(select(User).where(User.email == email))
|
||||||
|
user = row.scalar_one_or_none()
|
||||||
|
|
||||||
|
if user:
|
||||||
|
# 기존 유저 업데이트
|
||||||
|
if name and not user.name:
|
||||||
|
user.name = name
|
||||||
|
else:
|
||||||
|
# 신규 유저 생성 (비밀번호 없음 — SSO 전용)
|
||||||
|
user = User(
|
||||||
|
email=email,
|
||||||
|
name=name or email.split("@")[0],
|
||||||
|
hashed_password="!sso_login_only!", # 직접 로그인 불가
|
||||||
|
role=config.default_role,
|
||||||
|
tenant_id=config.tenant_id,
|
||||||
|
is_active=True,
|
||||||
|
sso_provider=config.id,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
# ── 엔드포인트 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/config", response_model=List[SSOConfigOut])
|
||||||
|
async def list_sso_configs(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
rows = await db.execute(
|
||||||
|
select(SSOConfig).where(SSOConfig.tenant_id == user.tenant_id)
|
||||||
|
)
|
||||||
|
configs = rows.scalars().all()
|
||||||
|
return [
|
||||||
|
SSOConfigOut(id=c.id, name=c.name, provider_type=c.provider_type,
|
||||||
|
is_active=c.is_active, created_at=c.created_at)
|
||||||
|
for c in configs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/config")
|
||||||
|
async def create_sso_config(
|
||||||
|
req: SSOConfigCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""SSO IdP 설정 등록 (client_secret는 암호화 저장)."""
|
||||||
|
config = SSOConfig(
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
name=req.name,
|
||||||
|
provider_type=req.provider_type,
|
||||||
|
idp_metadata_url=req.idp_metadata_url,
|
||||||
|
idp_sso_url=req.idp_sso_url,
|
||||||
|
idp_cert=req.idp_cert,
|
||||||
|
client_id=req.client_id,
|
||||||
|
client_secret_enc=req.client_secret, # TODO: AES-256-GCM 암호화
|
||||||
|
discovery_url=req.discovery_url,
|
||||||
|
scopes=req.scopes,
|
||||||
|
attribute_mapping=json.dumps(req.attribute_mapping),
|
||||||
|
default_role=req.default_role,
|
||||||
|
is_active=req.is_active,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(config)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(config)
|
||||||
|
return {"ok": True, "id": config.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/config/{config_id}")
|
||||||
|
async def delete_sso_config(
|
||||||
|
config_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
row = await db.execute(
|
||||||
|
select(SSOConfig).where(SSOConfig.id == config_id, SSOConfig.tenant_id == user.tenant_id)
|
||||||
|
)
|
||||||
|
config = row.scalar_one_or_none()
|
||||||
|
if not config:
|
||||||
|
raise HTTPException(404, "SSO 설정을 찾을 수 없습니다")
|
||||||
|
await db.delete(config)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/login/{config_id}")
|
||||||
|
async def sso_login_redirect(
|
||||||
|
config_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""SSO 로그인 페이지로 리다이렉트."""
|
||||||
|
row = await db.execute(
|
||||||
|
select(SSOConfig).where(SSOConfig.id == config_id, SSOConfig.is_active == True)
|
||||||
|
)
|
||||||
|
config = row.scalar_one_or_none()
|
||||||
|
if not config:
|
||||||
|
raise HTTPException(404, "SSO 설정을 찾을 수 없습니다")
|
||||||
|
|
||||||
|
state = secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
if config.provider_type == "SAML":
|
||||||
|
acs_url = f"{BASE_URL}/api/sso/callback/saml?config_id={config_id}"
|
||||||
|
sp_entity_id = f"{BASE_URL}/api/sso/metadata"
|
||||||
|
authn_request = _build_saml_authn_request(
|
||||||
|
config.idp_sso_url or "", sp_entity_id, acs_url
|
||||||
|
)
|
||||||
|
redirect_url = f"{config.idp_sso_url}?" + urlencode({
|
||||||
|
"SAMLRequest": authn_request,
|
||||||
|
"RelayState": state,
|
||||||
|
})
|
||||||
|
return RedirectResponse(redirect_url)
|
||||||
|
|
||||||
|
elif config.provider_type in ("OIDC", "OAUTH2"):
|
||||||
|
discovery = await _oidc_discovery(config.discovery_url or "")
|
||||||
|
if not discovery:
|
||||||
|
raise HTTPException(400, "OIDC 디스커버리 실패")
|
||||||
|
auth_endpoint = discovery.get("authorization_endpoint", "")
|
||||||
|
redirect_uri = f"{BASE_URL}/api/sso/callback/oidc"
|
||||||
|
params = {
|
||||||
|
"response_type": "code",
|
||||||
|
"client_id": config.client_id,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"scope": config.scopes,
|
||||||
|
"state": f"{config_id}:{state}",
|
||||||
|
}
|
||||||
|
return RedirectResponse(f"{auth_endpoint}?{urlencode(params)}")
|
||||||
|
|
||||||
|
raise HTTPException(400, "지원하지 않는 IdP 타입")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/callback/saml")
|
||||||
|
async def saml_acs(
|
||||||
|
request: Request,
|
||||||
|
config_id: int = Query(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""SAML ACS — IdP에서 전달한 Assertion 처리 → JWT 발급."""
|
||||||
|
form = await request.form()
|
||||||
|
saml_response = form.get("SAMLResponse", "")
|
||||||
|
if not saml_response:
|
||||||
|
raise HTTPException(400, "SAMLResponse 없음")
|
||||||
|
|
||||||
|
attrs = _parse_saml_response(str(saml_response))
|
||||||
|
if not attrs.get("email"):
|
||||||
|
raise HTTPException(401, "SAML Assertion에서 이메일 추출 실패")
|
||||||
|
|
||||||
|
row = await db.execute(select(SSOConfig).where(SSOConfig.id == config_id))
|
||||||
|
config = row.scalar_one_or_none()
|
||||||
|
if not config:
|
||||||
|
raise HTTPException(404, "SSO 설정 없음")
|
||||||
|
|
||||||
|
user = await _upsert_sso_user(attrs, config, db)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(401, "사용자 동기화 실패")
|
||||||
|
|
||||||
|
token = create_access_token({"sub": user.email, "user_id": user.id})
|
||||||
|
# SPA로 토큰 전달 (실제: secure cookie 또는 post message)
|
||||||
|
return RedirectResponse(f"/?sso_token={token}&provider=saml")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/callback/oidc")
|
||||||
|
async def oidc_callback(
|
||||||
|
code: str = Query(...),
|
||||||
|
state: str = Query(""),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""OIDC 콜백 — 인가코드 → 토큰 → 사용자 정보 → JWT 발급."""
|
||||||
|
config_id_str = state.split(":")[0] if ":" in state else "0"
|
||||||
|
try:
|
||||||
|
config_id = int(config_id_str)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, "잘못된 state")
|
||||||
|
|
||||||
|
row = await db.execute(select(SSOConfig).where(SSOConfig.id == config_id))
|
||||||
|
config = row.scalar_one_or_none()
|
||||||
|
if not config:
|
||||||
|
raise HTTPException(404, "SSO 설정 없음")
|
||||||
|
|
||||||
|
redirect_uri = f"{BASE_URL}/api/sso/callback/oidc"
|
||||||
|
tokens = await _exchange_code(config, code, redirect_uri)
|
||||||
|
if not tokens:
|
||||||
|
raise HTTPException(401, "토큰 교환 실패")
|
||||||
|
|
||||||
|
userinfo = await _get_userinfo(config.discovery_url or "", tokens.get("access_token", ""))
|
||||||
|
if not userinfo:
|
||||||
|
raise HTTPException(401, "사용자 정보 조회 실패")
|
||||||
|
|
||||||
|
user = await _upsert_sso_user(userinfo, config, db)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(401, "사용자 동기화 실패")
|
||||||
|
|
||||||
|
jwt_token = create_access_token({"sub": user.email, "user_id": user.id})
|
||||||
|
return RedirectResponse(f"/?sso_token={jwt_token}&provider=oidc")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/metadata")
|
||||||
|
async def sp_metadata():
|
||||||
|
"""SP (Service Provider) SAML Metadata XML."""
|
||||||
|
acs_url = f"{BASE_URL}/api/sso/callback/saml"
|
||||||
|
entity_id = f"{BASE_URL}/api/sso/metadata"
|
||||||
|
xml = f"""<?xml version="1.0"?>
|
||||||
|
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
|
||||||
|
entityID="{entity_id}">
|
||||||
|
<md:SPSSODescriptor
|
||||||
|
AuthnRequestsSigned="false"
|
||||||
|
WantAssertionsSigned="false"
|
||||||
|
protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||||
|
<md:AssertionConsumerService
|
||||||
|
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
|
||||||
|
Location="{acs_url}"
|
||||||
|
index="1"/>
|
||||||
|
</md:SPSSODescriptor>
|
||||||
|
</md:EntityDescriptor>"""
|
||||||
|
return Response(content=xml, media_type="application/xml")
|
||||||
259
workspace/guardia-itsm/routers/white_label.py
Normal file
259
workspace/guardia-itsm/routers/white_label.py
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
"""
|
||||||
|
화이트라벨 브랜딩 — 기관별 로고·색상·도메인 커스터마이즈
|
||||||
|
|
||||||
|
기능:
|
||||||
|
- 기관별 로고 URL, 브랜드 색상, 회사명 설정
|
||||||
|
- 이메일 발신 템플릿 커스터마이즈
|
||||||
|
- 커스텀 도메인 설정 (nginx 설정 자동 반영)
|
||||||
|
- 프론트엔드에서 CSS 변수로 동적 적용
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
GET /api/brand/ — 현재 테넌트 브랜딩 설정 조회
|
||||||
|
PUT /api/brand/ — 브랜딩 설정 저장/수정
|
||||||
|
POST /api/brand/logo — 로고 이미지 URL 설정
|
||||||
|
GET /api/brand/css — CSS 변수 동적 생성 (프론트엔드용)
|
||||||
|
GET /api/brand/email-template — 이메일 템플릿 조회
|
||||||
|
PUT /api/brand/email-template — 이메일 템플릿 수정
|
||||||
|
POST /api/brand/preview-email — 이메일 미리보기
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.auth import get_current_user, require_admin_role
|
||||||
|
from database import get_db
|
||||||
|
from models import User, TenantBranding # 신규 모델
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/brand", tags=["White Label Branding"])
|
||||||
|
|
||||||
|
# 기본 브랜딩 (GUARDiA 기본값)
|
||||||
|
DEFAULT_BRANDING = {
|
||||||
|
"company_name": "GUARDiA ITSM",
|
||||||
|
"logo_url": "/logo.png",
|
||||||
|
"logo_dark_url": "/logo-white.png",
|
||||||
|
"favicon_url": "/favicon.ico",
|
||||||
|
"primary_color": "#003366",
|
||||||
|
"secondary_color": "#00A0C8",
|
||||||
|
"accent_color": "#10B981",
|
||||||
|
"font_family": "Pretendard, -apple-system, sans-serif",
|
||||||
|
"login_bg_color": "#001f4d",
|
||||||
|
"header_bg_color": "#003366",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pydantic 스키마 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class BrandingUpdate(BaseModel):
|
||||||
|
company_name: Optional[str] = Field(None, max_length=200)
|
||||||
|
logo_url: Optional[str] = Field(None, max_length=500)
|
||||||
|
logo_dark_url: Optional[str] = Field(None, max_length=500)
|
||||||
|
favicon_url: Optional[str] = Field(None, max_length=500)
|
||||||
|
primary_color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
|
||||||
|
secondary_color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
|
||||||
|
accent_color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
|
||||||
|
font_family: Optional[str] = Field(None, max_length=200)
|
||||||
|
login_bg_color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
|
||||||
|
header_bg_color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
|
||||||
|
custom_domain: Optional[str] = Field(None, max_length=200)
|
||||||
|
footer_text: Optional[str] = Field(None, max_length=500)
|
||||||
|
|
||||||
|
class EmailTemplateUpdate(BaseModel):
|
||||||
|
subject_prefix: str = Field("[GUARDiA]", max_length=50)
|
||||||
|
header_html: Optional[str] = None
|
||||||
|
footer_html: Optional[str] = None
|
||||||
|
primary_color: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 유틸 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _merge_branding(db_branding: Optional[TenantBranding]) -> dict:
|
||||||
|
"""DB 설정과 기본값 병합."""
|
||||||
|
if not db_branding:
|
||||||
|
return DEFAULT_BRANDING.copy()
|
||||||
|
merged = DEFAULT_BRANDING.copy()
|
||||||
|
for key in DEFAULT_BRANDING:
|
||||||
|
val = getattr(db_branding, key, None)
|
||||||
|
if val:
|
||||||
|
merged[key] = val
|
||||||
|
merged["company_name"] = db_branding.company_name or merged["company_name"]
|
||||||
|
merged["custom_domain"] = db_branding.custom_domain
|
||||||
|
merged["footer_text"] = db_branding.footer_text
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _build_css_vars(branding: dict) -> str:
|
||||||
|
"""CSS 변수 문자열 생성 (프론트엔드 동적 적용용)."""
|
||||||
|
return f"""
|
||||||
|
:root {{
|
||||||
|
--brand-primary: {branding.get('primary_color', '#003366')};
|
||||||
|
--brand-secondary: {branding.get('secondary_color', '#00A0C8')};
|
||||||
|
--brand-accent: {branding.get('accent_color', '#10B981')};
|
||||||
|
--brand-font: {branding.get('font_family', 'Pretendard, -apple-system, sans-serif')};
|
||||||
|
--brand-login-bg: {branding.get('login_bg_color', '#001f4d')};
|
||||||
|
--brand-header-bg: {branding.get('header_bg_color', '#003366')};
|
||||||
|
--brand-company: "{branding.get('company_name', 'GUARDiA ITSM')}";
|
||||||
|
}}
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ── 엔드포인트 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def get_branding(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""현재 테넌트 브랜딩 설정 조회."""
|
||||||
|
row = await db.execute(
|
||||||
|
select(TenantBranding).where(TenantBranding.tenant_id == user.tenant_id)
|
||||||
|
)
|
||||||
|
branding = row.scalar_one_or_none()
|
||||||
|
return _merge_branding(branding)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/")
|
||||||
|
async def update_branding(
|
||||||
|
req: BrandingUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""브랜딩 설정 저장."""
|
||||||
|
row = await db.execute(
|
||||||
|
select(TenantBranding).where(TenantBranding.tenant_id == user.tenant_id)
|
||||||
|
)
|
||||||
|
branding = row.scalar_one_or_none()
|
||||||
|
|
||||||
|
update_data = {k: v for k, v in req.model_dump().items() if v is not None}
|
||||||
|
|
||||||
|
if branding:
|
||||||
|
for k, v in update_data.items():
|
||||||
|
setattr(branding, k, v)
|
||||||
|
branding.updated_at = datetime.utcnow()
|
||||||
|
else:
|
||||||
|
branding = TenantBranding(
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
**update_data,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
updated_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(branding)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(branding)
|
||||||
|
return {"ok": True, "branding": _merge_branding(branding)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/css")
|
||||||
|
async def get_brand_css(
|
||||||
|
tenant_id: Optional[int] = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
CSS 변수 동적 생성 (인증 불필요 — 프론트엔드에서 직접 로드).
|
||||||
|
<link rel="stylesheet" href="/api/brand/css">
|
||||||
|
"""
|
||||||
|
tid = tenant_id
|
||||||
|
if tid:
|
||||||
|
row = await db.execute(select(TenantBranding).where(TenantBranding.tenant_id == tid))
|
||||||
|
branding = row.scalar_one_or_none()
|
||||||
|
css_vars = _build_css_vars(_merge_branding(branding))
|
||||||
|
else:
|
||||||
|
css_vars = _build_css_vars(DEFAULT_BRANDING)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=css_vars,
|
||||||
|
media_type="text/css",
|
||||||
|
headers={"Cache-Control": "public, max-age=300"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/email-template")
|
||||||
|
async def get_email_template(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""이메일 템플릿 조회."""
|
||||||
|
row = await db.execute(
|
||||||
|
select(TenantBranding).where(TenantBranding.tenant_id == user.tenant_id)
|
||||||
|
)
|
||||||
|
branding = row.scalar_one_or_none()
|
||||||
|
merged = _merge_branding(branding)
|
||||||
|
|
||||||
|
header_html = getattr(branding, 'email_header_html', None) if branding else None
|
||||||
|
footer_html = getattr(branding, 'email_footer_html', None) if branding else None
|
||||||
|
|
||||||
|
default_header = f"""
|
||||||
|
<div style="background:{merged['primary_color']};padding:20px;text-align:center;">
|
||||||
|
<img src="{merged['logo_url']}" height="40" alt="{merged['company_name']}"/>
|
||||||
|
</div>"""
|
||||||
|
default_footer = f"""
|
||||||
|
<div style="background:#f5f5f5;padding:15px;text-align:center;font-size:12px;color:#666;">
|
||||||
|
{merged['company_name']} | {merged.get('footer_text', 'GUARDiA ITSM 자동 발송 메일입니다.')}
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"subject_prefix": f"[{merged['company_name']}]",
|
||||||
|
"header_html": header_html or default_header,
|
||||||
|
"footer_html": footer_html or default_footer,
|
||||||
|
"primary_color": merged["primary_color"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/email-template")
|
||||||
|
async def update_email_template(
|
||||||
|
req: EmailTemplateUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""이메일 템플릿 수정."""
|
||||||
|
row = await db.execute(
|
||||||
|
select(TenantBranding).where(TenantBranding.tenant_id == user.tenant_id)
|
||||||
|
)
|
||||||
|
branding = row.scalar_one_or_none()
|
||||||
|
if not branding:
|
||||||
|
branding = TenantBranding(
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
updated_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(branding)
|
||||||
|
|
||||||
|
if req.header_html is not None:
|
||||||
|
branding.email_header_html = req.header_html
|
||||||
|
if req.footer_html is not None:
|
||||||
|
branding.email_footer_html = req.footer_html
|
||||||
|
branding.updated_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/preview-email")
|
||||||
|
async def preview_email(
|
||||||
|
subject: str = "테스트 메일",
|
||||||
|
body: str = "메일 본문 내용입니다.",
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""이메일 미리보기 (HTML 반환)."""
|
||||||
|
template = await get_email_template(db, user)
|
||||||
|
html = f"""<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="UTF-8"/><title>{subject}</title></head>
|
||||||
|
<body style="font-family:{DEFAULT_BRANDING['font_family']};margin:0;padding:0;">
|
||||||
|
{template['header_html']}
|
||||||
|
<div style="padding:24px;max-width:600px;margin:0 auto;">
|
||||||
|
<h2 style="color:{template['primary_color']};">{subject}</h2>
|
||||||
|
<div>{body}</div>
|
||||||
|
</div>
|
||||||
|
{template['footer_html']}
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
return Response(content=html, media_type="text/html")
|
||||||
Loading…
Reference in New Issue
Block a user