Los permisos en Django REST Framework funcionan, pero muestran sus límites en cuanto las reglas de acceso se vuelven moderadamente complejas. Varios roles, objetos pertenecientes a un usuario específico, acciones custom en un ViewSet: acabas con clases has_permission y has_object_permission que mezclan verificaciones heterogéneas, difíciles de leer y aún más difíciles de testear.
rest_access_policy (paquete djangorestframework-access-policy) propone un enfoque diferente: declarar las reglas de acceso como statements, similar a las políticas IAM de AWS. El resultado es legible de un vistazo, testeable independientemente del ViewSet, y extensible sin reescribir toda la clase.
El problema con los permisos clásicos de DRF
Tomemos un caso habitual: una API de artículos de blog. Las reglas parecen simples a primera vista.
- Cualquiera puede leer.
- Los usuarios autenticados pueden crear.
- Un autor puede modificar o eliminar sus propios artículos.
- Los admins pueden hacer todo.
Con DRF estándar, se ve así:
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
Este código no está mal para dos reglas. Pero obliga a malabarear entre has_permission (antes de que el objeto exista) y has_object_permission (tras el fetch), usando SAFE_METHODS para distinguir lecturas de escrituras. Añadir un tercer rol o una acción custom obliga a modificar dos métodos, con un riesgo real de olvido.
El enfoque declarativo de rest_access_policy
rest_access_policy invierte la lógica: no se codifica comportamiento, se declaran reglas.
pip install djangorestframework-access-policy
Cada regla es un statement con cuatro claves:
| Clave | Lo que define |
|---|---|
principal | Quién está afectado (*, "authenticated", "admin", ["group:nombre"]) |
action | Qué acción del ViewSet (list, create, retrieve, update, …) |
effect | allow o deny |
condition | Nombre de un método en la Policy (opcional) |
La Policy evalúa todos los statements y recoge aquellos cuyo principal y acción coinciden con la petición. El acceso se concede si al menos un statement allow coincide y ningún statement deny coincide. Si no hay coincidencia, el acceso se deniega por defecto.
Ejemplo completo: Policy de artículos 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
El ViewSet usa la Policy como cualquier permiso DRF:
class ArticleViewSet(viewsets.ModelViewSet):
queryset = Article.objects.select_related("author")
serializer_class = ArticleSerializer
permission_classes = [ArticleAccessPolicy]
Cuatro statements son suficientes para cubrir todos los casos. Se leen las reglas de arriba a abajo como una especificación funcional, no como código defensivo.
Condiciones reutilizables
En un proyecto real, varios recursos comparten las mismas verificaciones: is_owner, is_active_user, is_published. En lugar de duplicar métodos en cada Policy, se extraen en 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 resuelve is_owner mediante el orden MRO. La Policy hereda las condiciones, el código se define una sola vez.
Combinar varias condiciones
Por defecto, cuando la clave condition contiene una sola cadena, la condición debe retornar True para que el statement se aplique. También se puede pasar una lista: todas las condiciones deben ser verdaderas (lógica AND).
{
"action": ["publish"],
"principal": "authenticated",
"effect": "allow",
"condition": ["is_author", "is_active_subscription"],
}
Para lógica OR, rest_access_policy soporta condition_expression, que acepta una expresión booleana como cadena:
{
"action": ["update"],
"principal": "authenticated",
"effect": "allow",
"condition_expression": ["is_author or is_editor"],
}
is_author e is_editor siguen siendo métodos en la Policy (o en un mixin heredado). La biblioteca evalúa la expresión llamando a cada método y sustituyendo el resultado. Es más legible que escribir un único método is_author_or_editor que duplique la lógica a mano.
Filtrar querysets con scope_queryset
Hay un comportamiento de DRF que muchos desarrolladores descubren demasiado tarde: has_object_permission solo se llama cuando un objeto ya ha sido obtenido de la base de datos. Esto aplica únicamente a las acciones que trabajan sobre un objeto identificado por su ID: retrieve, update, partial_update y destroy.
La acción list funciona diferente. DRF devuelve el queryset tal cual, sin iterar sobre los objetos para llamar has_object_permission uno por uno. Sería demasiado costoso en consultas, y simplemente no es responsabilidad de DRF.
La consecuencia concreta:
GET /articles/1/ → DRF obtiene el objeto → verifica has_object_permission → 403 si denegado
GET /articles/ → DRF devuelve el queryset bruto → has_object_permission nunca se llama
Sin precaución, un usuario que no tiene acceso a /articles/1/ sigue viendo ese artículo en GET /articles/. Sabe que el objeto existe, puede ver su título o metadatos según el serializer, solo recibe un 403 si intenta acceder directamente. Eso es una fuga de información, no una frontera de seguridad.
rest_access_policy proporciona scope_queryset para cerrar esta brecha. Es donde se expresan, a nivel de queryset, las mismas reglas declaradas en los statements. La Policy gestiona “¿puede realizar esta acción?”, scope_queryset gestiona “¿qué puede ver en una lista?”. Ambos deben ser coherentes.
class ArticleAccessPolicy(AccessPolicy):
statements = [...]
@classmethod
def scope_queryset(cls, request, queryset):
# Los admins ven todo, incluidos los borradores
if request.user.groups.filter(name="admins").exists():
return queryset
# Los demás solo ven artículos publicados
return queryset.filter(status="published")
En el 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 es deliberadamente un classmethod: puede llamarse desde get_queryset sin instanciar la policy. Y como se traduce a SQL, el filtro ocurre en una sola consulta, no en un bucle sobre los objetos.
Tests
La ventaja de las policies declarativas es que se pueden testear unitariamente sin arrancar una vista. rest_access_policy proporciona un método _evaluate_statements, pero lo más práctico sigue siendo testear con el cliente 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": "Nuevo título"}
)
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)
Cada test apunta a una regla precisa. Cuando una regla cambia en la Policy, el test correspondiente falla, no un método que lo mezcla todo.
Errores comunes a evitar
scope_queryset sin implementar. Sin él, list devuelve objetos que el usuario no tiene derecho a ver, aunque retrieve los bloquee. Ambos deben ser coherentes.
Condición llamada en list. Si una condición llama a view.get_object() (como is_author), provoca una consulta extra por cada objeto en modo list, creando N+1 queries. La solución: construir los statements para que estas condiciones solo se apliquen a acciones sobre un objeto único (retrieve, update, partial_update, destroy).
AccessPolicy ausente de permission_classes. Si la Policy se hereda pero no se referencia en permission_classes, DRF no aplica ninguna restricción. Un error silencioso que no aparece en los tests si los fixtures no cubren los casos de denegación.
Varias policies en permission_classes. DRF combina las clases de permisos con lógica AND. Apilar ArticleAccessPolicy e IsAuthenticated denegará a los usuarios no autenticados incluso para acciones list marcadas como allow para *. Una sola Policy por ViewSet, las verificaciones de autenticación van dentro mediante principal.
Lo que rest_access_policy no es
La biblioteca gestiona la autorización a nivel de API: quién puede hacer qué en qué endpoint. No reemplaza los permisos a nivel de modelo de Django (django-guardian para acceso row-level multi-usuario), ni una capa de audit trail, ni un sistema RBAC completo con jerarquía de roles.
Para un proyecto con reglas de acceso simples a moderadas en APIs DRF, es exactamente la herramienta adecuada: menos código defensivo, reglas más legibles, tests precisos.
