Ukweli / app /api /api_keys.py
Benard John
feat: implement full-stack architecture with database models, authentication services, and web dashboard components
37b5223
Raw
History Blame Contribute Delete
4.92 kB
"""
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