"""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()