diff --git a/.env.example b/.env.example index bbfd60f..0233935 100644 --- a/.env.example +++ b/.env.example @@ -5,5 +5,6 @@ POSTGRES_USER=benevoles POSTGRES_PASSWORD=benevoles POSTGRES_DB=benevoles -# Secret de signature des jetons (lien magique / session). À régénérer en prod. -JWT_SECRET=dev-only-change-me +# Secret de signature des jetons (lien magique / session), >= 32 octets. +# À régénérer en production : openssl rand -hex 32 +JWT_SECRET=dev-insecure-secret-change-me-please-32b diff --git a/CLAUDE.md b/CLAUDE.md index 7e9aa84..538cdc3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,17 +95,40 @@ Conventions techniques : - Dates/heures **naïves**, heure locale Europe/Paris (`TZ` du conteneur + `tzdata`). `app.models.local_now()` pour les horodatages. -### Rôles et accès +### Rôles et accès (`app/auth/`) - **`COORDINATOR`** : accès complet, construit et publie les plannings. -- **`LEAD`** (référent) : lecture sur toute l'édition, écriture limitée à ses - propres postes. -- **`VOLUNTEER`** : accès à ses seules données. +- **`LEAD`** (référent) : lecture sur toute l'édition, écriture limitée aux + postes dont il est `lead_account_id` (et à leurs créneaux et affectations). +- **`VOLUNTEER`** : lecture de sa seule fiche et de ses seules affectations ; + aucune écriture. -Authentification par lien magique envoyé par email, sans mot de passe. L'envoi -d'email est **stubé** pour l'instant (le lien est écrit dans les logs). Un -bénévole sans email n'a pas de `Account` et ne se connecte pas ; il est géré -entièrement par la coordination. +Matrice appliquée par les endpoints : + +| Ressource | COORDINATOR | LEAD | VOLUNTEER | +|-----------|-------------|------|-----------| +| `volunteers` liste/écriture | ✔ | liste seule | — | +| `volunteers/{id}` lecture | ✔ | ✔ | soi-même | +| `positions`/`shifts` lecture | ✔ | ✔ | — | +| `positions`/`shifts` écriture | ✔ | ses postes | — | +| `assignments` écriture | ✔ | ses postes | — | +| `assignments` lecture | ✔ | ✔ | les siennes | +| `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — | + +**Authentification** : lien magique + session JWT (`Bearer`), sans mot de passe. + +1. `POST /editions/{id}/auth/magic-link` `{email}` → réponse `202` identique + qu'un compte existe ou non. Si oui, un `MagicLinkToken` est créé (jeton + aléatoire, **haché** en base, TTL `magic_link_ttl_minutes`). +2. **Envoi d'email stubé** : le lien est écrit dans les logs (`app.auth`, + niveau INFO, `jeton=…` — identifiants seulement, aucune donnée nominative). +3. `POST /editions/{id}/auth/verify` `{token}` → JWT HS256 + (`session_ttl_hours`) + `AccountRead`. Jeton à usage unique. +4. `GET /auth/me` → compte courant. En-tête `Authorization: Bearer `. + +Un bénévole sans email n'a pas de `Account` et ne se connecte pas ; il est géré +entièrement par la coordination. La coordination crée les comptes via +`POST /editions/{id}/accounts`. --- @@ -188,8 +211,10 @@ pointage sur site · fonctions d'IA. │ │ ├── db.py # engine + session │ │ ├── logging_config.py # RGPD : pas de PII dans les logs │ │ ├── api/ # routers + schemas Pydantic + deps -│ │ │ # health, imports, volunteers, positions, -│ │ │ # shifts, assignments, matrix +│ │ │ # health, auth, accounts, imports, +│ │ │ # volunteers, positions, shifts, +│ │ │ # assignments, matrix +│ │ ├── auth/ # lien magique + JWT + dépendances de rôle │ │ ├── importing/ # import CSV/Excel (parser, values, service) │ │ ├── models/ # tables SQLModel │ │ ├── validation/ # moteur pur (models, engine) + loader (base) @@ -268,10 +293,11 @@ Aucune dépendance locale requise hors Docker. | 3 | Import CSV/Excel (analyse + exécution, rapport, doublons, simulation) | validée | | 4 | Moteur de validation des affectations (pur, TDD, 5 règles) | validée | | 5 | API CRUD (Position, Shift, Volunteer, Assignment) + matrice + validation branchée | validée | -| 6 | Jeu de données de démonstration (`app/seed.py`) | à valider | +| 6 | Jeu de données de démonstration (`app/seed.py`) | validée | +| 7 | Authentification : lien magique + JWT + application des rôles | à valider | -Premier incrément backend terminé. **Reste hors périmètre** (modèle prêt à les -accueillir) : authentification par lien magique (`Account` + `MagicLinkToken` -existent, endpoints non encore protégés), solveur OR-Tools, envoi -d'emails/SMS, constructeur de formulaire, pointage, IA. Le frontend -(React/Vite/TS/Tailwind) reste à démarrer. +Premier incrément backend + authentification terminés. **Reste hors +périmètre** (modèle prêt à les accueillir) : envoi réel d'emails/SMS (le lien +magique est journalisé, pas envoyé), solveur OR-Tools, constructeur de +formulaire, pointage sur site, IA. Le frontend (React/Vite/TS/Tailwind) reste +à démarrer. diff --git a/backend/app/api/accounts.py b/backend/app/api/accounts.py new file mode 100644 index 0000000..3f26971 --- /dev/null +++ b/backend/app/api/accounts.py @@ -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) diff --git a/backend/app/api/assignments.py b/backend/app/api/assignments.py index 5788e3a..d938e96 100644 --- a/backend/app/api/assignments.py +++ b/backend/app/api/assignments.py @@ -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) diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..59e508b --- /dev/null +++ b/backend/app/api/auth.py @@ -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 diff --git a/backend/app/api/imports.py b/backend/app/api/imports.py index 9c83acf..b3a4352 100644 --- a/backend/app/api/imports.py +++ b/backend/app/api/imports.py @@ -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: diff --git a/backend/app/api/matrix.py b/backend/app/api/matrix.py index 5788a3a..c5ed85b 100644 --- a/backend/app/api/matrix.py +++ b/backend/app/api/matrix.py @@ -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( diff --git a/backend/app/api/positions.py b/backend/app/api/positions.py index 31d399e..6d02cf3 100644 --- a/backend/app/api/positions.py +++ b/backend/app/api/positions.py @@ -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) diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index b4fe5be..8bf92b5 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -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 diff --git a/backend/app/api/shifts.py b/backend/app/api/shifts.py index 26f0ca1..64aea06 100644 --- a/backend/app/api/shifts.py +++ b/backend/app/api/shifts.py @@ -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) diff --git a/backend/app/api/volunteers.py b/backend/app/api/volunteers.py index 008939e..48c8fb3 100644 --- a/backend/app/api/volunteers.py +++ b/backend/app/api/volunteers.py @@ -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) diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py new file mode 100644 index 0000000..d2a20f5 --- /dev/null +++ b/backend/app/auth/__init__.py @@ -0,0 +1,4 @@ +"""Authentification : lien magique (email stubé) + session JWT + rôles. + +Voir CLAUDE.md § 2 (« Rôles et accès »). +""" diff --git a/backend/app/auth/deps.py b/backend/app/auth/deps.py new file mode 100644 index 0000000..5c72749 --- /dev/null +++ b/backend/app/auth/deps.py @@ -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) diff --git a/backend/app/auth/tokens.py b/backend/app/auth/tokens.py new file mode 100644 index 0000000..230cef8 --- /dev/null +++ b/backend/app/auth/tokens.py @@ -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] + ) diff --git a/backend/app/config.py b/backend/app/config.py index 47b0597..c6e0015 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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"] diff --git a/backend/app/i18n/locales/fr.json b/backend/app/i18n/locales/fr.json index 2ad0f18..21c3754 100644 --- a/backend/app/i18n/locales/fr.json +++ b/backend/app/i18n/locales/fr.json @@ -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.", diff --git a/backend/app/main.py b/backend/app/main.py index 109ed9a..8b921b2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/seed.py b/backend/app/seed.py index a68769d..d329384 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -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, diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 2a32da9..5357e00 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "pydantic-settings>=2.6", "python-multipart>=0.0.12", "openpyxl>=3.1", + "pyjwt>=2.9", ] [dependency-groups] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index a0e0d8f..f9b0d28 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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() diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..6b35795 --- /dev/null +++ b/backend/tests/test_auth.py @@ -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 + ) diff --git a/backend/tests/test_authz.py b/backend/tests/test_authz.py new file mode 100644 index 0000000..3120805 --- /dev/null +++ b/backend/tests/test_authz.py @@ -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 + ) diff --git a/backend/uv.lock b/backend/uv.lock index 693b39b..087747c 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml index 3d0b7f6..a8f1622 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,7 +25,7 @@ services: DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-benevoles}:${POSTGRES_PASSWORD:-benevoles}@db:5432/${POSTGRES_DB:-benevoles} TEST_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-benevoles}:${POSTGRES_PASSWORD:-benevoles}@db:5432/benevoles_test ENVIRONMENT: development - JWT_SECRET: ${JWT_SECRET:-dev-only-change-me} + JWT_SECRET: ${JWT_SECRET:-dev-insecure-secret-change-me-please-32b} TZ: Europe/Paris ports: - "8000:8000"