zioinfo-mail/itsm/routers/tenant_mgmt.py
DESKTOP-TKLFCPR\ython e228faabf5 feat(itsm): G-1~G-12 확장 기능 + 하네스/봇/설치스크립트 구현
G-1: 메신저 Webhook Relay + _send_to_room 실제 httpx 호출 구현
G-2: POST /api/tasks/bulk SR 대량작업 엔드포인트 (최대 100건)
G-3: 라이선스 만료 알림 스케줄러 (매일 09:00 KST)
G-4: 체험판 upgrade_banner 필드 + license.py 배너 로직
G-5: core/auto_rca.py + incidents/problem auto-rca 엔드포인트
G-6: core/deploy_impact.py + vibe impact-analysis 엔드포인트
G-7: core/ticket_classifier.py + SR 생성 시 AI 분류 + ai-suggestion API
G-8: VulnPatchRecord 모델 + vuln_scan 패치추적 4개 엔드포인트
G-9: core/jira_sync.py + gateway Jira/Confluence 연동 엔드포인트
G-10: core/push_notify.py + routers/push.py + PushSubscription 모델
G-11: approvals 다중승인 (위임/서명/기한초과/마감연장)
G-12: alembic.ini + migrations/ + cicd/migrate_to_postgres.sh

하네스: guardia-orchestrator 확장기능 Phase 반영
봇명령어: /sr /status /license /bulk 슬래시 명령어 추가
설치스크립트: setup/ (Ubuntu, CentOS, RHEL, Windows) --test 옵션 포함

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:18:52 +09:00

236 lines
8.4 KiB
Python

"""
F-1: 멀티테넌트 관리 API
엔드포인트:
POST /api/tenants — 테넌트 생성 (ADMIN)
GET /api/tenants — 테넌트 목록 (ADMIN)
GET /api/tenants/current — 현재 요청 테넌트 정보
GET /api/tenants/{tenant_id} — 테넌트 상세 (ADMIN)
PUT /api/tenants/{tenant_id} — 테넌트 수정 (ADMIN)
DELETE /api/tenants/{tenant_id} — 테넌트 비활성화 (ADMIN)
POST /api/tenants/{tenant_id}/quota — 쿼터 설정 (ADMIN)
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Dict, Optional
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from middleware.tenant import (
_tenants,
get_current_tenant_id,
get_current_tenant,
register_tenant,
require_tenant,
)
from models import User, UserRole
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/tenants", tags=["tenants"])
PLAN_OPTIONS = ["STANDARD", "PROFESSIONAL", "ENTERPRISE"]
# ── Pydantic 스키마 ──────────────────────────────────────────────────────────
class TenantIn(BaseModel):
name: str
code: str = Field(..., min_length=2, max_length=20, pattern=r"^[A-Z0-9_]+$")
plan: str = "STANDARD"
description: str = ""
class TenantUpdateIn(BaseModel):
name: Optional[str] = None
plan: Optional[str] = None
description: Optional[str] = None
is_active: Optional[bool] = None
class QuotaIn(BaseModel):
max_users: Optional[int] = None
max_servers: Optional[int] = None
max_sr_per_month: Optional[int] = None
storage_gb: Optional[float] = None
rate_limit_rpm: Optional[int] = None
# ── 엔드포인트 ───────────────────────────────────────────────────────────────
@router.post("", status_code=201)
async def create_tenant(
body: TenantIn,
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""테넌트 생성 (ADMIN)."""
if current_user.role != UserRole.ADMIN:
raise HTTPException(403, "ADMIN 권한이 필요합니다.")
if body.plan not in PLAN_OPTIONS:
raise HTTPException(400, f"유효하지 않은 플랜: {body.plan}. 허용: {PLAN_OPTIONS}")
if body.code in _tenants:
raise HTTPException(409, f"테넌트 코드 '{body.code}'가 이미 존재합니다.")
tenant_id = f"TEN-{uuid4().hex[:8].upper()}"
try:
record = register_tenant(
tenant_id = tenant_id,
name = body.name,
code = body.code,
plan = body.plan,
created_by = current_user.username,
)
except ValueError as e:
raise HTTPException(409, str(e))
if body.description:
record["description"] = body.description
logger.info("테넌트 생성: %s (%s) by %s", tenant_id, body.name, current_user.username)
return record
@router.get("")
async def list_tenants_api(
include_inactive: bool = False,
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""테넌트 목록 (ADMIN)."""
if current_user.role != UserRole.ADMIN:
raise HTTPException(403, "ADMIN 권한이 필요합니다.")
tenants = list(_tenants.values())
if not include_inactive:
tenants = [t for t in tenants if t.get("is_active", True)]
return {
"total": len(tenants),
"tenants": tenants,
}
@router.get("/current")
async def current_tenant(
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""현재 요청의 테넌트 정보 조회."""
tenant_id = get_current_tenant_id()
tenant = get_current_tenant()
return {
"tenant_id": tenant_id,
"tenant_name": tenant["name"] if tenant else "DEFAULT",
"plan": tenant.get("plan", "ENTERPRISE") if tenant else "ENTERPRISE",
"quota": tenant.get("quota", {}) if tenant else {},
"is_default": tenant_id == "DEFAULT",
}
@router.get("/{tenant_id}")
async def get_tenant_detail(
tenant_id: str,
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""테넌트 상세 조회 (ADMIN)."""
if current_user.role != UserRole.ADMIN:
raise HTTPException(403, "ADMIN 권한이 필요합니다.")
t = _tenants.get(tenant_id)
if not t:
raise HTTPException(404, f"테넌트 '{tenant_id}'를 찾을 수 없습니다.")
return t
@router.put("/{tenant_id}")
async def update_tenant(
tenant_id: str,
body: TenantUpdateIn,
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""테넌트 수정 (ADMIN). 시스템 테넌트(DEFAULT)는 비활성화 불가."""
if current_user.role != UserRole.ADMIN:
raise HTTPException(403, "ADMIN 권한이 필요합니다.")
t = _tenants.get(tenant_id)
if not t:
raise HTTPException(404, f"테넌트 '{tenant_id}'를 찾을 수 없습니다.")
if t.get("is_system") and body.is_active is False:
raise HTTPException(400, "시스템 테넌트는 비활성화할 수 없습니다.")
if body.plan and body.plan not in PLAN_OPTIONS:
raise HTTPException(400, f"유효하지 않은 플랜: {body.plan}")
if body.name is not None: t["name"] = body.name
if body.plan is not None: t["plan"] = body.plan
if body.description is not None: t["description"] = body.description
if body.is_active is not None: t["is_active"] = body.is_active
t["updated_at"] = datetime.utcnow().isoformat()
t["updated_by"] = current_user.username
logger.info("테넌트 수정: %s by %s", tenant_id, current_user.username)
return t
@router.delete("/{tenant_id}")
async def deactivate_tenant(
tenant_id: str,
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""테넌트 비활성화 (ADMIN, 시스템 테넌트 보호)."""
if current_user.role != UserRole.ADMIN:
raise HTTPException(403, "ADMIN 권한이 필요합니다.")
t = _tenants.get(tenant_id)
if not t:
raise HTTPException(404, f"테넌트 '{tenant_id}'를 찾을 수 없습니다.")
if t.get("is_system"):
raise HTTPException(400, "시스템 테넌트는 삭제할 수 없습니다.")
t["is_active"] = False
t["deactivated_at"] = datetime.utcnow().isoformat()
t["deactivated_by"] = current_user.username
logger.info("테넌트 비활성화: %s by %s", tenant_id, current_user.username)
return {"message": f"테넌트 '{tenant_id}'가 비활성화되었습니다.", "tenant": t}
@router.post("/{tenant_id}/quota")
async def set_quota(
tenant_id: str,
body: QuotaIn,
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""테넌트 쿼터 설정 (ADMIN)."""
if current_user.role != UserRole.ADMIN:
raise HTTPException(403, "ADMIN 권한이 필요합니다.")
t = _tenants.get(tenant_id)
if not t:
raise HTTPException(404, f"테넌트 '{tenant_id}'를 찾을 수 없습니다.")
quota = t.setdefault("quota", {})
if body.max_users is not None: quota["max_users"] = body.max_users
if body.max_servers is not None: quota["max_servers"] = body.max_servers
if body.max_sr_per_month is not None: quota["max_sr_per_month"] = body.max_sr_per_month
if body.storage_gb is not None: quota["storage_gb"] = body.storage_gb
settings = t.setdefault("settings", {})
if body.rate_limit_rpm is not None: settings["rate_limit_rpm"] = body.rate_limit_rpm
t["quota_updated_at"] = datetime.utcnow().isoformat()
t["quota_updated_by"] = current_user.username
logger.info("테넌트 쿼터 설정: %s by %s", tenant_id, current_user.username)
return {"message": "쿼터가 설정되었습니다.", "quota": quota, "settings": settings}