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:
DESKTOP-TKLFCPR\ython 2026-06-02 05:57:02 +09:00
parent e7dc273b36
commit 09bab3c2ff
7 changed files with 1736 additions and 0 deletions

View File

@ -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")

View File

@ -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())

View 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)}

View 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}

View 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": "알 수 없는 명령어입니다."}

View 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")

View 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")