- itsm/ -> workspace/guardia-itsm/ - manager/ -> workspace/guardia-manager/ - app/ -> workspace/guardia-messenger/ - manual/ -> workspace/guardia-docs/ workspace/zioinfo-web/ unchanged. git mv preserves full commit history. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
236 lines
8.4 KiB
Python
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}
|