É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
+78
View File
@@ -0,0 +1,78 @@
"""Gestion des comptes par la coordination (créer un référent, un bénévole
connecté…)."""
import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select
from app.api._common import owned_or_404
from app.api.deps import get_edition
from app.api.schemas import AccountCreate, AccountRead
from app.auth.deps import require_coordinator
from app.db import get_session
from app.models import Account, Edition, Volunteer
router = APIRouter(
prefix="/editions/{edition_id}/accounts", tags=["accounts"]
)
@router.post("", response_model=AccountRead, status_code=status.HTTP_201_CREATED)
def create_account(
payload: AccountCreate,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Account:
if payload.volunteer_id is not None:
volunteer = session.get(Volunteer, payload.volunteer_id)
if volunteer is None or volunteer.edition_id != edition.id:
raise HTTPException(
status_code=422, detail={"code": "account.volunteer_wrong_edition"}
)
account = Account(
edition_id=edition.id,
email=payload.email.strip(),
role=payload.role,
volunteer_id=payload.volunteer_id,
)
session.add(account)
try:
session.commit()
except IntegrityError as exc:
session.rollback()
raise HTTPException(
status_code=409, detail={"code": "account.duplicate"}
) from exc
session.refresh(account)
return account
@router.get("", response_model=list[AccountRead])
def list_accounts(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> list[Account]:
return list(
session.exec(
select(Account)
.where(Account.edition_id == edition.id)
.order_by(Account.email)
)
)
@router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_account(
account_id: uuid.UUID,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Response:
account = owned_or_404(session.get(Account, account_id), edition.id)
session.delete(account)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
+42 -1
View File
@@ -17,11 +17,18 @@ from app.api.schemas import (
AssignmentValidateResult,
AssignmentWriteResult,
)
from app.auth.deps import (
current_account_in_edition,
ensure_can_write_shift,
require_staff,
)
from app.db import get_session
from app.models import (
ACTIVE_ASSIGNMENT_STATUSES,
Account,
Assignment,
Edition,
Role,
Shift,
Volunteer,
)
@@ -50,12 +57,25 @@ def _pair_or_422(
return volunteer, shift
def _forbidden() -> HTTPException:
return HTTPException(status_code=403, detail={"code": "error.forbidden"})
def _ensure_can_read(account: Account, volunteer_id: uuid.UUID) -> None:
if account.role in (Role.COORDINATOR, Role.LEAD):
return
if account.volunteer_id == volunteer_id:
return
raise _forbidden()
@router.post(
"/validate", response_model=AssignmentValidateResult, tags=["assignments"]
)
def validate_assignment(
payload: AssignmentValidateRequest,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_staff),
session: Session = Depends(get_session),
) -> AssignmentValidateResult:
volunteer, shift = _pair_or_422(
@@ -77,11 +97,13 @@ def validate_assignment(
def create_assignment(
payload: AssignmentCreate,
edition: Edition = Depends(get_edition),
account: Account = Depends(current_account_in_edition),
session: Session = Depends(get_session),
) -> AssignmentWriteResult:
volunteer, shift = _pair_or_422(
session, edition.id, payload.volunteer_id, payload.shift_id
)
ensure_can_write_shift(account, session, shift)
existing = session.exec(
select(Assignment)
.where(Assignment.volunteer_id == volunteer.id)
@@ -132,8 +154,14 @@ def list_assignments(
volunteer_id: uuid.UUID | None = None,
shift_id: uuid.UUID | None = None,
edition: Edition = Depends(get_edition),
account: Account = Depends(current_account_in_edition),
session: Session = Depends(get_session),
) -> list[Assignment]:
if account.role is Role.VOLUNTEER:
# Un bénévole ne voit que ses propres affectations.
if volunteer_id not in (None, account.volunteer_id):
raise _forbidden()
volunteer_id = account.volunteer_id
stmt = select(Assignment).where(Assignment.edition_id == edition.id)
if volunteer_id is not None:
stmt = stmt.where(Assignment.volunteer_id == volunteer_id)
@@ -146,9 +174,14 @@ def list_assignments(
def get_assignment(
assignment_id: uuid.UUID,
edition: Edition = Depends(get_edition),
account: Account = Depends(current_account_in_edition),
session: Session = Depends(get_session),
) -> Assignment:
return owned_or_404(session.get(Assignment, assignment_id), edition.id)
assignment = owned_or_404(
session.get(Assignment, assignment_id), edition.id
)
_ensure_can_read(account, assignment.volunteer_id)
return assignment
@router.patch("/{assignment_id}", response_model=AssignmentWriteResult)
@@ -156,11 +189,15 @@ def update_assignment(
assignment_id: uuid.UUID,
payload: AssignmentUpdate,
edition: Edition = Depends(get_edition),
account: Account = Depends(current_account_in_edition),
session: Session = Depends(get_session),
) -> AssignmentWriteResult:
assignment = owned_or_404(
session.get(Assignment, assignment_id), edition.id
)
ensure_can_write_shift(
account, session, session.get(Shift, assignment.shift_id)
)
changes = payload.model_dump(exclude_unset=True)
for field, value in changes.items():
setattr(assignment, field, value)
@@ -197,11 +234,15 @@ def update_assignment(
def delete_assignment(
assignment_id: uuid.UUID,
edition: Edition = Depends(get_edition),
account: Account = Depends(current_account_in_edition),
session: Session = Depends(get_session),
) -> Response:
assignment = owned_or_404(
session.get(Assignment, assignment_id), edition.id
)
ensure_can_write_shift(
account, session, session.get(Shift, assignment.shift_id)
)
session.delete(assignment)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
+103
View File
@@ -0,0 +1,103 @@
"""Endpoints d'authentification : lien magique, vérification, profil courant."""
import datetime as dt
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func
from sqlmodel import Session, select
from app.api.schemas import (
AccountRead,
MagicLinkRequest,
TokenResponse,
VerifyRequest,
)
from app.auth.deps import get_current_account
from app.auth.tokens import create_access_token, hash_token, new_raw_token
from app.config import get_settings
from app.db import get_session
from app.models import Account, MagicLinkToken, local_now
router = APIRouter(tags=["auth"])
logger = logging.getLogger("app.auth")
@router.post(
"/editions/{edition_id}/auth/magic-link",
status_code=status.HTTP_202_ACCEPTED,
)
def request_magic_link(
edition_id: uuid.UUID,
payload: MagicLinkRequest,
session: Session = Depends(get_session),
) -> dict[str, str]:
"""Émet un lien magique si un compte correspond. Réponse identique dans
tous les cas (pas de divulgation de l'existence d'un compte)."""
account = session.exec(
select(Account)
.where(Account.edition_id == edition_id)
.where(func.lower(Account.email) == payload.email.strip().lower())
).first()
if account is not None:
raw = new_raw_token()
token = MagicLinkToken(
account_id=account.id,
token_hash=hash_token(raw),
expires_at=local_now()
+ dt.timedelta(minutes=get_settings().magic_link_ttl_minutes),
)
session.add(token)
session.commit()
# STUB : en production, ce lien part par email. Ici on le journalise
# (identifiants uniquement, aucune donnée nominative).
logger.info(
"STUB lien magique — compte=%s édition=%s expire=%s jeton=%s",
account.id,
edition_id,
token.expires_at.isoformat(),
raw,
)
return {"status": "accepted"}
@router.post("/editions/{edition_id}/auth/verify", response_model=TokenResponse)
def verify_magic_link(
edition_id: uuid.UUID,
payload: VerifyRequest,
session: Session = Depends(get_session),
) -> TokenResponse:
token = session.exec(
select(MagicLinkToken).where(
MagicLinkToken.token_hash == hash_token(payload.token.strip())
)
).first()
if (
token is None
or token.used_at is not None
or token.expires_at < local_now()
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "auth.invalid_token"},
)
account = session.get(Account, token.account_id)
if account is None or account.edition_id != edition_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "auth.invalid_token"},
)
token.used_at = local_now()
session.commit()
session.refresh(account)
return TokenResponse(
access_token=create_access_token(account),
token_type="bearer",
account=AccountRead.model_validate(account),
)
@router.get("/auth/me", response_model=AccountRead)
def read_me(account: Account = Depends(get_current_account)) -> Account:
return account
+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:
+10 -1
View File
@@ -6,8 +6,16 @@ from sqlmodel import Session, select
from app.api.deps import get_edition
from app.api.schemas import MatrixPosition, MatrixResponse, MatrixShift
from app.auth.deps import require_staff
from app.db import get_session
from app.models import Assignment, AssignmentStatus, Edition, Position, Shift
from app.models import (
Account,
Assignment,
AssignmentStatus,
Edition,
Position,
Shift,
)
router = APIRouter(prefix="/editions/{edition_id}/matrix", tags=["matrix"])
@@ -15,6 +23,7 @@ router = APIRouter(prefix="/editions/{edition_id}/matrix", tags=["matrix"])
@router.get("", response_model=MatrixResponse)
def get_matrix(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_staff),
session: Session = Depends(get_session),
) -> MatrixResponse:
positions = list(
+13 -1
View File
@@ -8,8 +8,14 @@ from sqlmodel import Session, select
from app.api._common import owned_or_404
from app.api.deps import get_edition
from app.api.schemas import PositionCreate, PositionRead, PositionUpdate
from app.auth.deps import (
current_account_in_edition,
ensure_can_write_position,
require_coordinator,
require_staff,
)
from app.db import get_session
from app.models import Edition, Position
from app.models import Account, Edition, Position
router = APIRouter(
prefix="/editions/{edition_id}/positions", tags=["positions"]
@@ -20,6 +26,7 @@ router = APIRouter(
def create_position(
payload: PositionCreate,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Position:
position = Position(edition_id=edition.id, **payload.model_dump())
@@ -32,6 +39,7 @@ def create_position(
@router.get("", response_model=list[PositionRead])
def list_positions(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_staff),
session: Session = Depends(get_session),
) -> list[Position]:
return list(
@@ -47,6 +55,7 @@ def list_positions(
def get_position(
position_id: uuid.UUID,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_staff),
session: Session = Depends(get_session),
) -> Position:
return owned_or_404(session.get(Position, position_id), edition.id)
@@ -57,9 +66,11 @@ def update_position(
position_id: uuid.UUID,
payload: PositionUpdate,
edition: Edition = Depends(get_edition),
account: Account = Depends(current_account_in_edition),
session: Session = Depends(get_session),
) -> Position:
position = owned_or_404(session.get(Position, position_id), edition.id)
ensure_can_write_position(account, position)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(position, field, value)
session.commit()
@@ -71,6 +82,7 @@ def update_position(
def delete_position(
position_id: uuid.UUID,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Response:
position = owned_or_404(session.get(Position, position_id), edition.id)
+34
View File
@@ -9,6 +9,7 @@ from app.models import (
AssignmentSource,
AssignmentStatus,
PositionCategory,
Role,
TshirtSize,
)
from app.validation import Violation
@@ -190,3 +191,36 @@ class MatrixPosition(ApiModel):
class MatrixResponse(ApiModel):
edition_id: uuid.UUID
positions: list[MatrixPosition]
# --------------------------------------------------------------------------- #
# Authentification et comptes #
# --------------------------------------------------------------------------- #
class MagicLinkRequest(ApiModel):
email: str
class VerifyRequest(ApiModel):
token: str
class AccountRead(ApiModel):
id: uuid.UUID
edition_id: uuid.UUID
email: str
role: Role
volunteer_id: uuid.UUID | None
created_at: dt.datetime
updated_at: dt.datetime
class TokenResponse(ApiModel):
access_token: str
token_type: str
account: AccountRead
class AccountCreate(ApiModel):
email: str
role: Role
volunteer_id: uuid.UUID | None = None
+25 -3
View File
@@ -8,8 +8,21 @@ from sqlmodel import Session, select
from app.api._common import owned_or_404, replace_links, resolve_skills
from app.api.deps import get_edition
from app.api.schemas import ShiftCreate, ShiftRead, ShiftUpdate
from app.auth.deps import (
current_account_in_edition,
ensure_can_write_position,
ensure_can_write_shift,
require_staff,
)
from app.db import get_session
from app.models import Edition, Position, Shift, ShiftRequiredSkill, Skill
from app.models import (
Account,
Edition,
Position,
Shift,
ShiftRequiredSkill,
Skill,
)
router = APIRouter(prefix="/editions/{edition_id}/shifts", tags=["shifts"])
@@ -65,10 +78,12 @@ def _require_position(
def create_shift(
payload: ShiftCreate,
edition: Edition = Depends(get_edition),
account: Account = Depends(current_account_in_edition),
session: Session = Depends(get_session),
) -> ShiftRead:
_check_range(payload.starts_at, payload.ends_at)
_require_position(session, payload.position_id, edition.id)
position = _require_position(session, payload.position_id, edition.id)
ensure_can_write_position(account, position)
shift = Shift(
edition_id=edition.id,
**payload.model_dump(exclude={"required_skill_codes"}),
@@ -85,6 +100,7 @@ def create_shift(
def list_shifts(
position_id: uuid.UUID | None = None,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_staff),
session: Session = Depends(get_session),
) -> list[ShiftRead]:
stmt = (
@@ -101,6 +117,7 @@ def list_shifts(
def get_shift(
shift_id: uuid.UUID,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_staff),
session: Session = Depends(get_session),
) -> ShiftRead:
shift = owned_or_404(session.get(Shift, shift_id), edition.id)
@@ -112,13 +129,16 @@ def update_shift(
shift_id: uuid.UUID,
payload: ShiftUpdate,
edition: Edition = Depends(get_edition),
account: Account = Depends(current_account_in_edition),
session: Session = Depends(get_session),
) -> ShiftRead:
shift = owned_or_404(session.get(Shift, shift_id), edition.id)
ensure_can_write_shift(account, session, shift)
changes = payload.model_dump(exclude_unset=True)
skill_codes = changes.pop("required_skill_codes", None)
if "position_id" in changes:
_require_position(session, changes["position_id"], edition.id)
target = _require_position(session, changes["position_id"], edition.id)
ensure_can_write_position(account, target)
for field, value in changes.items():
setattr(shift, field, value)
_check_range(shift.starts_at, shift.ends_at)
@@ -133,9 +153,11 @@ def update_shift(
def delete_shift(
shift_id: uuid.UUID,
edition: Edition = Depends(get_edition),
account: Account = Depends(current_account_in_edition),
session: Session = Depends(get_session),
) -> Response:
shift = owned_or_404(session.get(Shift, shift_id), edition.id)
ensure_can_write_shift(account, session, shift)
session.delete(shift)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
+20 -1
View File
@@ -9,14 +9,27 @@ from sqlmodel import Session, select
from app.api._common import owned_or_404, replace_links, resolve_skills
from app.api.deps import get_edition
from app.api.schemas import VolunteerCreate, VolunteerRead, VolunteerUpdate
from app.auth.deps import (
current_account_in_edition,
require_coordinator,
require_staff,
)
from app.db import get_session
from app.models import Edition, Skill, Volunteer, VolunteerSkill
from app.models import Account, Edition, Role, Skill, Volunteer, VolunteerSkill
router = APIRouter(
prefix="/editions/{edition_id}/volunteers", tags=["volunteers"]
)
def _ensure_can_read(account: Account, volunteer_id: uuid.UUID) -> None:
if account.role in (Role.COORDINATOR, Role.LEAD):
return
if account.volunteer_id == volunteer_id:
return
raise HTTPException(status_code=403, detail={"code": "error.forbidden"})
def _skill_codes(session: Session, volunteer_id: uuid.UUID) -> list[str]:
return sorted(
session.exec(
@@ -53,6 +66,7 @@ def _set_skills(
def create_volunteer(
payload: VolunteerCreate,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> VolunteerRead:
volunteer = Volunteer(
@@ -76,6 +90,7 @@ def create_volunteer(
@router.get("", response_model=list[VolunteerRead])
def list_volunteers(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_staff),
session: Session = Depends(get_session),
) -> list[VolunteerRead]:
volunteers = session.exec(
@@ -90,8 +105,10 @@ def list_volunteers(
def get_volunteer(
volunteer_id: uuid.UUID,
edition: Edition = Depends(get_edition),
account: Account = Depends(current_account_in_edition),
session: Session = Depends(get_session),
) -> VolunteerRead:
_ensure_can_read(account, volunteer_id)
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
return _read(session, volunteer)
@@ -101,6 +118,7 @@ def update_volunteer(
volunteer_id: uuid.UUID,
payload: VolunteerUpdate,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> VolunteerRead:
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
@@ -126,6 +144,7 @@ def update_volunteer(
def delete_volunteer(
volunteer_id: uuid.UUID,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Response:
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
+4
View File
@@ -0,0 +1,4 @@
"""Authentification : lien magique (email stubé) + session JWT + rôles.
Voir CLAUDE.md § 2 (« Rôles et accès »).
"""
+93
View File
@@ -0,0 +1,93 @@
"""Dépendances d'authentification et d'autorisation."""
import uuid
from collections.abc import Callable
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlmodel import Session
from app.auth.tokens import decode_access_token
from app.db import get_session
from app.models import Account, Position, Role, Shift
_bearer = HTTPBearer(auto_error=False)
def _unauthorized() -> HTTPException:
return HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "error.unauthorized"},
headers={"WWW-Authenticate": "Bearer"},
)
def _forbidden() -> HTTPException:
return HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"code": "error.forbidden"},
)
def get_current_account(
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
session: Session = Depends(get_session),
) -> Account:
if credentials is None or credentials.scheme.lower() != "bearer":
raise _unauthorized()
try:
claims = decode_access_token(credentials.credentials)
account_id = uuid.UUID(claims["sub"])
except (jwt.PyJWTError, KeyError, ValueError) as exc:
raise _unauthorized() from exc
account = session.get(Account, account_id)
if account is None:
raise _unauthorized()
return account
def current_account_in_edition(
edition_id: uuid.UUID,
account: Account = Depends(get_current_account),
) -> Account:
"""Le compte doit appartenir à l'édition de l'URL."""
if account.edition_id != edition_id:
raise _forbidden()
return account
def require_roles(*roles: Role) -> Callable[..., Account]:
def dependency(
account: Account = Depends(current_account_in_edition),
) -> Account:
if account.role not in roles:
raise _forbidden()
return account
return dependency
# Singletons prêts à l'emploi (évite un appel de fonction dans un défaut d'arg).
require_coordinator = require_roles(Role.COORDINATOR)
require_staff = require_roles(Role.COORDINATOR, Role.LEAD)
def ensure_can_write_position(account: Account, position: Position) -> None:
"""COORDINATION partout ; RÉFÉRENT uniquement sur ses propres postes."""
if account.role is Role.COORDINATOR:
return
if account.role is Role.LEAD and position.lead_account_id == account.id:
return
raise _forbidden()
def ensure_can_write_shift(
account: Account, session: Session, shift: Shift
) -> None:
if account.role is Role.COORDINATOR:
return
position = session.get(Position, shift.position_id)
if position is None:
raise _forbidden()
ensure_can_write_position(account, position)
+43
View File
@@ -0,0 +1,43 @@
"""Jetons : lien magique (aléatoire, haché en base) et session JWT."""
import datetime as dt
import hashlib
import secrets
import jwt
from app.config import get_settings
from app.models import Account
_ALGORITHM = "HS256"
def new_raw_token() -> str:
"""Jeton de lien magique en clair (transmis à l'utilisateur une seule fois)."""
return secrets.token_urlsafe(32)
def hash_token(raw: str) -> str:
"""Empreinte stockée en base. Le jeton a 256 bits d'entropie : un SHA-256
simple suffit (pas de sel ni de KDF nécessaires)."""
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def create_access_token(account: Account) -> str:
settings = get_settings()
now = dt.datetime.now(dt.UTC)
payload = {
"sub": str(account.id),
"edi": str(account.edition_id),
"role": account.role.value,
"iat": now,
"exp": now + dt.timedelta(hours=settings.session_ttl_hours),
}
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
def decode_access_token(token: str) -> dict:
"""Lève `jwt.PyJWTError` si le jeton est invalide ou expiré."""
return jwt.decode(
token, get_settings().jwt_secret, algorithms=[_ALGORITHM]
)
+3 -2
View File
@@ -19,9 +19,10 @@ class Settings(BaseSettings):
"postgresql+psycopg://benevoles:benevoles@localhost:5432/benevoles_test"
)
# Utilisés par l'étape authentification (lien magique). Pas encore actifs.
jwt_secret: str = "dev-only-change-me"
# Authentification (lien magique + session JWT). Secret >= 32 octets.
jwt_secret: str = "dev-insecure-secret-change-me-please-32b"
magic_link_ttl_minutes: int = 30
session_ttl_hours: int = 12
log_level: str = "INFO"
cors_origins: list[str] = ["http://localhost:5173"]
+3
View File
@@ -10,6 +10,9 @@
"volunteer.duplicate_email": "Un bénévole avec cet email existe déjà dans l'édition.",
"assignment.wrong_edition": "Le bénévole ou le créneau n'appartient pas à cette édition.",
"assignment.duplicate": "Ce bénévole est déjà affecté à ce créneau.",
"auth.invalid_token": "Lien de connexion invalide ou expiré.",
"account.duplicate": "Un compte existe déjà pour cet email ou ce bénévole.",
"account.volunteer_wrong_edition": "Le bénévole n'appartient pas à cette édition.",
"import.error.empty_file": "Le fichier est vide.",
"import.error.encoding": "L'encodage du fichier n'est pas reconnu.",
+4
View File
@@ -1,7 +1,9 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.accounts import router as account_router
from app.api.assignments import router as assignment_router
from app.api.auth import router as auth_router
from app.api.health import router as health_router
from app.api.imports import router as import_router
from app.api.matrix import router as matrix_router
@@ -26,6 +28,8 @@ app.add_middleware(
)
app.include_router(health_router)
app.include_router(auth_router)
app.include_router(account_router)
app.include_router(import_router)
app.include_router(volunteer_router)
app.include_router(position_router)
+19
View File
@@ -207,6 +207,7 @@ _STAFF: dict[PositionCategory, tuple[int, int]] = {
@dataclass
class Summary:
volunteers: int = 0
accounts: int = 0
positions: int = 0
shifts: int = 0
understaffed_shifts: int = 0
@@ -218,6 +219,7 @@ class Summary:
lines = [
"Jeu de démonstration « Festival des Lucioles » 2026",
f" Bénévoles : {self.volunteers}",
f" Comptes (connexion) : {self.accounts}",
f" Postes : {self.positions}",
f" Créneaux : {self.shifts}",
f" Créneaux sous l'effectif minimal (trous) : {self.understaffed_shifts}",
@@ -351,6 +353,15 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
session.add(volunteer)
session.flush()
volunteers.append(volunteer)
if email is not None:
session.add(
Account(
edition_id=edition.id,
email=email,
role=Role.VOLUNTEER,
volunteer_id=volunteer.id,
)
)
count = rng.choices([0, 1, 2, 3], weights=[0.3, 0.34, 0.24, 0.12])[0]
codes = set(rng.sample(list(skills), k=count))
# Le permis B est courant : on en garantit chez ~40 % des bénévoles
@@ -497,8 +508,16 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
for shift in volunteer_shifts:
fill[shift.id] += 1
session.flush()
account_count = len(
session.exec(
select(Account).where(Account.edition_id == edition.id)
).all()
)
summary = Summary(
volunteers=len(volunteers),
accounts=account_count,
positions=len(positions),
shifts=len(shifts),
assignments=n_assignments,
+1
View File
@@ -12,6 +12,7 @@ dependencies = [
"pydantic-settings>=2.6",
"python-multipart>=0.0.12",
"openpyxl>=3.1",
"pyjwt>=2.9",
]
[dependency-groups]
+60 -9
View File
@@ -1,14 +1,16 @@
import datetime as dt
import itertools
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, SQLModel, create_engine, select
from app import models # noqa: F401 (enregistre les tables sur SQLModel.metadata)
from app.auth.deps import get_current_account
from app.config import get_settings
from app.db import get_session
from app.main import app
from app.models import DayPart, Edition
from app.models import Account, DayPart, Edition, Role
@pytest.fixture(scope="session")
@@ -36,14 +38,6 @@ def session(engine):
connection.close()
@pytest.fixture
def client(session):
app.dependency_overrides[get_session] = lambda: session
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
@pytest.fixture
def edition(session) -> Edition:
"""Édition de 4 jours avec 3 demi-journées paramétrées."""
@@ -83,3 +77,60 @@ def day_parts(session, edition) -> dict[str, DayPart]:
select(DayPart).where(DayPart.edition_id == edition.id)
)
return {p.code: p for p in parts}
@pytest.fixture
def make_account(session, edition):
"""Fabrique de comptes rattachés à l'édition de test."""
counter = itertools.count()
def _make(
role: Role, *, volunteer_id=None, email: str | None = None
) -> Account:
account = Account(
edition_id=edition.id,
role=role,
email=email or f"{role.value.lower()}{next(counter)}@test",
volunteer_id=volunteer_id,
)
session.add(account)
session.commit()
session.refresh(account)
return account
return _make
@pytest.fixture
def coordinator(make_account) -> Account:
return make_account(Role.COORDINATOR)
@pytest.fixture
def as_account(session):
"""Fabrique de clients HTTP authentifiés. Fixe l'identité **active** de
l'app : le dernier appel l'emporte. Pour rejouer un autre rôle après en
avoir simulé un, rappeler `as_account(...)`."""
def _make(account: Account) -> TestClient:
app.dependency_overrides[get_session] = lambda: session
app.dependency_overrides[get_current_account] = lambda: account
return TestClient(app)
yield _make
app.dependency_overrides.clear()
@pytest.fixture
def client(coordinator, as_account) -> TestClient:
"""Client authentifié en COORDINATION (rôle par défaut des tests)."""
return as_account(coordinator)
@pytest.fixture
def raw_client(session):
"""Client sans authentification simulée (pour tester le lien magique)."""
app.dependency_overrides[get_session] = lambda: session
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
+99
View File
@@ -0,0 +1,99 @@
import datetime as dt
import logging
from sqlmodel import select
from app.auth.tokens import hash_token
from app.models import MagicLinkToken, Role, local_now
def _url(edition, suffix):
return f"/editions/{edition.id}/auth/{suffix}"
def test_magic_link_unknown_email_is_accepted_without_token(
raw_client, session, edition
):
resp = raw_client.post(
_url(edition, "magic-link"), json={"email": "personne@example.fr"}
)
assert resp.status_code == 202
assert session.exec(select(MagicLinkToken)).all() == []
def test_full_flow_magic_link_then_jwt_then_me(
raw_client, session, edition, make_account, caplog
):
make_account(Role.COORDINATOR, email="coord@example.fr")
with caplog.at_level(logging.INFO, logger="app.auth"):
resp = raw_client.post(
_url(edition, "magic-link"), json={"email": "COORD@example.fr"}
)
assert resp.status_code == 202
log_line = next(m for m in caplog.messages if "STUB lien magique" in m)
raw_token = log_line.split("jeton=")[-1].strip()
verified = raw_client.post(
_url(edition, "verify"), json={"token": raw_token}
)
assert verified.status_code == 200
body = verified.json()
assert body["token_type"] == "bearer"
assert body["account"]["role"] == "COORDINATOR"
me = raw_client.get(
"/auth/me",
headers={"Authorization": f"Bearer {body['access_token']}"},
)
assert me.status_code == 200
assert me.json()["email"] == "coord@example.fr"
def test_verify_is_single_use(raw_client, session, edition, make_account):
account = make_account(Role.VOLUNTEER, email="v@example.fr")
session.add(
MagicLinkToken(
account_id=account.id,
token_hash=hash_token("jeton-connu"),
expires_at=local_now() + dt.timedelta(minutes=10),
)
)
session.commit()
first = raw_client.post(_url(edition, "verify"), json={"token": "jeton-connu"})
assert first.status_code == 200
second = raw_client.post(
_url(edition, "verify"), json={"token": "jeton-connu"}
)
assert second.status_code == 401
assert second.json()["detail"]["code"] == "auth.invalid_token"
def test_verify_rejects_expired_token(raw_client, session, edition, make_account):
account = make_account(Role.VOLUNTEER, email="v@example.fr")
session.add(
MagicLinkToken(
account_id=account.id,
token_hash=hash_token("perime"),
expires_at=local_now() - dt.timedelta(minutes=1),
)
)
session.commit()
resp = raw_client.post(_url(edition, "verify"), json={"token": "perime"})
assert resp.status_code == 401
def test_verify_rejects_unknown_token(raw_client, edition):
resp = raw_client.post(_url(edition, "verify"), json={"token": "n'importe"})
assert resp.status_code == 401
def test_me_requires_a_valid_bearer(raw_client):
assert raw_client.get("/auth/me").status_code == 401
assert (
raw_client.get(
"/auth/me", headers={"Authorization": "Bearer pas-un-jwt"}
).status_code
== 401
)
+253
View File
@@ -0,0 +1,253 @@
"""Application des rôles : COORDINATION / RÉFÉRENT (LEAD) / BÉNÉVOLE."""
from app.models import Assignment, Role
from tests.factories import (
dt_at,
make_position,
make_shift,
make_volunteer,
)
def _e(edition, suffix=""):
return f"/editions/{edition.id}{suffix}"
def test_all_endpoints_require_authentication(raw_client, edition):
assert raw_client.get(_e(edition, "/volunteers")).status_code == 401
assert raw_client.get(_e(edition, "/matrix")).status_code == 401
assert raw_client.post(_e(edition, "/positions"), json={}).status_code == 401
def test_account_from_another_edition_is_forbidden(
session, as_account, edition, make_account
):
import datetime as dt
from app.models import Edition
other = Edition(
year=2027, start_date=dt.date(2027, 7, 1), end_date=dt.date(2027, 7, 4)
)
session.add(other)
session.commit()
session.refresh(other)
from app.models import Account
stranger = Account(
edition_id=other.id, email="x@e.fr", role=Role.COORDINATOR
)
session.add(stranger)
session.commit()
session.refresh(stranger)
client = as_account(stranger)
assert client.get(_e(edition, "/volunteers")).status_code == 403
# --------------------------------------------------------------------------- #
# BÉNÉVOLE #
# --------------------------------------------------------------------------- #
def test_volunteer_scope(session, edition, make_account, as_account):
me = make_volunteer(session, edition, email="me@e.fr")
other = make_volunteer(session, edition, email="other@e.fr")
account = make_account(Role.VOLUNTEER, volunteer_id=me.id, email="me@e.fr")
client = as_account(account)
assert client.get(_e(edition, "/volunteers")).status_code == 403
assert client.get(_e(edition, f"/volunteers/{me.id}")).status_code == 200
assert client.get(_e(edition, f"/volunteers/{other.id}")).status_code == 403
assert client.get(_e(edition, "/matrix")).status_code == 403
assert (
client.post(
_e(edition, "/volunteers"),
json={"first_name": "A", "last_name": "B"},
).status_code
== 403
)
def test_volunteer_sees_only_own_assignments(
session, edition, make_account, as_account
):
me = make_volunteer(session, edition, email="me@e.fr")
other = make_volunteer(session, edition, email="other@e.fr")
position = make_position(session, edition)
shift = make_shift(
session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12)
)
session.add_all(
[
Assignment(
edition_id=edition.id, volunteer_id=me.id, shift_id=shift.id
),
]
)
session.commit()
account = make_account(Role.VOLUNTEER, volunteer_id=me.id, email="me@e.fr")
client = as_account(account)
mine = client.get(_e(edition, "/assignments"))
assert mine.status_code == 200
assert all(a["volunteer_id"] == str(me.id) for a in mine.json())
assert (
client.get(
_e(edition, "/assignments"),
params={"volunteer_id": str(other.id)},
).status_code
== 403
)
# --------------------------------------------------------------------------- #
# RÉFÉRENT (LEAD) #
# --------------------------------------------------------------------------- #
def test_lead_write_is_limited_to_own_positions(
session, edition, make_account, as_account
):
lead = make_account(Role.LEAD, email="lead@e.fr")
mine = make_position(session, edition, name="Bar")
mine.lead_account_id = lead.id
session.add(mine)
session.commit()
theirs = make_position(session, edition, name="Régie")
client = as_account(lead)
# Lecture sur toute l'édition.
assert client.get(_e(edition, "/matrix")).status_code == 200
assert client.get(_e(edition, "/positions")).status_code == 200
# Création de poste réservée à la coordination.
assert (
client.post(
_e(edition, "/positions"),
json={"name": "N", "category": "BAR"},
).status_code
== 403
)
# PATCH : son poste oui, celui d'un autre non.
assert (
client.patch(
_e(edition, f"/positions/{mine.id}"), json={"location": "Chapiteau"}
).status_code
== 200
)
assert (
client.patch(
_e(edition, f"/positions/{theirs.id}"), json={"location": "X"}
).status_code
== 403
)
# Créneau : sur son poste oui, sur celui d'un autre non.
ok = client.post(
_e(edition, "/shifts"),
json={
"position_id": str(mine.id),
"starts_at": "2026-07-10T09:00:00",
"ends_at": "2026-07-10T12:00:00",
},
)
assert ok.status_code == 201
ko = client.post(
_e(edition, "/shifts"),
json={
"position_id": str(theirs.id),
"starts_at": "2026-07-10T09:00:00",
"ends_at": "2026-07-10T12:00:00",
},
)
assert ko.status_code == 403
# Ni bénévoles ni import.
assert (
client.post(
_e(edition, "/volunteers"),
json={"first_name": "A", "last_name": "B"},
).status_code
== 403
)
assert (
client.post(
_e(edition, "/import/analyze"),
files={"file": ("x.csv", b"a;b\n1;2\n", "text/csv")},
).status_code
== 403
)
def test_lead_assignment_scope(session, edition, make_account, as_account):
lead = make_account(Role.LEAD, email="lead@e.fr")
mine = make_position(session, edition, name="Bar")
mine.lead_account_id = lead.id
session.add(mine)
session.commit()
theirs = make_position(session, edition, name="Accueil")
my_shift = make_shift(
session, edition, mine, start=dt_at(10, 9), end=dt_at(10, 12)
)
their_shift = make_shift(
session, edition, theirs, start=dt_at(11, 9), end=dt_at(11, 12)
)
volunteer = make_volunteer(session, edition, email="v@e.fr")
client = as_account(lead)
ok = client.post(
_e(edition, "/assignments"),
json={
"volunteer_id": str(volunteer.id),
"shift_id": str(my_shift.id),
},
)
assert ok.status_code == 201
ko = client.post(
_e(edition, "/assignments"),
json={
"volunteer_id": str(volunteer.id),
"shift_id": str(their_shift.id),
},
)
assert ko.status_code == 403
# --------------------------------------------------------------------------- #
# Comptes (coordination uniquement) #
# --------------------------------------------------------------------------- #
def test_account_management_is_coordinator_only(
session, edition, coordinator, make_account, as_account
):
coord_client = as_account(coordinator)
created = coord_client.post(
_e(edition, "/accounts"),
json={
"email": "nouveau.referent@e.fr",
"role": "LEAD",
"volunteer_id": None,
},
)
assert created.status_code == 201
account_id = created.json()["id"]
assert coord_client.get(_e(edition, "/accounts")).status_code == 200
lead_client = as_account(make_account(Role.LEAD, email="lead@e.fr"))
assert lead_client.get(_e(edition, "/accounts")).status_code == 403
assert (
lead_client.delete(
_e(edition, f"/accounts/{account_id}")
).status_code
== 403
)
# `as_account` fixe l'identité active : on repasse en COORDINATION.
coord_client = as_account(coordinator)
assert (
coord_client.delete(
_e(edition, f"/accounts/{account_id}")
).status_code
== 204
)
+11
View File
@@ -56,6 +56,7 @@ dependencies = [
{ name = "openpyxl" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pydantic-settings" },
{ name = "pyjwt" },
{ name = "python-multipart" },
{ name = "sqlmodel" },
{ name = "uvicorn", extra = ["standard"] },
@@ -76,6 +77,7 @@ requires-dist = [
{ name = "openpyxl", specifier = ">=3.1" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" },
{ name = "pydantic-settings", specifier = ">=2.6" },
{ name = "pyjwt", specifier = ">=2.9" },
{ name = "python-multipart", specifier = ">=0.0.12" },
{ name = "sqlmodel", specifier = ">=0.0.22" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.32" },
@@ -422,6 +424,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147 },
]
[[package]]
name = "pyjwt"
version = "2.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274 },
]
[[package]]
name = "pytest"
version = "9.1.1"