Django Guía

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.

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?

  1. 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.
  2. Signals Destructivos: Usar instance.groups.clear() en un signal post_save (como sugieren algunos guías) borrará cualquier grupo extra que hayas asignado manualmente (ej: "Newsletter Subscribers").
  3. 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.

Usa esto: {% 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)