186 lines
5.2 KiB
Python
186 lines
5.2 KiB
Python
"""Tenant management endpoints."""
|
|
|
|
import hashlib
|
|
import secrets
|
|
import uuid
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import select
|
|
|
|
from app.db import AsyncSessionDep
|
|
from app.dependencies import AdminAuthDep
|
|
from app.models.tenant import Tenant
|
|
from app.schemas.tenant import TenantCreate, TenantResponse
|
|
|
|
|
|
class SetDashboardTokenRequest(BaseModel):
|
|
"""Request body for setting dashboard token."""
|
|
|
|
token: str | None = Field(
|
|
None,
|
|
min_length=32,
|
|
max_length=128,
|
|
description="Dashboard token (32-128 chars). If None, generates a new token.",
|
|
)
|
|
|
|
|
|
class SetDashboardTokenResponse(BaseModel):
|
|
"""Response after setting dashboard token."""
|
|
|
|
token: str = Field(..., description="The dashboard token (only shown once)")
|
|
message: str = Field(default="Dashboard token configured successfully")
|
|
|
|
router = APIRouter(prefix="/tenants", tags=["Tenants"])
|
|
|
|
|
|
# --- Helper functions (embryonic service layer) ---
|
|
|
|
|
|
async def create_tenant(db: AsyncSessionDep, tenant_in: TenantCreate) -> Tenant:
|
|
"""Create a new tenant in the database."""
|
|
tenant = Tenant(
|
|
name=tenant_in.name,
|
|
domain=tenant_in.domain,
|
|
)
|
|
db.add(tenant)
|
|
await db.commit()
|
|
await db.refresh(tenant)
|
|
return tenant
|
|
|
|
|
|
async def get_tenants(db: AsyncSessionDep) -> list[Tenant]:
|
|
"""Retrieve all tenants from the database."""
|
|
result = await db.execute(select(Tenant).order_by(Tenant.created_at.desc()))
|
|
return list(result.scalars().all())
|
|
|
|
|
|
async def get_tenant_by_id(db: AsyncSessionDep, tenant_id: uuid.UUID) -> Tenant | None:
|
|
"""Retrieve a tenant by ID."""
|
|
result = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
# --- Route handlers (thin controllers) ---
|
|
|
|
|
|
@router.post("", response_model=TenantResponse, status_code=status.HTTP_201_CREATED)
|
|
async def create_tenant_endpoint(
|
|
tenant_in: TenantCreate,
|
|
db: AsyncSessionDep,
|
|
) -> Tenant:
|
|
"""
|
|
Create a new tenant.
|
|
|
|
- **name**: Unique tenant name (required)
|
|
- **domain**: Optional domain for the tenant
|
|
"""
|
|
return await create_tenant(db, tenant_in)
|
|
|
|
|
|
@router.get("", response_model=list[TenantResponse])
|
|
async def list_tenants_endpoint(db: AsyncSessionDep) -> list[Tenant]:
|
|
"""
|
|
List all tenants.
|
|
|
|
Returns a list of all registered tenants.
|
|
"""
|
|
return await get_tenants(db)
|
|
|
|
|
|
@router.get("/{tenant_id}", response_model=TenantResponse)
|
|
async def get_tenant_endpoint(
|
|
tenant_id: uuid.UUID,
|
|
db: AsyncSessionDep,
|
|
) -> Tenant:
|
|
"""
|
|
Get a tenant by ID.
|
|
|
|
Returns the tenant with the specified UUID.
|
|
"""
|
|
tenant = await get_tenant_by_id(db, tenant_id)
|
|
if tenant is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Tenant {tenant_id} not found",
|
|
)
|
|
return tenant
|
|
|
|
|
|
@router.post(
|
|
"/{tenant_id}/dashboard-token",
|
|
response_model=SetDashboardTokenResponse,
|
|
dependencies=[AdminAuthDep],
|
|
)
|
|
async def set_dashboard_token_endpoint(
|
|
tenant_id: uuid.UUID,
|
|
db: AsyncSessionDep,
|
|
request: SetDashboardTokenRequest | None = None,
|
|
) -> SetDashboardTokenResponse:
|
|
"""
|
|
Set or regenerate dashboard token for a tenant.
|
|
|
|
**Admin-only endpoint** - requires X-Admin-Api-Key header.
|
|
|
|
This token is used by the tenant's dashboard (Hub Dashboard or Control Panel)
|
|
to authenticate requests to the Orchestrator.
|
|
|
|
- If `token` is provided, it will be used (must be 32-128 characters)
|
|
- If `token` is None or not provided, a secure 48-character token is generated
|
|
|
|
**IMPORTANT**: The plaintext token is only returned once. Store it securely.
|
|
"""
|
|
# Get tenant
|
|
tenant = await get_tenant_by_id(db, tenant_id)
|
|
if tenant is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Tenant {tenant_id} not found",
|
|
)
|
|
|
|
# Generate or use provided token
|
|
if request and request.token:
|
|
token = request.token
|
|
else:
|
|
# Generate secure random token (48 chars = 192 bits of entropy)
|
|
token = secrets.token_hex(24)
|
|
|
|
# Store SHA-256 hash of token
|
|
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
|
tenant.dashboard_token_hash = token_hash
|
|
|
|
await db.commit()
|
|
|
|
return SetDashboardTokenResponse(
|
|
token=token,
|
|
message="Dashboard token configured successfully. Store this token securely - it will not be shown again.",
|
|
)
|
|
|
|
|
|
@router.delete(
|
|
"/{tenant_id}/dashboard-token",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
dependencies=[AdminAuthDep],
|
|
)
|
|
async def revoke_dashboard_token_endpoint(
|
|
tenant_id: uuid.UUID,
|
|
db: AsyncSessionDep,
|
|
) -> None:
|
|
"""
|
|
Revoke/remove dashboard token for a tenant.
|
|
|
|
**Admin-only endpoint** - requires X-Admin-Api-Key header.
|
|
|
|
After revocation, the tenant's dashboard will no longer be able to
|
|
authenticate with the Orchestrator until a new token is set.
|
|
"""
|
|
tenant = await get_tenant_by_id(db, tenant_id)
|
|
if tenant is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Tenant {tenant_id} not found",
|
|
)
|
|
|
|
tenant.dashboard_token_hash = None
|
|
await db.commit()
|