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:
@@ -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("/")
|
||||
|
||||
@@ -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"]
|
||||
|
||||
217
src/openrouter_monitor/routers/api_keys.py
Normal file
217
src/openrouter_monitor/routers/api_keys.py
Normal 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
|
||||
Reference in New Issue
Block a user