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