""" Ukweli — API Key Management GET /api-keys -> list caller's keys POST /api-keys -> generate a new key (secret shown ONCE) DELETE /api-keys/{id} -> revoke a key """ from __future__ import annotations import logging from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api.auth import ( AuthContext, generate_api_key_pair, hash_api_secret, require_auth, ) from app.db.session import get_db_session from app.models.auth import ApiKey, AuthAuditLog from app.models.enums import APIKeyStatus, AuthEventType, AuthTier from app.models.schemas import ( ApiKeyCreateRequest, ApiKeyCreateResponse, ApiKeyListResponse, ApiKeyOut, ) logger = logging.getLogger("ukweli.api.api_keys") router = APIRouter(prefix="/api-keys", tags=["API Keys"]) def _to_out(rec: ApiKey) -> ApiKeyOut: return ApiKeyOut( id=rec.id, key_id=rec.key_id, name=rec.name, description=rec.description, tier=rec.tier, quota_per_day=rec.quota_per_day, requests_today=rec.requests_today, last_used_at=rec.last_used_at, status=rec.status, created_at=rec.created_at, expires_at=rec.expires_at, ) @router.get("", response_model=ApiKeyListResponse) async def list_api_keys( db: AsyncSession = Depends(get_db_session), auth: AuthContext = Depends(require_auth), ) -> ApiKeyListResponse: """List the caller's API keys. Requires registered tier.""" if auth.user is None: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="API key management requires a registered account.", ) result = await db.execute( select(ApiKey) .where(ApiKey.user_id == auth.user.id) .order_by(ApiKey.created_at.desc()) ) keys = [_to_out(k) for k in result.scalars().all()] return ApiKeyListResponse(keys=keys, total=len(keys)) @router.post("", response_model=ApiKeyCreateResponse, status_code=status.HTTP_201_CREATED) async def create_api_key( payload: ApiKeyCreateRequest, db: AsyncSession = Depends(get_db_session), auth: AuthContext = Depends(require_auth), ) -> ApiKeyCreateResponse: """ Issue a new API key. The **secret** is returned in plaintext exactly once — we only store its bcrypt hash. """ if auth.user is None: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="API key management requires a registered account.", ) key_id, secret = generate_api_key_pair() rec = ApiKey( user_id=auth.user.id, key_id=key_id, secret_hash=hash_api_secret(secret), name=payload.name, description=payload.description, tier=AuthTier.API.value, quota_per_day=10000, status=APIKeyStatus.ACTIVE.value, requests_today_reset_at=datetime.now(timezone.utc), ) db.add(rec) await db.flush() # Audit db.add( AuthAuditLog( event_type=AuthEventType.API_KEY_CREATED.value, user_id=auth.user.id, api_key_id=rec.id, fingerprint=auth.fingerprint, success=True, ) ) logger.info("API key created: user=%s key_id=%s", auth.user.id, key_id) return ApiKeyCreateResponse( id=rec.id, key_id=key_id, secret=secret, name=rec.name, description=rec.description, tier=rec.tier, quota_per_day=rec.quota_per_day, created_at=rec.created_at, ) @router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT) async def revoke_api_key( key_id: str, db: AsyncSession = Depends(get_db_session), auth: AuthContext = Depends(require_auth), ): """Revoke an API key. Idempotent.""" if auth.user is None: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="API key management requires a registered account.", ) result = await db.execute( select(ApiKey).where( ApiKey.key_id == key_id, ApiKey.user_id == auth.user.id ) ) rec = result.scalar_one_or_none() if rec is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="API key not found.", ) if rec.status != APIKeyStatus.REVOKED.value: rec.status = APIKeyStatus.REVOKED.value db.add( AuthAuditLog( event_type=AuthEventType.API_KEY_REVOKED.value, user_id=auth.user.id, api_key_id=rec.id, fingerprint=auth.fingerprint, success=True, ) ) logger.info("API key revoked: id=%s", rec.id) return None