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

211 lines
6.7 KiB
Python

"""
/auth — Sign-up, sign-in (delegates to Keycloak)
"""
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from datetime import datetime, timedelta
from slugify import slugify
import secrets
from ..core.database import get_db
from ..core.config import get_settings
from ..models.tenant import Tenant, User, Plan, TenantTool, ProvisioningJob, AuditLog
from ..schemas.tenant import SignUpRequest, TenantResponse
from ..services import keycloak_service, provisioner
router = APIRouter(prefix="/auth", tags=["auth"])
async def _default_plan(db: AsyncSession) -> Plan:
settings = get_settings()
result = await db.execute(select(Plan).where(Plan.name == settings.DEFAULT_TRIAL_DAYS or "trial"))
plan = result.scalar_one_or_none()
if not plan:
result = await db.execute(select(Plan).where(Plan.name == "trial"))
plan = result.scalar_one()
return plan
@router.post("/signup", response_model=TenantResponse, status_code=status.HTTP_201_CREATED)
async def signup(
payload: SignUpRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
):
"""
User signs up with company name → creates Tenant + Keycloak realm + starts trial.
vCluster provisioning is triggered asynchronously.
"""
s = get_settings()
slug = slugify(payload.company_name, max_length=40, separator="-")
# Check slug uniqueness
existing = await db.execute(select(Tenant).where(Tenant.slug == slug))
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Company name '{payload.company_name}' is already registered."
)
plan = await _default_plan(db)
now = datetime.utcnow()
trial_days = plan.trial_days
# Create tenant record
tenant = Tenant(
slug=slug,
company_name=payload.company_name,
plan_id=plan.id,
status="trial",
trial_started_at=now,
trial_ends_at=now + timedelta(days=trial_days),
trial_duration_days=trial_days,
vcluster_status="provisioning",
tools_enabled=plan.features,
billing_email=payload.email,
keycloak_realm=f"tenant-{slug}",
sso_type="system",
)
db.add(tenant)
await db.flush()
# Create owner user record
user = User(
tenant_id=tenant.id,
email=payload.email,
first_name=payload.first_name,
last_name=payload.last_name,
role="owner",
status="active",
)
db.add(user)
# Default tool rows
for tool, enabled in plan.features.items():
db.add(TenantTool(tenant_id=tenant.id, tool_name=tool, enabled=bool(enabled)))
# Provisioning job
job = ProvisioningJob(
tenant_id=tenant.id,
job_type="create_vcluster",
payload={
"slug": slug,
"company_name": payload.company_name,
"plan": plan.name,
"tools": plan.features,
"admin_email": payload.email,
}
)
db.add(job)
db.add(AuditLog(
tenant_id=tenant.id,
action="tenant.signup",
details={"email": payload.email, "plan": plan.name},
))
await db.commit()
await db.refresh(tenant)
# Async: create Keycloak realm + provision vCluster
background_tasks.add_task(
_provision_async,
tenant_id=str(tenant.id),
job_id=str(job.id),
slug=slug,
company_name=payload.company_name,
admin_email=payload.email,
plan_name=plan.name,
tools=plan.features,
)
return _to_response(tenant, plan.name)
async def _provision_async(tenant_id, job_id, slug, company_name,
admin_email, plan_name, tools):
"""Background task: Keycloak realm + GitOps vCluster write."""
from ..core.database import get_session_factory
from sqlalchemy import update as sa_update
from datetime import datetime
import logging
log = logging.getLogger(__name__)
factory = get_session_factory()
async with factory() as db:
try:
# Mark job running
await db.execute(
sa_update(ProvisioningJob)
.where(ProvisioningJob.id == job_id)
.values(status="running", started_at=datetime.utcnow())
)
await db.commit()
# 1. Create Keycloak realm
await keycloak_service.create_tenant_realm(slug, company_name, admin_email)
# 2. Write gitops manifests
result = await provisioner.provision_tenant(
tenant_slug=slug,
company_name=company_name,
plan_name=plan_name,
tools_enabled=tools,
)
# 3. Update tenant with vcluster info
await db.execute(
sa_update(Tenant)
.where(Tenant.id == tenant_id)
.values(
vcluster_name=result["vcluster_name"],
vcluster_url=result["vcluster_url"],
vcluster_status="ready",
status="trial",
)
)
await db.execute(
sa_update(ProvisioningJob)
.where(ProvisioningJob.id == job_id)
.values(status="succeeded", completed_at=datetime.utcnow())
)
await db.commit()
log.info("Provisioned tenant %s", slug)
except Exception as exc:
log.exception("Provisioning failed for %s", slug)
await db.execute(
sa_update(ProvisioningJob)
.where(ProvisioningJob.id == job_id)
.values(status="failed", error=str(exc), completed_at=datetime.utcnow())
)
await db.execute(
sa_update(Tenant)
.where(Tenant.id == tenant_id)
.values(vcluster_status="not_created")
)
await db.commit()
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,
)