16. Arquitectura de Roles y Permisos (RBAC)
En este capítulo transformaremos una implementación ingenua de roles en una arquitectura de seguridad profesional (RBAC) apta para producción, evitando vulnerabilidades comunes como Race Conditions y Signals destructivos.
Contenido del Módulo
1. Auditoría: La Falacia del Enfoque Híbrido
¡Cuidado con las malas prácticas!
Muchos tutoriales sugieren crear un campo role en el usuario y luego usar Mixins que hacen if user.role == 'TEACHER'. Esto es un error arquitectónico grave.
¿Por qué es incorrecto?
- Inutiliza el sistema de Django: Si verificas strings manualmente, ignoras la tabla
auth_permission. Un admin no podrá revocar permisos temporalmente desde el panel de administración sin cambiar el rol del usuario. - Signals Destructivos: Usar
instance.groups.clear()en un signalpost_save(como sugieren algunos guías) borrará cualquier grupo extra que hayas asignado manualmente (ej: "Newsletter Subscribers"). - Race Conditions: Las migraciones que dependen de permisos a menudo fallan en despliegues frescos (CI/CD) porque los permisos se crean después de todas las migraciones (signal
post_migrate).
2. Implementación Segura: Override save()
En lugar de usar signals impredecibles, sobrescribimos el método save() del modelo. Esto es explícito, fácil de debuguear y evita problemas con bulk_create (ya que somos conscientes de que save no corre en bulk).
# src/users/models.py
class CustomUser(AbstractUser):
# ... campos de rol ...
def save(self, *args, **kwargs):
is_new = self.pk is None
super().save(*args, **kwargs)
# Solo asignamos grupo al crear el usuario
if is_new and self.role:
from django.contrib.auth.models import Group
# Usamos first() para evitar crash si el grupo no existe (bootstrapping)
group = Group.objects.filter(name=self.role).first()
if group:
# Add es seguro, no borra otros grupos
self.groups.add(group)
3. Migraciones de Datos Robustas
Para evitar la "Race Condition" donde los permisos no existen cuando corre tu migración, debemos forzar la señal emit_post_migrate_signal.
# src/users/migrations/0003_setup_roles_groups.py
from django.db import migrations
from django.core.management.sql import emit_post_migrate_signal
def create_groups(apps, schema_editor):
# Truco Pro: Forzar creación de permisos antes de usarlos
emit_post_migrate_signal(2, False, 'default')
Group = apps.get_model('auth', 'Group')
Permission = apps.get_model('auth', 'Permission')
teacher_group, _ = Group.objects.get_or_create(name='TEACHER')
# Ahora es seguro buscar el permiso
try:
perm = Permission.objects.get(codename='add_curso', content_type__app_label='conceptos_basicos')
teacher_group.permissions.add(perm)
except Permission.DoesNotExist:
pass
class Migration(migrations.Migration):
dependencies = [
("users", "0002_customuser_role"),
("conceptos_basicos", "0001_initial"), # Dependencia clave
]
operations = [
migrations.RunPython(create_groups),
]
4. Protección Real con Permisos
El Mixin Correcto
No crees un mixin que chequee role == 'TEACHER'. Crea uno que chequee el permiso.
# src/users/mixins.py
from django.contrib.auth.mixins import PermissionRequiredMixin
class TeacherRequiredMixin(PermissionRequiredMixin):
permission_required = 'conceptos_basicos.add_curso'
# Redirige a login si es anónimo, o 403 si está logueado sin permiso
raise_exception = False
En los Templates
Nunca uses {% if user.role == 'TEACHER' %}. Si mañana cambias el nombre del rol, tendrás que editar 50 templates.
{% if perms.conceptos_basicos.add_curso %}
<nav>
<a href="/">Inicio</a>
{% if perms.conceptos_basicos.add_curso %}
<a href="{% url 'crear_curso' %}">Crear Nuevo Curso</a>
{% endif %}
</nav>
5. Reto Experto: Permisos a Nivel de Objeto
Hasta ahora tenemos permisos globales: "Un profesor puede editar cursos". Pero, ¿puede el Profesor A editar el curso del Profesor B?
El problema: Los permisos de Django (add, change) son a nivel de Tabla, no de Fila.
Solución Nativa: get_queryset
Filtra los objetos que el usuario puede ver/editar.
class CursoUpdateView(TeacherRequiredMixin, UpdateView):
def get_queryset(self):
# Solo permite editar cursos creados por el usuario actual
# (Asumiendo que agregamos un campo 'autor' al modelo Curso)
return Curso.objects.filter(autor=self.request.user)
Librerías Avanzadas
Para casos complejos (ej: "compartir" un documento con un usuario específico), utiliza django-guardian, que permite asignar permisos por instancia: assign_perm('change_curso', user, curso_instance).
6. Testing Robusto
Asegúrate de probar los casos de fallo (403 Forbidden).
def test_student_cannot_access_create_view(self):
self.client.force_login(self.student)
response = self.client.get(reverse('curso_create'))
self.assertEqual(response.status_code, 403)