Benard John
feat: implement full-stack architecture with database models, authentication services, and web dashboard components
37b5223 | """ | |
| 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, | |
| ) | |
| 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)) | |
| 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, | |
| ) | |
| 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 | |