Étape 7 : authentification par lien magique + JWT + rôles

- app/auth/ : jetons (lien magique aléatoire haché SHA-256, JWT HS256),
  dépendances get_current_account / current_account_in_edition / require_roles
  + require_coordinator / require_staff, et ensure_can_write_position/_shift
- Endpoints app/api/auth.py : POST .../auth/magic-link (202 constant, pas de
  divulgation), POST .../auth/verify (JWT, jeton à usage unique), GET /auth/me
- Envoi d'email stubé : le lien est journalisé (logger app.auth, identifiants
  seulement, aucune donnée nominative)
- app/api/accounts.py : CRUD des comptes réservé à la COORDINATION
- Rôles appliqués sur toute l'API :
  COORDINATION accès complet ; RÉFÉRENT lecture globale + écriture sur ses
  seuls postes/créneaux/affectations ; BÉNÉVOLE sa fiche et ses affectations,
  aucune écriture
- imports.py aligné sur la dépendance get_edition (404 avant 403)
- Secret JWT porté à >= 32 octets (config, .env.example, docker-compose)
- seed : crée un compte VOLUNTEER pour chaque bénévole avec email (104 comptes)
- conftest : fixtures make_account / coordinator / as_account / raw_client ;
  la suite existante s'exécute en COORDINATION
- 13 tests d'authentification + de rôles ; 119 tests au total ; alembic check
  toujours vert (Account / MagicLinkToken déjà dans la migration 0001)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
AdamThomas
2026-09-09 23:22:35 +02:00
co-authored by Claude Sonnet 5
parent c9dda65361
commit ced922f785
24 changed files with 971 additions and 50 deletions
+7 -13
View File
@@ -1,12 +1,13 @@
"""Endpoints d'import : analyse puis exécution."""
import datetime as dt
import uuid
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from pydantic import ValidationError
from sqlmodel import Session, select
from app.api.deps import get_edition
from app.auth.deps import require_coordinator
from app.db import get_session
from app.importing.parser import ImportParseError, parse_upload
from app.importing.schemas import (
@@ -17,7 +18,7 @@ from app.importing.schemas import (
PreviewRow,
)
from app.importing.service import run_import, suggest_mapping, validate_mapping
from app.models import DayPart, Edition
from app.models import Account, DayPart, Edition
router = APIRouter(prefix="/editions/{edition_id}/import", tags=["import"])
@@ -25,13 +26,6 @@ _PREVIEW_ROWS = 5
_MAX_BYTES = 5 * 1024 * 1024
def _get_edition(edition_id: uuid.UUID, session: Session) -> Edition:
edition = session.get(Edition, edition_id)
if edition is None:
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
return edition
async def _read_upload(file: UploadFile) -> bytes:
content = await file.read()
if not content:
@@ -68,11 +62,11 @@ def _day_parts(session: Session, edition: Edition) -> list[DayPart]:
@router.post("/analyze", response_model=AnalyzeResponse)
async def analyze(
edition_id: uuid.UUID,
file: UploadFile = File(...),
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> AnalyzeResponse:
edition = _get_edition(edition_id, session)
content = await _read_upload(file)
try:
table = parse_upload(file.filename or "", content)
@@ -109,12 +103,12 @@ async def analyze(
@router.post("/execute", response_model=ImportReport)
async def execute(
edition_id: uuid.UUID,
file: UploadFile = File(...),
mapping: str = Form(...),
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> ImportReport:
edition = _get_edition(edition_id, session)
content = await _read_upload(file)
try: