214 lines
8.1 KiB
Python
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,
|
|
)
|