Permisos avanzados y object-level en DRF

Avanzado
Django
Django
Actualizado: 19/04/2026

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_permission y 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ñade user.id en la clave.
  • No testear con usuarios anónimos: cualquier endpoint sin IsAuthenticated explícito podría quedar público.
  • Permisos heredados poco claros: prefiere explicitar permission_classes siempre 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 IsOwnerOrReadOnly o similar.
  • [ ] SaaS multi-tenant tiene TenantIsolatedMixin o 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 - Autor del tutorial

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