217 lines
9.2 KiB
Python
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: Optional[str] = Header(default=None)):
|
|
if not x_admin_key or 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
|