From 09bab3c2ffad45f3a9a57bd16b9165c7aca5d82d Mon Sep 17 00:00:00 2001 From: "DESKTOP-TKLFCPR\\ython" Date: Tue, 2 Jun 2026 05:57:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(expansion):=20GUARDiA=20v3=20P2=20?= =?UTF-8?q?=E2=80=94=205=20routers=20+=205=20DB=20tables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 라우터 (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 --- workspace/guardia-itsm/main.py | 8 + workspace/guardia-itsm/models.py | 93 ++++ workspace/guardia-itsm/routers/kubernetes.py | 369 ++++++++++++++++ .../guardia-itsm/routers/predictive_ops.py | 315 ++++++++++++++ .../guardia-itsm/routers/slack_connector.py | 292 +++++++++++++ .../guardia-itsm/routers/sso_provider.py | 400 ++++++++++++++++++ workspace/guardia-itsm/routers/white_label.py | 259 ++++++++++++ 7 files changed, 1736 insertions(+) create mode 100644 workspace/guardia-itsm/routers/kubernetes.py create mode 100644 workspace/guardia-itsm/routers/predictive_ops.py create mode 100644 workspace/guardia-itsm/routers/slack_connector.py create mode 100644 workspace/guardia-itsm/routers/sso_provider.py create mode 100644 workspace/guardia-itsm/routers/white_label.py diff --git a/workspace/guardia-itsm/main.py b/workspace/guardia-itsm/main.py index 8d67f6b9..6ba33a3b 100644 --- a/workspace/guardia-itsm/main.py +++ b/workspace/guardia-itsm/main.py @@ -316,6 +316,14 @@ app.include_router(tenant_portal.router) # 테넌트 셀프서비스 포 app.include_router(bi_dashboard.router) # BI 대시보드 (트렌드·히트맵·퍼널) 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") diff --git a/workspace/guardia-itsm/models.py b/workspace/guardia-itsm/models.py index 45097a4d..f4d402c0 100644 --- a/workspace/guardia-itsm/models.py +++ b/workspace/guardia-itsm/models.py @@ -4817,3 +4817,96 @@ class AutoWorkflowRun(Base): error_message = Column(Text, nullable=True) started_at = Column(DateTime, default=func.now()) 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()) diff --git a/workspace/guardia-itsm/routers/kubernetes.py b/workspace/guardia-itsm/routers/kubernetes.py new file mode 100644 index 00000000..ee8e6ce7 --- /dev/null +++ b/workspace/guardia-itsm/routers/kubernetes.py @@ -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)} diff --git a/workspace/guardia-itsm/routers/predictive_ops.py b/workspace/guardia-itsm/routers/predictive_ops.py new file mode 100644 index 00000000..68197303 --- /dev/null +++ b/workspace/guardia-itsm/routers/predictive_ops.py @@ -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} diff --git a/workspace/guardia-itsm/routers/slack_connector.py b/workspace/guardia-itsm/routers/slack_connector.py new file mode 100644 index 00000000..6e1cc19a --- /dev/null +++ b/workspace/guardia-itsm/routers/slack_connector.py @@ -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}님이 조회했습니다. "}}, + ] + } + 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": "알 수 없는 명령어입니다."} diff --git a/workspace/guardia-itsm/routers/sso_provider.py b/workspace/guardia-itsm/routers/sso_provider.py new file mode 100644 index 00000000..3e6cbf88 --- /dev/null +++ b/workspace/guardia-itsm/routers/sso_provider.py @@ -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""" + + {sp_entity_id} + +""" + 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[^>]*>([^<]+)', decoded) + email = email_m.group(1).strip() if email_m else None + # 속성값 추출 + attrs = {} + for m in re.finditer( + r'<(?:[^:]+:)?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""" + + + + +""" + return Response(content=xml, media_type="application/xml") diff --git a/workspace/guardia-itsm/routers/white_label.py b/workspace/guardia-itsm/routers/white_label.py new file mode 100644 index 00000000..4b5b5bb8 --- /dev/null +++ b/workspace/guardia-itsm/routers/white_label.py @@ -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 변수 동적 생성 (인증 불필요 — 프론트엔드에서 직접 로드). + + """ + 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""" +
+ {merged['company_name']} +
""" + default_footer = f""" +
+ {merged['company_name']} | {merged.get('footer_text', 'GUARDiA ITSM 자동 발송 메일입니다.')} +
""" + + 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""" + +{subject} + + {template['header_html']} +
+

{subject}

+
{body}
+
+ {template['footer_html']} + +""" + return Response(content=html, media_type="text/html")