Retour au cours

backend / fastapi

Permissions et rôles

Leçon 121 exercice

Explication

Ce que vous allez apprendre

  • Distinguer clairement authentification et autorisation
  • Implémenter une vérification de rôle hiérarchique avec une dépendance paramétrable
  • Construire un modèle RBAC par permissions granulaires plus flexible qu'une hiérarchie simple
  • Vérifier l'ownership d'une ressource avant d'autoriser sa modification
  • Filtrer directement une requête base de données selon les droits de l'utilisateur

Dans quel contexte ?

Sur DELETE /products/{id} de app/routers/products.py, un utilisateur authentifié avec un rôle "membre" parvient à supprimer un produit qui ne lui appartient même pas, simplement parce que l'endpoint vérifie que le token est valide (authentification) sans jamais vérifier que l'utilisateur a le droit de supprimer CE produit précis (autorisation). Ajouter une dépendance get_owned_product qui compare product.owner_id à l'utilisateur courant, avec une exception pour les administrateurs, corrige cette faille de sécurité.

Une distinction essentielle, d'abord

La leçon précédente répondait à la question "qui es-tu ?", c'est l'authentification. Celle-ci répond à une question différente : "as-tu le droit de faire CETTE action précise ?", c'est l'autorisation.

Un utilisateur peut être parfaitement authentifié, son token est valide, tout en n'ayant pas la permission de supprimer un produit. Ces deux notions sont donc bien distinctes et se vérifient séparément.

Voyons d'abord une première approche simple : la hiérarchie de rôles. Un rôle comme membre, éditeur ou admin résume un ensemble de droits sous une seule étiquette.

Une hiérarchie ordonnée permet de vérifier facilement "ce rôle a-t-il AU MOINS ce niveau requis ?". Une simple comparaison numérique suffit, simple à comprendre.

Mais cette approche devient rigide dès que les besoins ne suivent plus une ligne droite. Un rôle "support" qui peut lire les commandes mais pas les produits, par exemple, ne rentre pas dans une hiérarchie simple.

Une approche plus flexible existe : le modèle par permissions. Il associe à chaque rôle un ENSEMBLE de permissions granulaires, comme "products:write", plutôt qu'un simple niveau.

Ça permet de composer des droits précis sans créer un nouveau rôle intermédiaire pour chaque combinaison possible. Une flexibilité bien plus grande qu'une simple hiérarchie.

Une fois les rôles et permissions vérifiés, une vérification différente reste à ne jamais oublier : l'ownership. Un utilisateur ne devrait généralement modifier QUE ses propres ressources, sauf exception explicite pour un administrateur.

Cette vérification compare l'identité de l'utilisateur au propriétaire réel de la ressource ciblée. Elle doit être appliquée explicitement, jamais supposée acquise juste parce que le rôle est bon.

VérificationQuestion poséeExemple
AuthentificationQui es-tu ?Le token JWT est-il valide ?
Autorisation (rôle/permission)As-tu le droit de faire ce TYPE d'action ?As-tu la permission products:delete ?
OwnershipAs-tu le droit sur CETTE ressource précise ?Es-tu le propriétaire de ce produit ?

Piège fréquent

Vérifier qu'un utilisateur a le rôle "editor" ne garantit pas qu'il a le droit de modifier UNE ressource précise : sans vérification d'ownership explicite, n'importe quel editor pourrait modifier les ressources de n'importe quel autre utilisateur.

Le principe qui guide toute cette leçon pour conclure : par défaut, un utilisateur ne devrait rien pouvoir faire, et chaque permission doit être accordée explicitement — filtrer directement dans la requête base de données applique ce principe jusqu'au bout, plutôt que de filtrer après coup côté client.

Commandes & code

Permissions et rôles

python
# app/models/user.py — rôle stocké comme énumération
import enum
from sqlalchemy import Enum
from sqlalchemy.orm import Mapped, mapped_column


class Role(str, enum.Enum):
    MEMBER = "member"
    EDITOR = "editor"
    ADMIN = "admin"


class User(Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str]
    role: Mapped[Role] = mapped_column(Enum(Role), default=Role.MEMBER)
python
# Dépendance paramétrable pour exiger un rôle minimum
from fastapi import Depends, HTTPException, status
from typing import Annotated


def require_role(minimum_role: Role):
    role_hierarchy = {Role.MEMBER: 0, Role.EDITOR: 1, Role.ADMIN: 2}

    def dependency(current_user: Annotated[User, Depends(get_current_user)]) -> User:
        if role_hierarchy[current_user.role] < role_hierarchy[minimum_role]:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail=f"Rôle '{minimum_role.value}' requis",
            )
        return current_user

    return dependency


@router.delete("/products/{id}")
def delete_product(
    id: int,
    current_user: Annotated[User, Depends(require_role(Role.ADMIN))],
):
    return {"deleted": id}
python
# Permissions granulaires (RBAC fin) — au-delà d'une simple hiérarchie de rôles
PERMISSIONS = {
    Role.MEMBER: {"products:read"},
    Role.EDITOR: {"products:read", "products:write"},
    Role.ADMIN: {"products:read", "products:write", "products:delete", "users:manage"},
}


def require_permission(permission: str):
    def dependency(current_user: Annotated[User, Depends(get_current_user)]) -> User:
        if permission not in PERMISSIONS.get(current_user.role, set()):
            raise HTTPException(status_code=403, detail=f"Permission '{permission}' requise")
        return current_user

    return dependency


@router.post("/products")
def create_product(
    data: ProductCreate,
    current_user: Annotated[User, Depends(require_permission("products:write"))],
):
    return {"created": data}
python
# Ownership check — l'utilisateur ne peut modifier QUE ses propres ressources (sauf admin)
def get_owned_product(
    product_id: int,
    db: Annotated[Session, Depends(get_db)],
    current_user: Annotated[User, Depends(get_current_user)],
) -> Product:
    product = db.get(Product, product_id)
    if not product:
        raise HTTPException(status_code=404, detail="Produit introuvable")

    if product.owner_id != current_user.id and current_user.role != Role.ADMIN:
        raise HTTPException(status_code=403, detail="Vous n'êtes pas propriétaire de cette ressource")

    return product


@router.put("/products/{product_id}")
def update_product(
    product: Annotated[Product, Depends(get_owned_product)],
    data: ProductUpdate,
    db: Annotated[Session, Depends(get_db)],
):
    for key, value in data.model_dump(exclude_unset=True).items():
        setattr(product, key, value)
    db.commit()
    return product
python
# Row-level security au niveau de la requête — filtrer directement dans la query
def list_my_products(
    db: Annotated[Session, Depends(get_db)],
    current_user: Annotated[User, Depends(get_current_user)],
):
    if current_user.role == Role.ADMIN:
        return db.query(Product).all()  # l'admin voit tout
    return db.query(Product).filter(Product.owner_id == current_user.id).all()

Résumé

  • Une dépendance factory (require_role(role)) permet de paramétrer la vérification par endpoint.
  • Un modèle RBAC par permissions ("products:write") est plus flexible qu'une simple hiérarchie de rôles.
  • La vérification d'ownership (propriétaire de la ressource) doit être une dépendance explicite, jamais implicite.
  • Filtrer directement dans la requête DB (row-level security applicative) évite d'exposer des données non autorisées.

Exercices pratiques

1 disponible
1

Mission : le membre qui supprime les produits des autres

Objectif : Corriger une faille d'autorisation où l'authentification est vérifiée mais pas l'ownership de la ressource ciblée.

Contexte

Sur DELETE /products/{id} de app/routers/products.py, un utilisateur avec le rôle "member" a réussi à supprimer un produit appartenant à un autre utilisateur. L'endpoint vérifie actuellement uniquement Depends(get_current_user), sans jamais comparer le propriétaire du produit à l'utilisateur courant.

Résoudre l’exercice →