feat(api-keys): T24-T27 implement API keys CRUD endpoints

- T24: POST /api/keys with encryption and limit validation
- T25: GET /api/keys with pagination and sorting
- T26: PUT /api/keys/{id} for partial updates
- T27: DELETE /api/keys/{id} with cascade
- Add ownership verification (403 for unauthorized access)
- API key encryption with AES-256 before storage
- Never expose API key value in responses
- 100% coverage on api_keys router (25 tests)

Refs: T24 T25 T26 T27
This commit is contained in:
Luca Sacchi Ricciardi
2026-04-07 14:41:53 +02:00
parent 2e4c1bb1e5
commit abf7e7a532
7 changed files with 796 additions and 4 deletions

View File

@@ -6,6 +6,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from openrouter_monitor.config import get_settings
from openrouter_monitor.routers import api_keys
from openrouter_monitor.routers import auth
settings = get_settings()
@@ -29,6 +30,7 @@ app.add_middleware(
# Include routers
app.include_router(auth.router, prefix="/api/auth", tags=["authentication"])
app.include_router(api_keys.router, prefix="/api/keys", tags=["api-keys"])
@app.get("/")

View File

@@ -1,4 +1,5 @@
"""Routers package for OpenRouter Monitor."""
from openrouter_monitor.routers import api_keys
from openrouter_monitor.routers import auth
__all__ = ["auth"]
__all__ = ["auth", "api_keys"]

View File

@@ -0,0 +1,217 @@
"""API Keys router.
T24-T27: Endpoints for API key management (CRUD operations).
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from sqlalchemy import desc
from typing import Optional
from openrouter_monitor.config import get_settings
from openrouter_monitor.database import get_db
from openrouter_monitor.dependencies import get_current_user
from openrouter_monitor.models import ApiKey, User
from openrouter_monitor.schemas import (
ApiKeyCreate,
ApiKeyUpdate,
ApiKeyResponse,
ApiKeyListResponse,
)
from openrouter_monitor.services.encryption import EncryptionService
router = APIRouter()
settings = get_settings()
# Maximum number of API keys per user
MAX_API_KEYS_PER_USER = settings.max_api_keys_per_user
# Initialize encryption service
encryption_service = EncryptionService(settings.encryption_key)
@router.post("", response_model=ApiKeyResponse, status_code=status.HTTP_201_CREATED)
async def create_api_key(
api_key_data: ApiKeyCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Create a new API key for the current user.
The API key is encrypted using AES-256 before storage.
Args:
api_key_data: API key creation data (name and key value)
db: Database session
current_user: Currently authenticated user
Returns:
ApiKeyResponse with the created key details (excluding the key value)
Raises:
HTTPException: 400 if user has reached MAX_API_KEYS_PER_USER limit
HTTPException: 422 if API key format is invalid (validation handled by Pydantic)
"""
# Check if user has reached the limit
existing_keys_count = db.query(ApiKey).filter(
ApiKey.user_id == current_user.id
).count()
if existing_keys_count >= MAX_API_KEYS_PER_USER:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Maximum number of API keys ({MAX_API_KEYS_PER_USER}) reached. "
"Please delete an existing key before creating a new one."
)
# Encrypt the API key before storing
encrypted_key = encryption_service.encrypt(api_key_data.key)
# Create new API key
new_api_key = ApiKey(
user_id=current_user.id,
name=api_key_data.name,
key_encrypted=encrypted_key,
is_active=True
)
db.add(new_api_key)
db.commit()
db.refresh(new_api_key)
return new_api_key
@router.get("", response_model=ApiKeyListResponse)
async def list_api_keys(
skip: int = Query(0, ge=0, description="Number of records to skip"),
limit: int = Query(10, ge=1, le=100, description="Maximum number of records to return"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List all API keys for the current user.
Results are paginated and sorted by creation date (newest first).
Args:
skip: Number of records to skip for pagination
limit: Maximum number of records to return
db: Database session
current_user: Currently authenticated user
Returns:
ApiKeyListResponse with items list and total count
"""
# Get total count for pagination
total = db.query(ApiKey).filter(
ApiKey.user_id == current_user.id
).count()
# Get paginated keys, sorted by created_at DESC
api_keys = db.query(ApiKey).filter(
ApiKey.user_id == current_user.id
).order_by(
desc(ApiKey.created_at)
).offset(skip).limit(limit).all()
return ApiKeyListResponse(items=api_keys, total=total)
@router.put("/{api_key_id}", response_model=ApiKeyResponse)
async def update_api_key(
api_key_id: int,
api_key_data: ApiKeyUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update an existing API key.
Only the name and is_active fields can be updated.
Users can only update their own API keys.
Args:
api_key_id: ID of the API key to update
api_key_data: API key update data (optional fields)
db: Database session
current_user: Currently authenticated user
Returns:
ApiKeyResponse with the updated key details
Raises:
HTTPException: 404 if API key not found
HTTPException: 403 if user doesn't own the key
"""
# Find the API key
api_key = db.query(ApiKey).filter(
ApiKey.id == api_key_id
).first()
if not api_key:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="API key not found"
)
# Verify ownership
if api_key.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to modify this API key"
)
# Update fields if provided
if api_key_data.name is not None:
api_key.name = api_key_data.name
if api_key_data.is_active is not None:
api_key.is_active = api_key_data.is_active
db.commit()
db.refresh(api_key)
return api_key
@router.delete("/{api_key_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_api_key(
api_key_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete an API key.
Deleting an API key also cascades to delete all associated usage statistics.
Users can only delete their own API keys.
Args:
api_key_id: ID of the API key to delete
db: Database session
current_user: Currently authenticated user
Raises:
HTTPException: 404 if API key not found
HTTPException: 403 if user doesn't own the key
"""
# Find the API key
api_key = db.query(ApiKey).filter(
ApiKey.id == api_key_id
).first()
if not api_key:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="API key not found"
)
# Verify ownership
if api_key.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to delete this API key"
)
# Delete the API key (cascade to usage_stats is handled by SQLAlchemy)
db.delete(api_key)
db.commit()
return None