401 lines
12 KiB
Python
401 lines
12 KiB
Python
|
|
"""Admin routes for client and instance management."""
|
||
|
|
|
||
|
|
import hashlib
|
||
|
|
import secrets
|
||
|
|
from typing import Annotated
|
||
|
|
from uuid import UUID
|
||
|
|
|
||
|
|
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||
|
|
from sqlalchemy import select
|
||
|
|
from sqlalchemy.orm import selectinload
|
||
|
|
|
||
|
|
from app.config import settings
|
||
|
|
from app.db import AsyncSessionDep
|
||
|
|
from app.models.base import utc_now
|
||
|
|
from app.models.client import Client
|
||
|
|
from app.models.instance import Instance
|
||
|
|
from app.schemas.client import ClientCreate, ClientResponse, ClientUpdate
|
||
|
|
from app.schemas.instance import InstanceBriefResponse, InstanceCreate, InstanceResponse
|
||
|
|
|
||
|
|
|
||
|
|
def validate_admin_key(
|
||
|
|
x_admin_api_key: Annotated[str, Header(description="Admin API key")],
|
||
|
|
) -> str:
|
||
|
|
"""Validate the admin API key with constant-time comparison."""
|
||
|
|
if not secrets.compare_digest(x_admin_api_key, settings.ADMIN_API_KEY):
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
|
|
detail="Invalid admin API key",
|
||
|
|
)
|
||
|
|
return x_admin_api_key
|
||
|
|
|
||
|
|
|
||
|
|
AdminKeyDep = Annotated[str, Depends(validate_admin_key)]
|
||
|
|
|
||
|
|
router = APIRouter(prefix="/api/v1/admin", tags=["Admin"])
|
||
|
|
|
||
|
|
|
||
|
|
# ============ CLIENT MANAGEMENT ============
|
||
|
|
|
||
|
|
|
||
|
|
@router.post("/clients", response_model=ClientResponse, status_code=status.HTTP_201_CREATED)
|
||
|
|
async def create_client(
|
||
|
|
client: ClientCreate,
|
||
|
|
db: AsyncSessionDep,
|
||
|
|
_: AdminKeyDep,
|
||
|
|
) -> Client:
|
||
|
|
"""Create a new client (company/organization)."""
|
||
|
|
db_client = Client(
|
||
|
|
name=client.name,
|
||
|
|
contact_email=client.contact_email,
|
||
|
|
billing_plan=client.billing_plan,
|
||
|
|
)
|
||
|
|
db.add(db_client)
|
||
|
|
await db.commit()
|
||
|
|
await db.refresh(db_client)
|
||
|
|
return db_client
|
||
|
|
|
||
|
|
|
||
|
|
@router.get("/clients", response_model=list[ClientResponse])
|
||
|
|
async def list_clients(
|
||
|
|
db: AsyncSessionDep,
|
||
|
|
_: AdminKeyDep,
|
||
|
|
) -> list[Client]:
|
||
|
|
"""List all clients."""
|
||
|
|
result = await db.execute(select(Client).order_by(Client.created_at.desc()))
|
||
|
|
return list(result.scalars().all())
|
||
|
|
|
||
|
|
|
||
|
|
@router.get("/clients/{client_id}", response_model=ClientResponse)
|
||
|
|
async def get_client(
|
||
|
|
client_id: UUID,
|
||
|
|
db: AsyncSessionDep,
|
||
|
|
_: AdminKeyDep,
|
||
|
|
) -> Client:
|
||
|
|
"""Get a specific client by ID."""
|
||
|
|
result = await db.execute(select(Client).where(Client.id == client_id))
|
||
|
|
client = result.scalar_one_or_none()
|
||
|
|
if client is None:
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
||
|
|
detail="Client not found",
|
||
|
|
)
|
||
|
|
return client
|
||
|
|
|
||
|
|
|
||
|
|
@router.patch("/clients/{client_id}", response_model=ClientResponse)
|
||
|
|
async def update_client(
|
||
|
|
client_id: UUID,
|
||
|
|
update: ClientUpdate,
|
||
|
|
db: AsyncSessionDep,
|
||
|
|
_: AdminKeyDep,
|
||
|
|
) -> Client:
|
||
|
|
"""Update a client."""
|
||
|
|
result = await db.execute(select(Client).where(Client.id == client_id))
|
||
|
|
client = result.scalar_one_or_none()
|
||
|
|
if client is None:
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
||
|
|
detail="Client not found",
|
||
|
|
)
|
||
|
|
|
||
|
|
update_data = update.model_dump(exclude_unset=True)
|
||
|
|
for field, value in update_data.items():
|
||
|
|
setattr(client, field, value)
|
||
|
|
|
||
|
|
await db.commit()
|
||
|
|
await db.refresh(client)
|
||
|
|
return client
|
||
|
|
|
||
|
|
|
||
|
|
@router.delete("/clients/{client_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||
|
|
async def delete_client(
|
||
|
|
client_id: UUID,
|
||
|
|
db: AsyncSessionDep,
|
||
|
|
_: AdminKeyDep,
|
||
|
|
) -> None:
|
||
|
|
"""Delete a client and all associated instances."""
|
||
|
|
result = await db.execute(select(Client).where(Client.id == client_id))
|
||
|
|
client = result.scalar_one_or_none()
|
||
|
|
if client is None:
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
||
|
|
detail="Client not found",
|
||
|
|
)
|
||
|
|
|
||
|
|
await db.delete(client)
|
||
|
|
await db.commit()
|
||
|
|
|
||
|
|
|
||
|
|
# ============ INSTANCE MANAGEMENT ============
|
||
|
|
|
||
|
|
|
||
|
|
@router.post(
|
||
|
|
"/clients/{client_id}/instances",
|
||
|
|
response_model=InstanceResponse,
|
||
|
|
status_code=status.HTTP_201_CREATED,
|
||
|
|
)
|
||
|
|
async def create_instance(
|
||
|
|
client_id: UUID,
|
||
|
|
instance: InstanceCreate,
|
||
|
|
db: AsyncSessionDep,
|
||
|
|
_: AdminKeyDep,
|
||
|
|
) -> dict:
|
||
|
|
"""
|
||
|
|
Create a new instance for a client.
|
||
|
|
|
||
|
|
Returns the license_key and hub_api_key in PLAINTEXT - this is the only time
|
||
|
|
they are visible. Store them securely and provide to client for their config.json.
|
||
|
|
"""
|
||
|
|
# Verify client exists
|
||
|
|
client_result = await db.execute(select(Client).where(Client.id == client_id))
|
||
|
|
client = client_result.scalar_one_or_none()
|
||
|
|
if client is None:
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
||
|
|
detail="Client not found",
|
||
|
|
)
|
||
|
|
|
||
|
|
# Check instance_id uniqueness
|
||
|
|
existing = await db.execute(
|
||
|
|
select(Instance).where(Instance.instance_id == instance.instance_id)
|
||
|
|
)
|
||
|
|
if existing.scalar_one_or_none():
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_409_CONFLICT,
|
||
|
|
detail=f"Instance with id '{instance.instance_id}' already exists",
|
||
|
|
)
|
||
|
|
|
||
|
|
# Generate license key
|
||
|
|
license_key = f"lb_inst_{secrets.token_hex(32)}"
|
||
|
|
license_key_hash = hashlib.sha256(license_key.encode()).hexdigest()
|
||
|
|
license_key_prefix = license_key[:12]
|
||
|
|
|
||
|
|
# Generate hub API key
|
||
|
|
hub_api_key = f"hk_{secrets.token_hex(24)}"
|
||
|
|
hub_api_key_hash = hashlib.sha256(hub_api_key.encode()).hexdigest()
|
||
|
|
|
||
|
|
now = utc_now()
|
||
|
|
db_instance = Instance(
|
||
|
|
client_id=client_id,
|
||
|
|
instance_id=instance.instance_id,
|
||
|
|
license_key_hash=license_key_hash,
|
||
|
|
license_key_prefix=license_key_prefix,
|
||
|
|
license_status="active",
|
||
|
|
license_issued_at=now,
|
||
|
|
license_expires_at=instance.license_expires_at,
|
||
|
|
hub_api_key_hash=hub_api_key_hash,
|
||
|
|
region=instance.region,
|
||
|
|
status="pending",
|
||
|
|
)
|
||
|
|
db.add(db_instance)
|
||
|
|
await db.commit()
|
||
|
|
await db.refresh(db_instance)
|
||
|
|
|
||
|
|
# Return instance with plaintext keys (only time visible)
|
||
|
|
return {
|
||
|
|
"id": db_instance.id,
|
||
|
|
"instance_id": db_instance.instance_id,
|
||
|
|
"client_id": db_instance.client_id,
|
||
|
|
"license_key": license_key, # Plaintext, only time visible
|
||
|
|
"license_key_prefix": db_instance.license_key_prefix,
|
||
|
|
"license_status": db_instance.license_status,
|
||
|
|
"license_issued_at": db_instance.license_issued_at,
|
||
|
|
"license_expires_at": db_instance.license_expires_at,
|
||
|
|
"hub_api_key": hub_api_key, # Plaintext, only time visible
|
||
|
|
"activated_at": db_instance.activated_at,
|
||
|
|
"last_activation_at": db_instance.last_activation_at,
|
||
|
|
"activation_count": db_instance.activation_count,
|
||
|
|
"region": db_instance.region,
|
||
|
|
"version": db_instance.version,
|
||
|
|
"last_seen_at": db_instance.last_seen_at,
|
||
|
|
"status": db_instance.status,
|
||
|
|
"created_at": db_instance.created_at,
|
||
|
|
"updated_at": db_instance.updated_at,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
@router.get("/clients/{client_id}/instances", response_model=list[InstanceBriefResponse])
|
||
|
|
async def list_client_instances(
|
||
|
|
client_id: UUID,
|
||
|
|
db: AsyncSessionDep,
|
||
|
|
_: AdminKeyDep,
|
||
|
|
) -> list[Instance]:
|
||
|
|
"""List all instances for a client."""
|
||
|
|
# Verify client exists
|
||
|
|
client_result = await db.execute(select(Client).where(Client.id == client_id))
|
||
|
|
if client_result.scalar_one_or_none() is None:
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
||
|
|
detail="Client not found",
|
||
|
|
)
|
||
|
|
|
||
|
|
result = await db.execute(
|
||
|
|
select(Instance)
|
||
|
|
.where(Instance.client_id == client_id)
|
||
|
|
.order_by(Instance.created_at.desc())
|
||
|
|
)
|
||
|
|
return list(result.scalars().all())
|
||
|
|
|
||
|
|
|
||
|
|
@router.get("/instances/{instance_id}", response_model=InstanceBriefResponse)
|
||
|
|
async def get_instance(
|
||
|
|
instance_id: str,
|
||
|
|
db: AsyncSessionDep,
|
||
|
|
_: AdminKeyDep,
|
||
|
|
) -> Instance:
|
||
|
|
"""Get a specific instance by its instance_id."""
|
||
|
|
result = await db.execute(
|
||
|
|
select(Instance).where(Instance.instance_id == instance_id)
|
||
|
|
)
|
||
|
|
instance = result.scalar_one_or_none()
|
||
|
|
if instance is None:
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
||
|
|
detail="Instance not found",
|
||
|
|
)
|
||
|
|
return instance
|
||
|
|
|
||
|
|
|
||
|
|
@router.post("/instances/{instance_id}/rotate-license", response_model=dict)
|
||
|
|
async def rotate_license_key(
|
||
|
|
instance_id: str,
|
||
|
|
db: AsyncSessionDep,
|
||
|
|
_: AdminKeyDep,
|
||
|
|
) -> dict:
|
||
|
|
"""
|
||
|
|
Generate a new license key for an instance.
|
||
|
|
|
||
|
|
Invalidates the old key. Returns new key in plaintext (only time visible).
|
||
|
|
"""
|
||
|
|
result = await db.execute(
|
||
|
|
select(Instance).where(Instance.instance_id == instance_id)
|
||
|
|
)
|
||
|
|
instance = result.scalar_one_or_none()
|
||
|
|
if instance is None:
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
||
|
|
detail="Instance not found",
|
||
|
|
)
|
||
|
|
|
||
|
|
new_license_key = f"lb_inst_{secrets.token_hex(32)}"
|
||
|
|
instance.license_key_hash = hashlib.sha256(new_license_key.encode()).hexdigest()
|
||
|
|
instance.license_key_prefix = new_license_key[:12]
|
||
|
|
instance.license_issued_at = utc_now()
|
||
|
|
|
||
|
|
await db.commit()
|
||
|
|
|
||
|
|
return {
|
||
|
|
"instance_id": instance.instance_id,
|
||
|
|
"license_key": new_license_key,
|
||
|
|
"license_key_prefix": instance.license_key_prefix,
|
||
|
|
"license_issued_at": instance.license_issued_at,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
@router.post("/instances/{instance_id}/rotate-hub-key", response_model=dict)
|
||
|
|
async def rotate_hub_api_key(
|
||
|
|
instance_id: str,
|
||
|
|
db: AsyncSessionDep,
|
||
|
|
_: AdminKeyDep,
|
||
|
|
) -> dict:
|
||
|
|
"""
|
||
|
|
Generate a new Hub API key for telemetry.
|
||
|
|
|
||
|
|
Invalidates the old key. Returns new key in plaintext (only time visible).
|
||
|
|
"""
|
||
|
|
result = await db.execute(
|
||
|
|
select(Instance).where(Instance.instance_id == instance_id)
|
||
|
|
)
|
||
|
|
instance = result.scalar_one_or_none()
|
||
|
|
if instance is None:
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
||
|
|
detail="Instance not found",
|
||
|
|
)
|
||
|
|
|
||
|
|
new_hub_api_key = f"hk_{secrets.token_hex(24)}"
|
||
|
|
instance.hub_api_key_hash = hashlib.sha256(new_hub_api_key.encode()).hexdigest()
|
||
|
|
|
||
|
|
await db.commit()
|
||
|
|
|
||
|
|
return {
|
||
|
|
"instance_id": instance.instance_id,
|
||
|
|
"hub_api_key": new_hub_api_key,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
@router.post("/instances/{instance_id}/suspend", response_model=dict)
|
||
|
|
async def suspend_instance(
|
||
|
|
instance_id: str,
|
||
|
|
db: AsyncSessionDep,
|
||
|
|
_: AdminKeyDep,
|
||
|
|
) -> dict:
|
||
|
|
"""Suspend an instance license (blocks future activations)."""
|
||
|
|
result = await db.execute(
|
||
|
|
select(Instance).where(Instance.instance_id == instance_id)
|
||
|
|
)
|
||
|
|
instance = result.scalar_one_or_none()
|
||
|
|
if instance is None:
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
||
|
|
detail="Instance not found",
|
||
|
|
)
|
||
|
|
|
||
|
|
instance.license_status = "suspended"
|
||
|
|
instance.status = "suspended"
|
||
|
|
await db.commit()
|
||
|
|
|
||
|
|
return {"instance_id": instance.instance_id, "status": "suspended"}
|
||
|
|
|
||
|
|
|
||
|
|
@router.post("/instances/{instance_id}/reactivate", response_model=dict)
|
||
|
|
async def reactivate_instance(
|
||
|
|
instance_id: str,
|
||
|
|
db: AsyncSessionDep,
|
||
|
|
_: AdminKeyDep,
|
||
|
|
) -> dict:
|
||
|
|
"""Reactivate a suspended instance license."""
|
||
|
|
result = await db.execute(
|
||
|
|
select(Instance).where(Instance.instance_id == instance_id)
|
||
|
|
)
|
||
|
|
instance = result.scalar_one_or_none()
|
||
|
|
if instance is None:
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
||
|
|
detail="Instance not found",
|
||
|
|
)
|
||
|
|
|
||
|
|
if instance.license_status == "revoked":
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||
|
|
detail="Cannot reactivate a revoked license",
|
||
|
|
)
|
||
|
|
|
||
|
|
instance.license_status = "active"
|
||
|
|
instance.status = "active" if instance.activated_at else "pending"
|
||
|
|
await db.commit()
|
||
|
|
|
||
|
|
return {"instance_id": instance.instance_id, "status": instance.status}
|
||
|
|
|
||
|
|
|
||
|
|
@router.delete("/instances/{instance_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||
|
|
async def delete_instance(
|
||
|
|
instance_id: str,
|
||
|
|
db: AsyncSessionDep,
|
||
|
|
_: AdminKeyDep,
|
||
|
|
) -> None:
|
||
|
|
"""Delete an instance."""
|
||
|
|
result = await db.execute(
|
||
|
|
select(Instance).where(Instance.instance_id == instance_id)
|
||
|
|
)
|
||
|
|
instance = result.scalar_one_or_none()
|
||
|
|
if instance is None:
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
||
|
|
detail="Instance not found",
|
||
|
|
)
|
||
|
|
|
||
|
|
await db.delete(instance)
|
||
|
|
await db.commit()
|