Imago/app/routers/auth.py
Bruno Charest cc99fea20a
Some checks failed
CI / Lint & Format (push) Has been cancelled
CI / Tests (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
Add comprehensive test suite for image processing and related services
- Implement tests for database generator to ensure proper session handling.
- Create tests for EXIF extraction and conversion functions.
- Add tests for image-related endpoints, ensuring proper data retrieval and isolation between clients.
- Develop tests for OCR functionality, including language detection and text extraction.
- Introduce tests for the image processing pipeline, covering success and failure scenarios.
- Validate rate limiting functionality and ensure independent counters for different clients.
- Implement scraper tests to verify HTML content fetching and error handling.
- Add unit tests for various services, including storage and filename generation.
- Establish worker entry point for ARQ to handle background image processing tasks.
2026-02-24 11:22:10 -05:00

199 lines
7.5 KiB
Python

"""
Router — Auth : gestion des clients API (CRUD + rotation de clé)
"""
import logging
import secrets
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies.auth import get_current_client, hash_api_key, require_scope
from app.models.client import APIClient
from app.schemas.auth import (
ClientCreate,
ClientCreateResponse,
ClientResponse,
ClientUpdate,
KeyRotateResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["Authentification"])
# ─────────────────────────────────────────────────────────────
# CRÉER UN CLIENT
# ─────────────────────────────────────────────────────────────
@router.post(
"/clients",
response_model=ClientCreateResponse,
status_code=status.HTTP_201_CREATED,
summary="Créer un nouveau client API",
description=(
"Crée un client et retourne la clé API **en clair une seule fois**. "
"Stockez-la immédiatement — elle ne sera plus jamais affichée."
),
dependencies=[Depends(require_scope("admin"))],
)
async def create_client(
body: ClientCreate,
db: AsyncSession = Depends(get_db),
) -> ClientCreateResponse:
# Génération de la clé API
raw_key = secrets.token_urlsafe(32)
key_hash = hash_api_key(raw_key)
client = APIClient(
name=body.name,
api_key_hash=key_hash,
scopes=body.scopes,
plan=body.plan,
)
db.add(client)
await db.flush()
await db.refresh(client)
logger.info("Client créé : %s (%s)", client.name, client.id)
return ClientCreateResponse(
id=client.id,
name=client.name,
scopes=client.scopes,
plan=client.plan,
is_active=client.is_active,
created_at=client.created_at,
updated_at=client.updated_at,
api_key=raw_key,
)
# ─────────────────────────────────────────────────────────────
# LISTER LES CLIENTS
# ─────────────────────────────────────────────────────────────
@router.get(
"/clients",
response_model=list[ClientResponse],
summary="Lister tous les clients API",
dependencies=[Depends(require_scope("admin"))],
)
async def list_clients(
db: AsyncSession = Depends(get_db),
) -> list[ClientResponse]:
result = await db.execute(select(APIClient).order_by(APIClient.created_at.desc()))
clients = result.scalars().all()
return [ClientResponse.model_validate(c) for c in clients]
# ─────────────────────────────────────────────────────────────
# DÉTAIL D'UN CLIENT
# ─────────────────────────────────────────────────────────────
@router.get(
"/clients/{client_id}",
response_model=ClientResponse,
summary="Détail d'un client API",
dependencies=[Depends(require_scope("admin"))],
)
async def get_client(
client_id: str,
db: AsyncSession = Depends(get_db),
) -> ClientResponse:
result = await db.execute(select(APIClient).where(APIClient.id == client_id))
client = result.scalar_one_or_none()
if not client:
raise HTTPException(status_code=404, detail="Client introuvable")
return ClientResponse.model_validate(client)
# ─────────────────────────────────────────────────────────────
# MODIFIER UN CLIENT
# ─────────────────────────────────────────────────────────────
@router.patch(
"/clients/{client_id}",
response_model=ClientResponse,
summary="Modifier un client API",
dependencies=[Depends(require_scope("admin"))],
)
async def update_client(
client_id: str,
body: ClientUpdate,
db: AsyncSession = Depends(get_db),
) -> ClientResponse:
result = await db.execute(select(APIClient).where(APIClient.id == client_id))
client = result.scalar_one_or_none()
if not client:
raise HTTPException(status_code=404, detail="Client introuvable")
update_data = body.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(client, field, value)
await db.flush()
await db.refresh(client)
logger.info("Client mis à jour : %s (%s)", client.name, client.id)
return ClientResponse.model_validate(client)
# ─────────────────────────────────────────────────────────────
# ROTATION DE CLÉ
# ─────────────────────────────────────────────────────────────
@router.post(
"/clients/{client_id}/rotate-key",
response_model=KeyRotateResponse,
summary="Régénérer la clé API d'un client",
description="Invalide l'ancienne clé et en génère une nouvelle.",
dependencies=[Depends(require_scope("admin"))],
)
async def rotate_key(
client_id: str,
db: AsyncSession = Depends(get_db),
) -> KeyRotateResponse:
result = await db.execute(select(APIClient).where(APIClient.id == client_id))
client = result.scalar_one_or_none()
if not client:
raise HTTPException(status_code=404, detail="Client introuvable")
raw_key = secrets.token_urlsafe(32)
client.api_key_hash = hash_api_key(raw_key)
await db.flush()
logger.info("Clé API rotée pour client : %s (%s)", client.name, client.id)
return KeyRotateResponse(id=client.id, api_key=raw_key)
# ─────────────────────────────────────────────────────────────
# DÉSACTIVER UN CLIENT (soft delete)
# ─────────────────────────────────────────────────────────────
@router.delete(
"/clients/{client_id}",
response_model=ClientResponse,
summary="Désactiver un client API",
description="Soft delete — marque le client comme inactif sans supprimer les données.",
dependencies=[Depends(require_scope("admin"))],
)
async def delete_client(
client_id: str,
db: AsyncSession = Depends(get_db),
) -> ClientResponse:
result = await db.execute(select(APIClient).where(APIClient.id == client_id))
client = result.scalar_one_or_none()
if not client:
raise HTTPException(status_code=404, detail="Client introuvable")
client.is_active = False
await db.flush()
await db.refresh(client)
logger.info("Client désactivé : %s (%s)", client.name, client.id)
return ClientResponse.model_validate(client)