Les permissions dans Django REST Framework fonctionnent, mais elles montrent leurs limites dès que les règles d’accès deviennent un peu complexes. Plusieurs rôles, des objets appartenant à un utilisateur, des actions custom sur un ViewSet : on se retrouve rapidement avec des classes has_permission et has_object_permission qui mélangent des vérifications hétérogènes, difficiles à lire et encore plus difficiles à tester.
rest_access_policy (paquet djangorestframework-access-policy) propose une autre approche : déclarer les règles d’accès sous forme de statements, à la manière des politiques IAM d’AWS. Le résultat est lisible en un coup d’oeil, testable indépendamment du ViewSet, et extensible sans réécrire toute la classe.
Le problème avec les permissions DRF classiques
Prenons un cas courant : une API d’articles de blog. Les règles sont simples en apparence.
- Tout le monde peut lire.
- Les utilisateurs authentifiés peuvent créer.
- Un auteur peut modifier ou supprimer ses propres articles.
- Les admins peuvent tout faire.
Avec DRF standard, ça ressemble à ça :
class ArticlePermission(BasePermission):
def has_permission(self, request, view):
if request.method in SAFE_METHODS:
return True
if view.action == "create":
return request.user.is_authenticated
return request.user.is_authenticated
def has_object_permission(self, request, view, obj):
if request.method in SAFE_METHODS:
return True
if request.user.is_staff:
return True
return obj.author == request.user
Ce code n’est pas mauvais pour deux règles. Mais il force à jongler entre has_permission (avant que l’objet existe) et has_object_permission (après fetch), avec SAFE_METHODS pour distinguer lecture et écriture. Ajouter un troisième rôle ou une action custom oblige à modifier deux méthodes, avec un risque d’oubli réel.
L’approche déclarative de rest_access_policy
rest_access_policy inverse la logique : on ne code pas du comportement, on déclare des règles.
pip install djangorestframework-access-policy
Chaque règle est un statement avec quatre clés :
| Clé | Ce qu’elle définit |
|---|---|
principal | Qui est concerné (*, "authenticated", "admin", ["group:nom"]) |
action | Quelle action ViewSet (list, create, retrieve, update, …) |
effect | allow ou deny |
condition | Nom d’une méthode sur la Policy (optionnel) |
La Policy évalue tous les statements et collecte ceux dont le principal et l’action correspondent à la requête. L’accès est accordé si au moins un statement allow correspond et qu’aucun statement deny ne correspond. Si aucun statement ne correspond, l’accès est refusé par défaut.
Exemple complet : Policy d’articles de blog
from rest_access_policy import AccessPolicy
class ArticleAccessPolicy(AccessPolicy):
statements = [
{
"action": ["list", "retrieve"],
"principal": "*",
"effect": "allow",
},
{
"action": ["create"],
"principal": "authenticated",
"effect": "allow",
},
{
"action": ["update", "partial_update", "destroy"],
"principal": "authenticated",
"effect": "allow",
"condition": "is_author",
},
{
"action": "*",
"principal": ["group:admins"],
"effect": "allow",
},
]
def is_author(self, request, view, action) -> bool:
article = view.get_object()
return article.author == request.user
Le ViewSet utilise la Policy comme n’importe quelle permission DRF :
class ArticleViewSet(viewsets.ModelViewSet):
queryset = Article.objects.select_related("author")
serializer_class = ArticleSerializer
permission_classes = [ArticleAccessPolicy]
Quatre statements suffisent pour couvrir tous les cas. On lit les règles de haut en bas comme une spécification fonctionnelle, pas comme du code défensif.
Conditions réutilisables
Dans un projet réel, plusieurs resources partagent les mêmes vérifications : is_owner, is_active_user, is_published. Plutôt que de dupliquer les méthodes dans chaque Policy, on les extrait dans un mixin.
class BaseConditions:
def is_owner(self, request, view, action) -> bool:
obj = view.get_object()
return obj.owner == request.user
def is_active_subscription(self, request, view, action) -> bool:
return request.user.subscription.is_active
class ArticleAccessPolicy(BaseConditions, AccessPolicy):
statements = [
{
"action": ["update", "destroy"],
"principal": "authenticated",
"effect": "allow",
"condition": "is_owner",
},
...
]
class CommentAccessPolicy(BaseConditions, AccessPolicy):
statements = [
{
"action": ["create"],
"principal": "authenticated",
"effect": "allow",
"condition": "is_active_subscription",
},
...
]
Python résout is_owner via l’ordre MRO. La Policy hérite des conditions, le code n’est défini qu’une fois.
Combiner plusieurs conditions
Par défaut, quand la clé condition contient une seule chaîne, la condition doit retourner True pour que le statement s’applique. Mais on peut passer une liste : dans ce cas, toutes les conditions doivent être vraies (logique AND).
{
"action": ["publish"],
"principal": "authenticated",
"effect": "allow",
"condition": ["is_author", "is_active_subscription"],
}
Pour une logique OR, rest_access_policy supporte condition_expression, qui accepte une expression booléenne sous forme de chaîne :
{
"action": ["update"],
"principal": "authenticated",
"effect": "allow",
"condition_expression": ["is_author or is_editor"],
}
is_author et is_editor sont toujours des méthodes sur la Policy (ou un mixin hérité). La bibliothèque évalue l’expression en appelant chaque méthode et en substituant le résultat. C’est plus lisible qu’une méthode is_author_or_editor qui dupliquerait la logique à la main.
Filtrer les querysets avec scope_queryset
Il y a un comportement de DRF que beaucoup de développeurs découvrent trop tard : has_object_permission n’est appelée que lorsqu’un objet est déjà fetché depuis la base. Cela concerne uniquement les actions retrieve, update, partial_update et destroy, c’est-à-dire les actions qui travaillent sur un objet identifié par son ID.
L’action list est différente. DRF retourne le queryset tel quel, sans itérer sur les objets pour appeler has_object_permission un par un. Ce serait trop coûteux en requêtes, et ce n’est pas son rôle.
La conséquence concrète :
GET /articles/1/ → DRF fetche l'objet → vérifie has_object_permission → 403 si refusé
GET /articles/ → DRF retourne le queryset brut → has_object_permission jamais appelé
Sans précaution, un utilisateur qui n’a pas accès à /articles/1/ voit quand même cet article dans la liste GET /articles/. Il sait que l’objet existe, il en voit le titre ou les métadonnées selon le serializer, il reçoit juste une 403 s’il tente d’y accéder directement. C’est une fuite d’information, pas une sécurité.
rest_access_policy fournit scope_queryset pour combler cet écart. C’est l’endroit où on exprime, côté queryset, les mêmes règles que celles déclarées dans les statements. La Policy gère “peut-il effectuer cette action ?”, scope_queryset gère “que peut-il voir dans une liste ?”. Les deux doivent être cohérents.
class ArticleAccessPolicy(AccessPolicy):
statements = [...]
@classmethod
def scope_queryset(cls, request, queryset):
# Les admins voient tout, y compris les brouillons
if request.user.groups.filter(name="admins").exists():
return queryset
# Les autres ne voient que les articles publiés
return queryset.filter(status="published")
Dans le ViewSet :
class ArticleViewSet(viewsets.ModelViewSet):
permission_classes = [ArticleAccessPolicy]
def get_queryset(self):
queryset = Article.objects.select_related("author")
return ArticleAccessPolicy.scope_queryset(self.request, queryset)
scope_queryset est un classmethod délibérément : il peut être appelé depuis get_queryset sans instancier la policy. Et parce que c’est du SQL, le filtre se fait en une seule requête, pas en boucle sur les objets.
Tests
L’avantage des policies déclaratives, c’est qu’elles se testent unitairement sans démarrer une vue. rest_access_policy fournit une méthode _evaluate_statements mais le plus pragmatique reste de tester via le client DRF.
from rest_framework.test import APITestCase
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
User = get_user_model()
class ArticleAccessPolicyTest(APITestCase):
def setUp(self):
self.author = User.objects.create_user("author", password="pass")
self.other = User.objects.create_user("other", password="pass")
self.admin = User.objects.create_user("admin", password="pass")
admin_group = Group.objects.create(name="admins")
self.admin.groups.add(admin_group)
self.article = Article.objects.create(
title="Test", author=self.author, status="published"
)
def test_anonymous_can_read(self):
response = self.client.get(f"/api/articles/{self.article.pk}/")
self.assertEqual(response.status_code, 200)
def test_author_can_update_own_article(self):
self.client.force_authenticate(self.author)
response = self.client.patch(
f"/api/articles/{self.article.pk}/", {"title": "Nouveau titre"}
)
self.assertEqual(response.status_code, 200)
def test_other_user_cannot_update(self):
self.client.force_authenticate(self.other)
response = self.client.patch(
f"/api/articles/{self.article.pk}/", {"title": "Hack"}
)
self.assertEqual(response.status_code, 403)
def test_admin_can_delete_any_article(self):
self.client.force_authenticate(self.admin)
response = self.client.delete(f"/api/articles/{self.article.pk}/")
self.assertEqual(response.status_code, 204)
Chaque test cible une règle précise. Quand une règle change dans la Policy, le test correspondant casse, pas une méthode fourre-tout.
Les pièges à éviter
scope_queryset non implémenté. Sans lui, list renvoie des objets que l’utilisateur n’a pas le droit de voir, même si retrieve les bloque. Les deux doivent être cohérents.
Condition appelée sur list. Si une condition appelle view.get_object() (comme is_author), elle déclenche une requête supplémentaire pour chaque objet en mode list, ce qui crée un N+1. La solution : construire les statements pour que ces conditions ne s’appliquent qu’aux actions portant sur un objet unique (retrieve, update, partial_update, destroy).
AccessPolicy absente des permission_classes. Si la Policy est héritée mais pas référencée dans permission_classes, DRF n’applique aucune restriction. Une erreur silencieuse qui ne remonte pas en test si les fixtures ne couvrent pas les cas de refus.
Plusieurs policies dans permission_classes. DRF combine les permission classes avec un AND logique. Si on empile ArticleAccessPolicy et IsAuthenticated, les unauthenticated users se verront refuser même les actions list marquées allow pour *. Une seule Policy par ViewSet, les vérifications d’authentification vont dedans via les principal.
Ce que rest_access_policy n’est pas
La bibliothèque gère l’autorisation au niveau de l’API : qui peut faire quoi sur quel endpoint. Elle ne remplace pas les permissions au niveau modèle de Django (django-guardian pour du row-level multi-user), ni une couche d’audit trail, ni un système RBAC complet avec hiérarchie de rôles.
Pour un projet avec des règles d’accès simples à modérées sur des APIs DRF, c’est exactement le bon outil : moins de code défensif, plus de règles lisibles, des tests ciblés. L’investissement initial (comprendre les statements, câbler scope_queryset, écrire un test par règle) se rentabilise dès la troisième règle d’accès qu’on n’a pas eu à déboguer en production à 2h du matin.
