Permission class vs permission method
DRF tiene dos niveles de comprobación:
has_permission(request, view): al principio de la request. Decide si el usuario puede acceder al endpoint en absoluto.has_object_permission(request, view, obj): cuando se accede a un objeto concreto (retrieve, update, destroy). Decide si puede ver/editar ese objeto.
La mayoría de permisos estándar (IsAuthenticated, IsAdminUser) solo implementan has_permission. Para casos como "solo puedes editar tu propio pedido" necesitas has_object_permission.
IsOwnerOrReadOnly: el patrón canónico
El permiso más común: lectura pública o autenticada, escritura solo por el dueño del recurso.
from rest_framework import permissions
class IsOwnerOrReadOnly(permissions.BasePermission):
"""
Cualquier usuario autenticado lee. Solo el dueno escribe.
"""
def has_permission(self, request, view):
# Para list/create: aplicar has_permission
if request.method in permissions.SAFE_METHODS: # GET, HEAD, OPTIONS
return True
return request.user.is_authenticated
def has_object_permission(self, request, view, obj):
# Lectura libre
if request.method in permissions.SAFE_METHODS:
return True
# Escritura solo dueno
return obj.owner == request.user
Uso:
class PedidoViewSet(viewsets.ModelViewSet):
queryset = Pedido.objects.all()
serializer_class = PedidoSerializer
permission_classes = [permissions.IsAuthenticated, IsOwnerOrReadOnly]
Combinación con operadores
DRF 3.10+ permite componer permisos con & (AND), | (OR), ~ (NOT):
class PedidoViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated & (IsAdmin | IsOwnerOrReadOnly)]
Lee: "debe estar autenticado, y (ser admin o ser dueño con permisos de lectura)".
Con paréntesis y varios operadores puedes modelar políticas complejas sin crear una permission class nueva.
Permisos basados en scope / rol
Si usas JWT con claims de rol, las comprobaciones pueden evitar queries:
class HasRole(permissions.BasePermission):
required_role = None
def has_permission(self, request, view):
if not request.auth:
return False
return request.auth.get('role') == self.required_role
class IsEditor(HasRole):
required_role = 'editor'
class IsAdmin(HasRole):
required_role = 'admin'
Uso:
class ArticuloViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated & (IsAdmin | IsEditor)]
Permisos por método HTTP
DRF tiene DjangoModelPermissions que comprueba permisos por método:
- GET requiere
view_producto - POST requiere
add_producto - PUT/PATCH requiere
change_producto - DELETE requiere
delete_producto
class ProductoViewSet(viewsets.ModelViewSet):
permission_classes = [DjangoModelPermissions]
queryset = Producto.objects.all()
serializer_class = ProductoSerializer
Los permisos son los estándar de Django: app.add_modelo, app.view_modelo, etc. Se asignan a usuarios o grupos desde el admin o programáticamente.
Object-level permissions con django-guardian
Para permisos por fila específica (ej. "el usuario X puede editar el pedido Y pero no el Z"):
pip install django-guardian
# settings.py
INSTALLED_APPS = [
...
'guardian',
]
AUTHENTICATION_BACKENDS = [
'django.contrib.auth.backends.ModelBackend',
'guardian.backends.ObjectPermissionBackend',
]
ANONYMOUS_USER_NAME = 'AnonymousUser'
Asignar permiso a un usuario sobre un objeto concreto:
from guardian.shortcuts import assign_perm, remove_perm
pedido = Pedido.objects.get(id=123)
assign_perm('change_pedido', user, pedido)
# Comprobar
user.has_perm('change_pedido', pedido) # True
Uso con DRF:
from rest_framework_guardian.filters import ObjectPermissionsFilter
from rest_framework import permissions
class PedidoViewSet(viewsets.ModelViewSet):
queryset = Pedido.objects.all()
serializer_class = PedidoSerializer
permission_classes = [DjangoObjectPermissions]
filter_backends = [ObjectPermissionsFilter] # solo devuelve objetos sobre los que hay permiso
Pattern: ownership via queryset
Alternativa más simple a guardian para el caso habitual: filtrar el queryset según el usuario.
class PedidoViewSet(viewsets.ModelViewSet):
serializer_class = PedidoSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
user = self.request.user
if user.is_staff:
return Pedido.objects.all()
return Pedido.objects.filter(usuario=user)
El usuario solo ve SUS pedidos. Si intenta acceder al pedido de otro por ID, recibe 404 (el queryset no lo incluye). No necesita has_object_permission.
Pattern: tenant isolation multi-tenant
Un patrón muy común en SaaS multi-tenant:
class TenantIsolatedMixin:
def get_queryset(self):
tenant = self.request.user.tenant # el usuario tiene un tenant asignado
return super().get_queryset().filter(tenant=tenant)
def perform_create(self, serializer):
serializer.save(tenant=self.request.user.tenant)
class PedidoViewSet(TenantIsolatedMixin, viewsets.ModelViewSet):
queryset = Pedido.objects.all()
serializer_class = PedidoSerializer
permission_classes = [IsAuthenticated]
Al listar, solo ve los de su tenant. Al crear, se asigna al tenant del usuario. No puede salir de su tenant aunque conozca los IDs ajenos.
Diferencia entre 403 y 404
- 403 Forbidden: el usuario está autenticado pero no tiene permisos.
- 404 Not Found: el recurso no existe (o el usuario no sabe si existe).
Por seguridad prefiere 404 cuando el usuario no tiene permisos, para no filtrar "existe un recurso con ID X pero no puedes verlo". El patrón del queryset filtrado lo da automáticamente.
DRF por defecto da 404 cuando has_object_permission es False tras filtrar. Puedes controlarlo con NOT_FOUND_ON_PERMISSION_DENIED.
Testear permisos
Tests exhaustivos de permisos son cruciales para evitar brechas:
class PedidoPermissionsTest(APITestCase):
def setUp(self):
self.user_a = User.objects.create_user(username='a', password='p')
self.user_b = User.objects.create_user(username='b', password='p')
self.pedido_a = Pedido.objects.create(usuario=self.user_a, ...)
self.pedido_b = Pedido.objects.create(usuario=self.user_b, ...)
def test_a_puede_ver_su_pedido(self):
self.client.force_authenticate(self.user_a)
response = self.client.get(f'/api/pedidos/{self.pedido_a.id}/')
self.assertEqual(response.status_code, 200)
def test_a_no_puede_ver_pedido_de_b(self):
self.client.force_authenticate(self.user_a)
response = self.client.get(f'/api/pedidos/{self.pedido_b.id}/')
self.assertEqual(response.status_code, 404) # filtrado por queryset
def test_a_no_puede_editar_pedido_de_b(self):
self.client.force_authenticate(self.user_a)
response = self.client.patch(f'/api/pedidos/{self.pedido_b.id}/', {'estado': 'cancelado'})
self.assertEqual(response.status_code, 404)
def test_anonimo_no_puede_ver_nada(self):
response = self.client.get('/api/pedidos/')
self.assertEqual(response.status_code, 401)
Composición recomendada
Un stack de permisos típico para B2B:
class SaasViewSet(viewsets.ModelViewSet):
permission_classes = [
IsAuthenticated,
HasValidApiKey,
TenantActivo,
]
def get_queryset(self):
# Filtro obligatorio por tenant del usuario
return super().get_queryset().filter(tenant=self.request.user.tenant)
def get_permissions(self):
# Permisos distintos por accion
if self.action in ('list', 'retrieve'):
return [IsAuthenticated(), HasValidApiKey(), TenantActivo()]
elif self.action == 'destroy':
return [IsAuthenticated(), IsAdmin()]
return super().get_permissions()
Errores comunes
- Olvidar
has_object_permissiony solo verificar por queryset: puede ser vulnerable si hay retrieve por ID que no filtra. - Confiar en permisos de lectura estándar cuando el endpoint es muy sensible: protege con permisos específicos.
- Cachear queryset filtrado: si cacheas
PedidoViewSet.list()compartes entre usuarios. Añadeuser.iden la clave. - No testear con usuarios anónimos: cualquier endpoint sin
IsAuthenticatedexplícito podría quedar público. - Permisos heredados poco claros: prefiere explicitar
permission_classessiempre aunque coincida con el default.
Checklist
- [ ]
DEFAULT_PERMISSION_CLASSES = [IsAuthenticated]en settings como default seguro. - [ ] Cada ViewSet sobreescribe si necesita otra cosa.
- [ ] Endpoints con objetos propios usan
IsOwnerOrReadOnlyo similar. - [ ] SaaS multi-tenant tiene
TenantIsolatedMixino equivalente. - [ ] Tests exhaustivos (usuario, otro usuario, anónimo, admin) para los endpoints sensibles.
- [ ] Preferencia por 404 sobre 403 para no filtrar existencia.
- [ ] Logging de intentos de acceso denegados para detectar abusos.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Django es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Django
Explora más contenido relacionado con Django y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Escribir BasePermission con has_permission y has_object_permission. Diferenciar permisos vista-level de object-level. Combinar permisos con los operadores & (AND) y | (OR) de DRF. Implementar IsOwnerOrReadOnly para recursos con dueno. Usar django-guardian para permisos granulares por fila. Integrar permisos con JWT claims para decisiones sin BBDD.
Cursos que incluyen esta lección
Esta lección forma parte de los siguientes cursos estructurados con rutas de aprendizaje