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

217 lines
9.2 KiB
Python

"""
/admin — Admin panel: manage tenants, trials, plans, platform settings.
Protected by a simple bearer token (admin API key).
"""
from fastapi import APIRouter, Depends, HTTPException, status, Header
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update as sa_update, func
from uuid import UUID
from datetime import datetime, timedelta
from typing import Optional
from ..core.database import get_db
from ..core.config import get_settings
from ..models.tenant import (
Tenant, User, Plan, ProvisioningJob, AuditLog, PlatformSetting
)
from ..schemas.tenant import (
AdminTrialUpdateRequest, AdminPlanUpdateRequest, PlatformSettingUpdate
)
from ..services import keycloak_service, provisioner
router = APIRouter(prefix="/admin", tags=["admin"])
ADMIN_API_KEY = "Platform-Admin-2024!" # in prod: from secret/env
def _require_admin(x_admin_key: str = Header(...)):
if x_admin_key != ADMIN_API_KEY:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
# ── Dashboard ─────────────────────────────────────────────────────────────────
@router.get("/dashboard", dependencies=[Depends(_require_admin)])
async def dashboard(db: AsyncSession = Depends(get_db)):
total = (await db.execute(select(func.count()).select_from(Tenant))).scalar()
active = (await db.execute(select(func.count()).select_from(Tenant).where(Tenant.status == "active"))).scalar()
trial = (await db.execute(select(func.count()).select_from(Tenant).where(Tenant.status == "trial"))).scalar()
suspended = (await db.execute(select(func.count()).select_from(Tenant).where(Tenant.status == "suspended"))).scalar()
expiring = (await db.execute(
select(func.count()).select_from(Tenant)
.where(Tenant.status == "trial", Tenant.trial_ends_at <= datetime.utcnow() + timedelta(days=7))
)).scalar()
users_total = (await db.execute(select(func.count()).select_from(User))).scalar()
return {
"tenants": {"total": total, "active": active, "trial": trial, "suspended": suspended},
"expiring_trials_7d": expiring,
"total_users": users_total,
}
# ── Tenant management ─────────────────────────────────────────────────────────
@router.get("/tenants", dependencies=[Depends(_require_admin)])
async def list_tenants(
status: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
q = select(Tenant, Plan.name.label("plan_name")).join(Plan, Tenant.plan_id == Plan.id)
if status:
q = q.where(Tenant.status == status)
rows = (await db.execute(q)).all()
return [
{
"id": str(t.id), "slug": t.slug, "company_name": t.company_name,
"status": t.status, "plan": plan_name,
"trial_ends_at": t.trial_ends_at.isoformat() if t.trial_ends_at else None,
"vcluster_status": t.vcluster_status,
"created_at": t.created_at.isoformat(),
}
for t, plan_name in rows
]
@router.get("/tenants/{tenant_id}", dependencies=[Depends(_require_admin)])
async def get_tenant(tenant_id: UUID, db: AsyncSession = Depends(get_db)):
t = await db.get(Tenant, tenant_id)
if not t:
raise HTTPException(status_code=404, detail="Not found")
jobs = (await db.execute(
select(ProvisioningJob).where(ProvisioningJob.tenant_id == tenant_id)
.order_by(ProvisioningJob.created_at.desc()).limit(5)
)).scalars().all()
users = (await db.execute(
select(User).where(User.tenant_id == tenant_id, User.status == "active")
)).scalars().all()
return {
"tenant": t,
"jobs": [{"type": j.job_type, "status": j.status, "created": j.created_at} for j in jobs],
"user_count": len(users),
}
@router.post("/tenants/{tenant_id}/extend-trial", dependencies=[Depends(_require_admin)])
async def extend_trial(
tenant_id: UUID,
payload: AdminTrialUpdateRequest,
db: AsyncSession = Depends(get_db),
):
t = await db.get(Tenant, tenant_id)
if not t:
raise HTTPException(status_code=404, detail="Not found")
base = max(t.trial_ends_at or datetime.utcnow(), datetime.utcnow())
new_end = base + timedelta(days=payload.trial_days)
await db.execute(
sa_update(Tenant).where(Tenant.id == tenant_id)
.values(trial_ends_at=new_end, status="trial")
)
db.add(AuditLog(tenant_id=tenant_id, action="admin.trial_extended",
details={"extra_days": payload.trial_days, "new_end": new_end.isoformat()}))
await db.commit()
return {"new_trial_end": new_end.isoformat()}
@router.post("/tenants/{tenant_id}/suspend", dependencies=[Depends(_require_admin)])
async def suspend_tenant(tenant_id: UUID, db: AsyncSession = Depends(get_db)):
t = await db.get(Tenant, tenant_id)
if not t:
raise HTTPException(status_code=404, detail="Not found")
await db.execute(
sa_update(Tenant).where(Tenant.id == tenant_id).values(status="suspended")
)
db.add(AuditLog(tenant_id=tenant_id, action="admin.tenant_suspended"))
await db.commit()
return {"status": "suspended"}
@router.post("/tenants/{tenant_id}/activate", dependencies=[Depends(_require_admin)])
async def activate_tenant(tenant_id: UUID, db: AsyncSession = Depends(get_db)):
t = await db.get(Tenant, tenant_id)
if not t:
raise HTTPException(status_code=404, detail="Not found")
await db.execute(
sa_update(Tenant).where(Tenant.id == tenant_id).values(status="active")
)
db.add(AuditLog(tenant_id=tenant_id, action="admin.tenant_activated"))
await db.commit()
return {"status": "active"}
@router.delete("/tenants/{tenant_id}", dependencies=[Depends(_require_admin)])
async def delete_tenant(tenant_id: UUID, db: AsyncSession = Depends(get_db)):
"""Full deprovisioning: remove vCluster + Keycloak realm."""
t = await db.get(Tenant, tenant_id)
if not t:
raise HTTPException(status_code=404, detail="Not found")
# Remove GitOps manifests (triggers ArgoCD prune)
await provisioner.deprovision_tenant(t.slug)
# Remove Keycloak realm
await keycloak_service.delete_tenant_realm(t.slug)
await db.execute(
sa_update(Tenant).where(Tenant.id == tenant_id)
.values(status="deleted", deleted_at=datetime.utcnow(), vcluster_status="deleted")
)
db.add(AuditLog(tenant_id=tenant_id, action="admin.tenant_deleted"))
await db.commit()
return {"status": "deleted"}
@router.post("/tenants/{tenant_id}/change-plan", dependencies=[Depends(_require_admin)])
async def change_plan(
tenant_id: UUID,
payload: AdminPlanUpdateRequest,
db: AsyncSession = Depends(get_db),
):
plan = (await db.execute(select(Plan).where(Plan.name == payload.plan_name))).scalar_one_or_none()
if not plan:
raise HTTPException(status_code=404, detail=f"Plan '{payload.plan_name}' not found")
await db.execute(
sa_update(Tenant).where(Tenant.id == tenant_id).values(plan_id=plan.id)
)
db.add(AuditLog(tenant_id=tenant_id, action="admin.plan_changed",
details={"new_plan": payload.plan_name}))
await db.commit()
return {"plan": payload.plan_name}
# ── Platform Settings ─────────────────────────────────────────────────────────
@router.get("/settings", dependencies=[Depends(_require_admin)])
async def list_settings(db: AsyncSession = Depends(get_db)):
rows = (await db.execute(select(PlatformSetting))).scalars().all()
return {r.key: {"value": r.value, "description": r.description} for r in rows}
@router.put("/settings/{key}", dependencies=[Depends(_require_admin)])
async def update_setting(key: str, payload: PlatformSettingUpdate, db: AsyncSession = Depends(get_db)):
row = await db.get(PlatformSetting, key)
if not row:
raise HTTPException(status_code=404, detail=f"Setting '{key}' not found")
row.value = payload.value
await db.commit()
return {"key": key, "value": payload.value}
# ── Plans ──────────────────────────────────────────────────────────────────────
@router.get("/plans", dependencies=[Depends(_require_admin)])
async def list_plans(db: AsyncSession = Depends(get_db)):
rows = (await db.execute(select(Plan))).scalars().all()
return rows
# ── Audit Log ─────────────────────────────────────────────────────────────────
@router.get("/audit", dependencies=[Depends(_require_admin)])
async def audit_log(
tenant_id: Optional[UUID] = None,
limit: int = 100,
db: AsyncSession = Depends(get_db),
):
q = select(AuditLog).order_by(AuditLog.created_at.desc()).limit(limit)
if tenant_id:
q = q.where(AuditLog.tenant_id == tenant_id)
rows = (await db.execute(q)).scalars().all()
return rows