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 :
IsAuthenticatedpar 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