Files
platform-api/app/routers/tenants.py
Platform CI f9bfc3afbd init
2026-02-21 17:54:44 -05:00

214 lines
8.1 KiB
Python

"""
/tenants — Tenant self-service: tools, SSO, users
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update as sa_update
from uuid import UUID
from datetime import datetime
from ..core.database import get_db
from ..core.config import get_settings
from ..models.tenant import Tenant, User, TenantTool, AuditLog, Plan
from ..schemas.tenant import (
TenantResponse, ToolUpdateRequest, CustomSSORequest, UserInviteRequest
)
from ..services import keycloak_service, provisioner
router = APIRouter(prefix="/tenants", tags=["tenants"])
async def _get_tenant(tenant_id: UUID, db: AsyncSession) -> Tenant:
r = await db.execute(select(Tenant).where(Tenant.id == tenant_id, Tenant.deleted_at.is_(None)))
t = r.scalar_one_or_none()
if not t:
raise HTTPException(status_code=404, detail="Tenant not found")
return t
# ── GET tenant info ──────────────────────────────────────────────────────────
@router.get("/by-slug/{slug}", response_model=TenantResponse)
async def get_tenant_by_slug(slug: str, db: AsyncSession = Depends(get_db)):
r = await db.execute(select(Tenant).where(Tenant.slug == slug, Tenant.deleted_at.is_(None)))
t = r.scalar_one_or_none()
if not t:
raise HTTPException(status_code=404, detail="Tenant not found")
plan = await db.get(Plan, t.plan_id)
return _to_response(t, plan.name if plan else "unknown")
@router.get("/{tenant_id}", response_model=TenantResponse)
async def get_tenant(tenant_id: UUID, db: AsyncSession = Depends(get_db)):
t = await _get_tenant(tenant_id, db)
plan = await db.get(Plan, t.plan_id)
return _to_response(t, plan.name if plan else "unknown")
# ── Tool management ──────────────────────────────────────────────────────────
@router.patch("/{tenant_id}/tools")
async def update_tools(
tenant_id: UUID,
payload: ToolUpdateRequest,
db: AsyncSession = Depends(get_db),
):
"""Enable/disable/scale data tools. Superset is always on by default."""
t = await _get_tenant(tenant_id, db)
plan = await db.get(Plan, t.plan_id)
# Enforce plan feature gating
new_tools = payload.to_dict(t.tools_enabled)
if plan:
for tool, enabled in new_tools.items():
if enabled and not plan.features.get(tool, False):
raise HTTPException(
status_code=402,
detail=f"Tool '{tool}' is not available on plan '{plan.name}'. Please upgrade."
)
# Always keep Superset on (default tool)
new_tools["superset"] = True
# Update DB
await db.execute(
sa_update(Tenant).where(Tenant.id == tenant_id).values(tools_enabled=new_tools)
)
for tool, enabled in new_tools.items():
existing = await db.execute(
select(TenantTool).where(TenantTool.tenant_id == tenant_id, TenantTool.tool_name == tool)
)
row = existing.scalar_one_or_none()
if row:
row.enabled = bool(enabled)
else:
db.add(TenantTool(tenant_id=tenant_id, tool_name=tool, enabled=bool(enabled)))
db.add(AuditLog(tenant_id=tenant_id, action="tools.updated", details={"tools": new_tools}))
await db.commit()
# Push gitops update
s = get_settings()
await provisioner.update_tenant_tools(t.slug, new_tools)
tool_urls = {
tool: f"https://{t.slug}-{tool}.{s.DATA_PLANE_DOMAIN}"
for tool, en in new_tools.items() if en
}
return {"tools_enabled": new_tools, "tool_urls": tool_urls}
# ── SSO management ───────────────────────────────────────────────────────────
@router.get("/{tenant_id}/sso")
async def get_sso_config(tenant_id: UUID, db: AsyncSession = Depends(get_db)):
t = await _get_tenant(tenant_id, db)
s = get_settings()
return {
"sso_type": t.sso_type,
"system_sso_url": f"{s.KEYCLOAK_URL}/realms/{t.keycloak_realm}",
"custom_config": t.custom_sso_config,
}
@router.post("/{tenant_id}/sso/custom")
async def configure_custom_sso(
tenant_id: UUID,
payload: CustomSSORequest,
db: AsyncSession = Depends(get_db),
):
"""Configure a custom OIDC/SAML IdP within the tenant's Keycloak realm."""
t = await _get_tenant(tenant_id, db)
plan = await db.get(Plan, t.plan_id)
if not (plan and plan.features.get("custom_sso")):
raise HTTPException(status_code=402, detail="Custom SSO requires Enterprise plan.")
await keycloak_service.add_custom_idp(t.slug, payload.model_dump())
await db.execute(
sa_update(Tenant)
.where(Tenant.id == tenant_id)
.values(sso_type="custom", custom_sso_config=payload.model_dump())
)
db.add(AuditLog(tenant_id=tenant_id, action="sso.custom_idp_added",
details={"alias": payload.alias}))
await db.commit()
return {"status": "Custom SSO configured", "alias": payload.alias}
@router.delete("/{tenant_id}/sso/custom")
async def remove_custom_sso(tenant_id: UUID, db: AsyncSession = Depends(get_db)):
t = await _get_tenant(tenant_id, db)
await db.execute(
sa_update(Tenant).where(Tenant.id == tenant_id)
.values(sso_type="system", custom_sso_config=None)
)
db.add(AuditLog(tenant_id=tenant_id, action="sso.reverted_to_system"))
await db.commit()
return {"status": "Reverted to system SSO"}
# ── User management ──────────────────────────────────────────────────────────
@router.get("/{tenant_id}/users")
async def list_users(tenant_id: UUID, db: AsyncSession = Depends(get_db)):
t = await _get_tenant(tenant_id, db)
kc_users = await keycloak_service.get_tenant_users(t.slug)
return {"users": kc_users}
@router.post("/{tenant_id}/users", status_code=201)
async def invite_user(
tenant_id: UUID,
payload: UserInviteRequest,
db: AsyncSession = Depends(get_db),
):
t = await _get_tenant(tenant_id, db)
kc_id = await keycloak_service.create_tenant_user(
t.slug, payload.email, payload.first_name, payload.last_name, payload.role
)
user = User(
tenant_id=tenant_id,
keycloak_user_id=kc_id,
email=payload.email,
first_name=payload.first_name,
last_name=payload.last_name,
role=payload.role,
)
db.add(user)
db.add(AuditLog(tenant_id=tenant_id, action="user.invited",
details={"email": payload.email, "role": payload.role}))
await db.commit()
return {"status": "invited", "keycloak_id": kc_id}
@router.delete("/{tenant_id}/users/{kc_user_id}")
async def remove_user(tenant_id: UUID, kc_user_id: str, db: AsyncSession = Depends(get_db)):
t = await _get_tenant(tenant_id, db)
await keycloak_service.delete_tenant_user(t.slug, kc_user_id)
await db.execute(
sa_update(User)
.where(User.tenant_id == tenant_id, User.keycloak_user_id == kc_user_id)
.values(status="deleted")
)
db.add(AuditLog(tenant_id=tenant_id, action="user.removed",
details={"kc_user_id": kc_user_id}))
await db.commit()
return {"status": "removed"}
def _to_response(tenant: Tenant, plan_name: str) -> TenantResponse:
s = get_settings()
dp_url = (
f"https://data.{s.DATA_PLANE_DOMAIN}?tenant={tenant.slug}"
if tenant.vcluster_status in ("ready", "provisioning") else None
)
return TenantResponse(
id=tenant.id, slug=tenant.slug, company_name=tenant.company_name,
status=tenant.status, plan_name=plan_name,
trial_ends_at=tenant.trial_ends_at,
vcluster_status=tenant.vcluster_status, vcluster_url=tenant.vcluster_url,
data_plane_url=dp_url, tools_enabled=tenant.tools_enabled,
keycloak_realm=tenant.keycloak_realm, sso_type=tenant.sso_type,
created_at=tenant.created_at,
)