Permissions DRF : contrôle d'accès granulaire

L'authentification répond à "qui êtes-vous ?", les permissions à "avez-vous le droit ?". Django REST Framework fournit un système de permissions flexible qui va bien au-delà du simple isAuthenticated. Voici comment l'exploiter pleinement.

Le système de permissions DRF

DRF distingue deux niveaux de permissions :

View-level vs Object-level

View-level (has_permission) : vérifie l'accès à l'action en général (peut-on lister les articles ?).
Object-level (has_object_permission) : vérifie l'accès à un objet spécifique (peut-on modifier cet article ?).

Permissions built-in

                
Permissions DRF de base
from rest_framework.permissions import ( AllowAny, # Accès libre IsAuthenticated, # Utilisateur connecté IsAdminUser, # user.is_staff == True IsAuthenticatedOrReadOnly, # Connecté pour écrire, libre pour lire DjangoModelPermissions, # Basé sur les permissions Django DjangoObjectPermissions, # Object-level Django )

Configuration globale

                
settings.py
REST_FRAMEWORK = { 'DEFAULT_PERMISSION_CLASSES': [ 'rest_framework.permissions.IsAuthenticated', ], }

Configuration par vue

                
api/views.py
from rest_framework import viewsets from rest_framework.permissions import IsAuthenticated, AllowAny class ArticleViewSet(viewsets.ModelViewSet): # S'applique à toutes les actions permission_classes = [IsAuthenticated] # Ou par action avec get_permissions() def get_permissions(self): if self.action in ['list', 'retrieve']: return [AllowAny()] return [IsAuthenticated()]

Permissions custom : les bases

Créer des permissions personnalisées est simple : héritez de BasePermission et implémentez les méthodes nécessaires.

Permission view-level

                
api/permissions.py
from rest_framework import permissions class IsVerifiedEmail(permissions.BasePermission): """ Autorise uniquement les utilisateurs avec email vérifié. """ message = "Veuillez vérifier votre email pour accéder à cette ressource." def has_permission(self, request, view): return ( request.user.is_authenticated and request.user.email_verified ) class IsPremiumUser(permissions.BasePermission): """ Autorise uniquement les abonnés premium. """ message = "Cette fonctionnalité nécessite un abonnement premium." def has_permission(self, request, view): if not request.user.is_authenticated: return False # Vérifier l'abonnement actif return request.user.subscription and \ request.user.subscription.is_active and \ request.user.subscription.plan == 'premium'

Permission object-level

                
api/permissions.py
class IsOwnerOrReadOnly(permissions.BasePermission): """ Lecture pour tous, écriture uniquement pour le propriétaire. """ def has_object_permission(self, request, view, obj): # Les méthodes safe (GET, HEAD, OPTIONS) sont toujours autorisées if request.method in permissions.SAFE_METHODS: return True # L'écriture nécessite d'être propriétaire return obj.owner == request.user class IsOwner(permissions.BasePermission): """ Accès total uniquement pour le propriétaire. """ def has_object_permission(self, request, view, obj): return obj.owner == request.user

Important : has_object_permission n'est appelé que si has_permission retourne True. Et il n'est pas appelé automatiquement pour les listes (list action) pour des raisons de performance.

Permissions flexibles avec attribut configurable

Rendez vos permissions réutilisables en les rendant configurables :

                
api/permissions.py
class IsOwnerOrReadOnly(permissions.BasePermission): """ Permission configurable avec owner_field sur la vue. """ def has_object_permission(self, request, view, obj): if request.method in permissions.SAFE_METHODS: return True # Récupérer le nom du champ owner depuis la vue owner_field = getattr(view, 'owner_field', 'owner') # Supporter les relations imbriquées (ex: 'project.owner') owner = obj for attr in owner_field.split('.'): owner = getattr(owner, attr, None) return owner == request.user
                
api/views.py
class ArticleViewSet(viewsets.ModelViewSet): permission_classes = [IsOwnerOrReadOnly] owner_field = 'author' # Le modèle utilise 'author' pas 'owner' class CommentViewSet(viewsets.ModelViewSet): permission_classes = [IsOwnerOrReadOnly] owner_field = 'article.author' # L'auteur de l'article parent

Permissions par action

Différentes actions nécessitent souvent différentes permissions. Voici un pattern propre :

                
api/mixins.py
class ActionBasedPermissionMixin: """ Mixin permettant de définir des permissions par action. Usage: permission_classes_by_action = { 'list': [AllowAny], 'retrieve': [AllowAny], 'create': [IsAuthenticated], 'update': [IsOwner], 'destroy': [IsAdminUser], } """ permission_classes_by_action = {} def get_permissions(self): try: return [ permission() for permission in self.permission_classes_by_action[self.action] ] except KeyError: # Fallback sur permission_classes par défaut return super().get_permissions()
                
api/views.py
class ProjectViewSet(ActionBasedPermissionMixin, viewsets.ModelViewSet): queryset = Project.objects.all() serializer_class = ProjectSerializer # Permissions par défaut permission_classes = [IsAuthenticated] # Permissions spécifiques par action permission_classes_by_action = { 'list': [AllowAny], 'retrieve': [AllowAny], 'create': [IsAuthenticated, IsVerifiedEmail], 'update': [IsAuthenticated, IsProjectMember], 'partial_update': [IsAuthenticated, IsProjectMember], 'destroy': [IsAuthenticated, IsProjectOwner], }

Composition de permissions

DRF supporte la composition de permissions avec les opérateurs logiques Python :

                
api/views.py
from rest_framework.permissions import IsAuthenticated, IsAdminUser class SensitiveDataViewSet(viewsets.ModelViewSet): # AND : toutes les permissions doivent être vraies permission_classes = [IsAuthenticated, IsPremiumUser] class ArticleViewSet(viewsets.ModelViewSet): # OR : admin OU propriétaire permission_classes = [IsAdminUser | IsOwner] class DraftArticleViewSet(viewsets.ModelViewSet): # AND avec OR : authentifié ET (admin OU propriétaire) permission_classes = [IsAuthenticated & (IsAdminUser | IsOwner)] class PublicExceptAdminViewSet(viewsets.ModelViewSet): # NOT : tout sauf admin permission_classes = [~IsAdminUser]

Les opérateurs |, & et ~ fonctionnent sur les classes de permissions depuis DRF 3.9+. Pour les versions antérieures, utilisez des permissions wrapper.

Permission composite custom

                
api/permissions.py
class IsOwnerOrAdmin(permissions.BasePermission): """ Propriétaire OU admin (avec message personnalisé). """ message = "Vous devez être propriétaire ou administrateur." def has_object_permission(self, request, view, obj): if request.user.is_staff: return True owner_field = getattr(view, 'owner_field', 'owner') return getattr(obj, owner_field) == request.user

Permissions basées sur les rôles (RBAC)

Pour les applications complexes, un système basé sur les rôles est plus maintenable :

                
core/models.py
from django.db import models class Role(models.TextChoices): VIEWER = 'viewer', 'Lecteur' EDITOR = 'editor', 'Éditeur' ADMIN = 'admin', 'Administrateur' OWNER = 'owner', 'Propriétaire' class ProjectMembership(models.Model): """Relation User-Project avec rôle.""" user = models.ForeignKey('auth.User', on_delete=models.CASCADE) project = models.ForeignKey('Project', on_delete=models.CASCADE) role = models.CharField(max_length=20, choices=Role.choices) class Meta: unique_together = ['user', 'project']
                
api/permissions.py
class HasProjectRole(permissions.BasePermission): """ Permission basée sur le rôle dans un projet. Usage dans la vue: required_roles = ['editor', 'admin', 'owner'] """ def has_permission(self, request, view): if not request.user.is_authenticated: return False # Pour les actions sur un projet spécifique project_id = view.kwargs.get('project_pk') or view.kwargs.get('pk') if not project_id: return True # Sera vérifié au niveau object required_roles = getattr(view, 'required_roles', []) if not required_roles: return True return ProjectMembership.objects.filter( user=request.user, project_id=project_id, role__in=required_roles ).exists() def has_object_permission(self, request, view, obj): required_roles = getattr(view, 'required_roles', []) if not required_roles: return True # Récupérer le projet selon le type d'objet project = getattr(obj, 'project', obj) return ProjectMembership.objects.filter( user=request.user, project=project, role__in=required_roles ).exists()
                
api/views.py
class ProjectViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated, HasProjectRole] def get_required_roles(self): """Rôles requis selon l'action.""" return { 'list': [], # Pas de restriction 'retrieve': ['viewer', 'editor', 'admin', 'owner'], 'update': ['editor', 'admin', 'owner'], 'partial_update': ['editor', 'admin', 'owner'], 'destroy': ['owner'], }.get(self.action, []) @property def required_roles(self): return self.get_required_roles()

Permissions sur les actions custom

Les actions custom (@action) peuvent avoir leurs propres permissions :

                
api/views.py
from rest_framework.decorators import action from rest_framework.response import Response class ArticleViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] @action( detail=True, methods=['post'], permission_classes=[IsAuthenticated, IsOwner] ) def publish(self, request, pk=None): """Publier un article (propriétaire uniquement).""" article = self.get_object() article.status = 'published' article.save() return Response({'status': 'published'}) @action( detail=True, methods=['post'], permission_classes=[IsAdminUser] ) def feature(self, request, pk=None): """Mettre en avant un article (admin uniquement).""" article = self.get_object() article.is_featured = True article.save() return Response({'status': 'featured'}) @action( detail=False, methods=['get'], permission_classes=[AllowAny] ) def popular(self, request): """Articles populaires (accès libre).""" popular = self.queryset.filter( status='published' ).order_by('-view_count')[:10] serializer = self.get_serializer(popular, many=True) return Response(serializer.data)

Filtrage des querysets selon les permissions

Les permissions object-level ne filtrent pas les listes. Pour n'afficher que les objets autorisés :

                
api/views.py
class ArticleViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated, IsOwnerOrReadOnly] def get_queryset(self): user = self.request.user if user.is_staff: # Admin voit tout return Article.objects.all() # Utilisateur normal voit : # - ses propres articles (tous statuts) # - les articles publiés des autres return Article.objects.filter( models.Q(author=user) | models.Q(status='published') ).distinct()

Pattern : FilterBackend pour les permissions

                
api/filters.py
from rest_framework import filters class OwnerFilterBackend(filters.BaseFilterBackend): """ Filtre pour ne retourner que les objets de l'utilisateur. Configurable via owner_field sur la vue. """ def filter_queryset(self, request, queryset, view): if request.user.is_staff: return queryset owner_field = getattr(view, 'owner_field', 'owner') filter_kwargs = {owner_field: request.user} return queryset.filter(**filter_kwargs)
                
api/views.py
class PrivateDocumentViewSet(viewsets.ModelViewSet): queryset = Document.objects.all() permission_classes = [IsAuthenticated, IsOwner] filter_backends = [OwnerFilterBackend] owner_field = 'uploaded_by'

Messages d'erreur personnalisés

Par défaut, DRF retourne un message générique. Personnalisez-le :

                
api/permissions.py
class IsProjectMember(permissions.BasePermission): message = "Vous devez être membre de ce projet." code = 'not_project_member' # Code pour l'API def has_object_permission(self, request, view, obj): return ProjectMembership.objects.filter( user=request.user, project=obj.project ).exists() class CanDeleteProject(permissions.BasePermission): """Permission avec message dynamique.""" def has_object_permission(self, request, view, obj): if obj.tasks.filter(status='in_progress').exists(): self.message = "Impossible de supprimer : des tâches sont en cours." return False if obj.members.count() > 1: self.message = "Retirez d'abord les autres membres du projet." return False if obj.owner != request.user: self.message = "Seul le propriétaire peut supprimer le projet." return False return True

Throttling vs Permissions

Le throttling (limitation de débit) n'est pas une permission, mais les deux travaillent ensemble :

Ordre d'exécution DRF

1. Authentication : qui êtes-vous ?
2. Permissions : avez-vous le droit ?
3. Throttling : n'abusez-vous pas ?

                
api/views.py
from rest_framework.throttling import UserRateThrottle class PremiumUserThrottle(UserRateThrottle): """Limite plus élevée pour les premium.""" rate = '1000/hour' class StandardUserThrottle(UserRateThrottle): rate = '100/hour' class APIViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] def get_throttles(self): if self.request.user.subscription.plan == 'premium': return [PremiumUserThrottle()] return [StandardUserThrottle()]

Bonnes pratiques

  • Défaut restrictif : IsAuthenticated par défaut, ouvrir explicitement
  • Nommage clair : IsOwner, CanPublish, HasProjectRole
  • Séparation : une permission = une responsabilité
  • Messages utiles : expliquer pourquoi l'accès est refusé
  • Tests : tester chaque permission isolément
  • Documentation : documenter les permissions requises dans l'API schema

Piège courant : Ne pas filtrer le queryset en pensant que has_object_permission suffit. La liste affichera tous les objets, seul l'accès individuel sera bloqué. Toujours combiner permission + filtrage du queryset.

Besoin d'aide sur votre projet ?

Je peux vous accompagner dans le développement ou l'optimisation de votre application.

Me contacter