feature/rbac permisos y roles implementados

This commit is contained in:
2026-05-21 07:54:59 -06:00
parent 9bbed42cf3
commit a318b70324
38 changed files with 2596 additions and 901 deletions

0
api/rbac/__init__.py Normal file
View File

99
api/rbac/admin.py Normal file
View File

@@ -0,0 +1,99 @@
from django.contrib import admin
from .models import OrganizationRole, RolePermission, UserPermission, UserRole
@admin.register(RolePermission)
class RolePermissionAdmin(admin.ModelAdmin):
list_display = ('codename', 'modulo', 'descripcion')
list_filter = ('modulo',)
search_fields = ('codename', 'descripcion')
ordering = ('modulo', 'codename')
def get_readonly_fields(self, request, obj=None):
# Al editar un permiso existente los campos son readonly para evitar inconsistencias
if obj:
return ('codename', 'modulo', 'descripcion')
return ()
def has_add_permission(self, request):
return request.user.is_superuser
def has_change_permission(self, request, obj=None):
return request.user.is_superuser
def has_delete_permission(self, request, obj=None):
return request.user.is_superuser
class UserRoleInline(admin.TabularInline):
model = UserRole
extra = 0
autocomplete_fields = ('user',)
readonly_fields = ('created_at',)
@admin.register(OrganizationRole)
class OrganizationRoleAdmin(admin.ModelAdmin):
list_display = ('nombre', 'organizacion', 'is_admin_role', 'permisos_count', 'usuarios_count')
list_filter = ('organizacion', 'is_admin_role')
search_fields = ('nombre', 'organizacion__nombre')
filter_horizontal = ('permissions',)
inlines = (UserRoleInline,)
readonly_fields = ('created_at', 'updated_at')
def permisos_count(self, obj):
return obj.permissions.count()
permisos_count.short_description = 'Permisos'
def usuarios_count(self, obj):
return obj.user_roles.count()
usuarios_count.short_description = 'Usuarios'
def has_add_permission(self, request):
return request.user.is_superuser
def has_delete_permission(self, request, obj=None):
if obj and obj.is_admin_role:
return False
return request.user.is_superuser
@admin.register(UserRole)
class UserRoleAdmin(admin.ModelAdmin):
list_display = ('user', 'role', 'organizacion', 'created_at')
list_filter = ('role__organizacion', 'role__nombre')
search_fields = ('user__username', 'user__email', 'role__nombre')
autocomplete_fields = ('user',)
readonly_fields = ('created_at',)
def organizacion(self, obj):
return obj.role.organizacion
organizacion.short_description = 'Organización'
def save_model(self, request, obj, form, change):
# Bloquear remoción del rol admin_role al owner de la org
if change and obj.role.is_admin_role:
org = obj.role.organizacion
if hasattr(org, 'owner') and org.owner == obj.user:
from django.contrib import messages
self.message_user(
request,
'No se puede remover el rol de administrador maestro al owner de la organización.',
level=messages.ERROR,
)
return
super().save_model(request, obj, form, change)
@admin.register(UserPermission)
class UserPermissionAdmin(admin.ModelAdmin):
list_display = ('user', 'permission', 'granted', 'organizacion', 'created_at')
list_filter = ('granted', 'permission__modulo')
search_fields = ('user__username', 'user__email', 'permission__codename')
autocomplete_fields = ('user',)
readonly_fields = ('created_at',)
def organizacion(self, obj):
return getattr(obj.user, 'organizacion', '')
organizacion.short_description = 'Organización'

8
api/rbac/apps.py Normal file
View File

@@ -0,0 +1,8 @@
from django.apps import AppConfig
class RbacConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'api.rbac'
label = 'rbac'
verbose_name = 'RBAC'

View File

View File

View File

@@ -0,0 +1,101 @@
"""
Sincroniza el catálogo de permisos de roles.py con la base de datos.
Uso básico (solo catálogo):
python manage.py sync_rbac
Con propagación a roles existentes (agrega permisos nuevos a roles que ya existen):
python manage.py sync_rbac --roles
Con listado de lo que hay actualmente:
python manage.py sync_rbac --list
"""
from django.core.management.base import BaseCommand
from api.rbac.roles import DEFAULT_ROLES, PERMISSIONS_CATALOG
class Command(BaseCommand):
help = 'Sincroniza el catálogo de permisos (roles.py → BD) sin necesidad de migración.'
def add_arguments(self, parser):
parser.add_argument(
'--roles',
action='store_true',
help='Propaga los permisos nuevos a los OrganizationRoles existentes que coincidan con DEFAULT_ROLES.',
)
parser.add_argument(
'--list',
action='store_true',
help='Lista los permisos actuales en la BD agrupados por módulo.',
)
def handle(self, *args, **options):
from api.rbac.models import OrganizationRole, RolePermission
if options['list']:
self._list_permisos(RolePermission)
return
self._sync_catalogo(RolePermission)
if options['roles']:
self._sync_roles(RolePermission, OrganizationRole)
# ------------------------------------------------------------------
def _sync_catalogo(self, RolePermission):
creados = 0
existentes = 0
for codename, descripcion, modulo in PERMISSIONS_CATALOG:
_, created = RolePermission.objects.get_or_create(
codename=codename,
defaults={'descripcion': descripcion, 'modulo': modulo},
)
if created:
self.stdout.write(self.style.SUCCESS(f' [+] {codename} ({modulo})'))
creados += 1
else:
existentes += 1
self.stdout.write(
self.style.SUCCESS(f'\nCatálogo: {creados} permisos creados, {existentes} ya existían.')
)
def _sync_roles(self, RolePermission, OrganizationRole):
perms_map = {p.codename: p for p in RolePermission.objects.all()}
roles_actualizados = 0
permisos_agregados = 0
for org_role in OrganizationRole.objects.select_related('organizacion').prefetch_related('permissions'):
config = DEFAULT_ROLES.get(org_role.nombre)
if not config:
continue
esperados = {c: perms_map[c] for c in config['permissions'] if c in perms_map}
actuales = {p.codename for p in org_role.permissions.all()}
nuevos = {c: p for c, p in esperados.items() if c not in actuales}
if nuevos:
org_role.permissions.add(*nuevos.values())
roles_actualizados += 1
permisos_agregados += len(nuevos)
self.stdout.write(
f' Rol "{org_role.nombre}" en {org_role.organizacion}: '
f'+{len(nuevos)}{", ".join(nuevos.keys())}'
)
self.stdout.write(
self.style.SUCCESS(
f'\nRoles: {roles_actualizados} roles actualizados, {permisos_agregados} asignaciones nuevas.'
)
)
def _list_permisos(self, RolePermission):
modulo_actual = None
for perm in RolePermission.objects.order_by('modulo', 'codename'):
if perm.modulo != modulo_actual:
modulo_actual = perm.modulo
self.stdout.write(self.style.HTTP_INFO(f'\n {modulo_actual}'))
self.stdout.write(f' {perm.codename:<40} {perm.descripcion}')

View File

@@ -0,0 +1,116 @@
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('organization', '0003_organizacion_apply_auto_download'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='RolePermission',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('codename', models.CharField(max_length=100, unique=True)),
('descripcion', models.CharField(max_length=255)),
('modulo', models.CharField(max_length=50)),
],
options={
'verbose_name': 'Permiso',
'verbose_name_plural': 'Permisos',
'db_table': 'rbac_role_permission',
'ordering': ['modulo', 'codename'],
},
),
migrations.CreateModel(
name='OrganizationRole',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('nombre', models.CharField(max_length=100)),
('descripcion', models.CharField(blank=True, max_length=255)),
('is_admin_role', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('organizacion', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='roles',
to='organization.organizacion',
)),
('permissions', models.ManyToManyField(
blank=True,
related_name='roles',
to='rbac.rolepermission',
)),
],
options={
'verbose_name': 'Rol de Organización',
'verbose_name_plural': 'Roles de Organización',
'db_table': 'rbac_organization_role',
'ordering': ['nombre'],
},
),
migrations.AddConstraint(
model_name='organizationrole',
constraint=models.UniqueConstraint(fields=['organizacion', 'nombre'], name='unique_role_per_org'),
),
migrations.CreateModel(
name='UserRole',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='user_roles',
to=settings.AUTH_USER_MODEL,
)),
('role', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='user_roles',
to='rbac.organizationrole',
)),
],
options={
'verbose_name': 'Rol de Usuario',
'verbose_name_plural': 'Roles de Usuario',
'db_table': 'rbac_user_role',
},
),
migrations.AddConstraint(
model_name='userrole',
constraint=models.UniqueConstraint(fields=['user', 'role'], name='unique_user_role'),
),
migrations.CreateModel(
name='UserPermission',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('granted', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='rbac_permissions',
to=settings.AUTH_USER_MODEL,
)),
('permission', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='user_overrides',
to='rbac.rolepermission',
)),
],
options={
'verbose_name': 'Permiso Singular',
'verbose_name_plural': 'Permisos Singulares',
'db_table': 'rbac_user_permission',
},
),
migrations.AddConstraint(
model_name='userpermission',
constraint=models.UniqueConstraint(fields=['user', 'permission'], name='unique_user_permission'),
),
]

View File

@@ -0,0 +1,88 @@
"""
Data migration que:
1. Crea el catálogo global de permisos (RolePermission).
2. Para cada Organizacion existente, crea los 5 roles por defecto con sus permisos.
3. Para cada CustomUser existente, mapea sus auth.Group actuales al UserRole equivalente.
Usa get_or_create en todos los pasos — segura de ejecutar múltiples veces.
"""
from django.db import migrations
# Importamos solo constantes (no modelos ni funciones con imports de Django)
# para que la migration sea estable ante futuros refactors del código de la app.
from api.rbac.roles import PERMISSIONS_CATALOG, DEFAULT_ROLES
def _crear_permisos(RolePermission):
perms_map = {}
for codename, descripcion, modulo in PERMISSIONS_CATALOG:
perm, _ = RolePermission.objects.get_or_create(
codename=codename,
defaults={'descripcion': descripcion, 'modulo': modulo},
)
perms_map[codename] = perm
return perms_map
def _crear_roles_org(OrganizationRole, org, perms_map):
for nombre, config in DEFAULT_ROLES.items():
role, created = OrganizationRole.objects.get_or_create(
organizacion=org,
nombre=nombre,
defaults={
'descripcion': config['descripcion'],
'is_admin_role': config.get('is_admin_role', False),
},
)
if created:
role_perms = [perms_map[c] for c in config['permissions'] if c in perms_map]
role.permissions.set(role_perms)
def seed_rbac_data(apps, schema_editor):
RolePermission = apps.get_model('rbac', 'RolePermission')
OrganizationRole = apps.get_model('rbac', 'OrganizationRole')
UserRole = apps.get_model('rbac', 'UserRole')
Organizacion = apps.get_model('organization', 'Organizacion')
CustomUser = apps.get_model('cuser', 'CustomUser')
# Paso 1 — Catálogo de permisos
perms_map = _crear_permisos(RolePermission)
# Paso 2 — Roles por defecto para cada organización existente
for org in Organizacion.objects.all():
_crear_roles_org(OrganizationRole, org, perms_map)
# Paso 3 — Mapeo de usuarios: auth.Group → UserRole
# Solo usuarios que tengan organización asignada y grupos asignados
for user in CustomUser.objects.filter(organizacion__isnull=False).prefetch_related('groups'):
for group in user.groups.all():
try:
role = OrganizationRole.objects.get(
organizacion=user.organizacion,
nombre=group.name,
)
UserRole.objects.get_or_create(user=user, role=role)
except OrganizationRole.DoesNotExist:
# El grupo no tiene equivalente en los roles por defecto — se ignora
pass
def reverse_seed(apps, schema_editor):
# Revertir borra todos los datos RBAC. Los auth.Group originales no se tocan.
apps.get_model('rbac', 'UserRole').objects.all().delete()
apps.get_model('rbac', 'OrganizationRole').objects.all().delete()
apps.get_model('rbac', 'RolePermission').objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
('rbac', '0001_initial'),
('cuser', '0005_customuser_rfc_fk_to_m2m'),
('organization', '0003_organizacion_apply_auto_download'),
]
operations = [
migrations.RunPython(seed_rbac_data, reverse_code=reverse_seed),
]

View File

@@ -0,0 +1,56 @@
"""
Agrega el permiso notificaciones.receive al catálogo y lo asigna a todos los
OrganizationRole que correspondan a los 5 roles por defecto (en todas las orgs).
"""
from django.db import migrations
NUEVO_PERMISO = (
'notificaciones.receive',
'Recibir notificaciones automáticas de eventos',
'notificaciones',
)
# Todos los roles por defecto deben recibir notificaciones
ROLES_CON_PERMISO = ['admin', 'developer', 'Agente Aduanal', 'user', 'Importador']
def agregar_notificaciones_receive(apps, schema_editor):
RolePermission = apps.get_model('rbac', 'RolePermission')
OrganizationRole = apps.get_model('rbac', 'OrganizationRole')
codename, descripcion, modulo = NUEVO_PERMISO
perm, _ = RolePermission.objects.get_or_create(
codename=codename,
defaults={'descripcion': descripcion, 'modulo': modulo},
)
roles = OrganizationRole.objects.filter(nombre__in=ROLES_CON_PERMISO)
for role in roles:
role.permissions.add(perm)
def revertir(apps, schema_editor):
RolePermission = apps.get_model('rbac', 'RolePermission')
OrganizationRole = apps.get_model('rbac', 'OrganizationRole')
try:
perm = RolePermission.objects.get(codename='notificaciones.receive')
except RolePermission.DoesNotExist:
return
for role in OrganizationRole.objects.all():
role.permissions.remove(perm)
perm.delete()
class Migration(migrations.Migration):
dependencies = [
('rbac', '0002_data_permissions'),
]
operations = [
migrations.RunPython(agregar_notificaciones_receive, reverse_code=revertir),
]

View File

@@ -0,0 +1,57 @@
"""
Agrega los permisos auditoria.view y auditoria.process al catálogo y los asigna
a los roles admin, developer (ambos) y Agente Aduanal (solo view).
"""
from django.db import migrations
NUEVOS_PERMISOS = [
('auditoria.view', 'Ver estado y resultados de auditoría VUCEM', 'auditoria'),
('auditoria.process', 'Lanzar procesos de auditoría y reauditoría', 'auditoria'),
]
ROLES_AUDITORIA_FULL = ['admin', 'developer']
ROLES_AUDITORIA_VIEW = ['Agente Aduanal']
def agregar_auditoria(apps, schema_editor):
RolePermission = apps.get_model('rbac', 'RolePermission')
OrganizationRole = apps.get_model('rbac', 'OrganizationRole')
perms = {}
for codename, descripcion, modulo in NUEVOS_PERMISOS:
perm, _ = RolePermission.objects.get_or_create(
codename=codename,
defaults={'descripcion': descripcion, 'modulo': modulo},
)
perms[codename] = perm
for role in OrganizationRole.objects.filter(nombre__in=ROLES_AUDITORIA_FULL):
role.permissions.add(perms['auditoria.view'], perms['auditoria.process'])
for role in OrganizationRole.objects.filter(nombre__in=ROLES_AUDITORIA_VIEW):
role.permissions.add(perms['auditoria.view'])
def revertir(apps, schema_editor):
RolePermission = apps.get_model('rbac', 'RolePermission')
OrganizationRole = apps.get_model('rbac', 'OrganizationRole')
for codename, _, _ in NUEVOS_PERMISOS:
try:
perm = RolePermission.objects.get(codename=codename)
except RolePermission.DoesNotExist:
continue
for role in OrganizationRole.objects.all():
role.permissions.remove(perm)
perm.delete()
class Migration(migrations.Migration):
dependencies = [
('rbac', '0003_notificaciones_receive'),
]
operations = [
migrations.RunPython(agregar_auditoria, reverse_code=revertir),
]

View File

109
api/rbac/models.py Normal file
View File

@@ -0,0 +1,109 @@
import uuid
from django.conf import settings
from django.db import models
class RolePermission(models.Model):
"""Catálogo global de permisos de la aplicación. Se define una vez y es compartido por todas las orgs."""
codename = models.CharField(max_length=100, unique=True)
descripcion = models.CharField(max_length=255)
modulo = models.CharField(max_length=50)
def __str__(self):
return self.codename
class Meta:
db_table = 'rbac_role_permission'
ordering = ['modulo', 'codename']
verbose_name = 'Permiso'
verbose_name_plural = 'Permisos'
class OrganizationRole(models.Model):
"""Rol de una organización. Cada org tiene su propio conjunto de roles con sus permisos."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
organizacion = models.ForeignKey(
'organization.Organizacion',
on_delete=models.CASCADE,
related_name='roles',
)
nombre = models.CharField(max_length=100)
descripcion = models.CharField(max_length=255, blank=True)
# El rol admin maestro no puede ser removido del owner de la org
is_admin_role = models.BooleanField(default=False)
permissions = models.ManyToManyField(
RolePermission,
blank=True,
related_name='roles',
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f'{self.nombre} ({self.organizacion})'
class Meta:
db_table = 'rbac_organization_role'
ordering = ['nombre']
verbose_name = 'Rol de Organización'
verbose_name_plural = 'Roles de Organización'
constraints = [
models.UniqueConstraint(fields=['organizacion', 'nombre'], name='unique_role_per_org'),
]
class UserRole(models.Model):
"""Asignación de un rol a un usuario dentro de su organización."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='user_roles',
)
role = models.ForeignKey(
OrganizationRole,
on_delete=models.CASCADE,
related_name='user_roles',
)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f'{self.user}{self.role.nombre}'
class Meta:
db_table = 'rbac_user_role'
verbose_name = 'Rol de Usuario'
verbose_name_plural = 'Roles de Usuario'
constraints = [
models.UniqueConstraint(fields=['user', 'role'], name='unique_user_role'),
]
class UserPermission(models.Model):
"""Permiso singular asignado directamente a un usuario, sin necesidad de rol.
granted=True otorga, granted=False deniega explícitamente (override sobre roles)."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='rbac_permissions',
)
permission = models.ForeignKey(
RolePermission,
on_delete=models.CASCADE,
related_name='user_overrides',
)
granted = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
estado = 'GRANT' if self.granted else 'DENY'
return f'{estado}: {self.user}{self.permission.codename}'
class Meta:
db_table = 'rbac_user_permission'
verbose_name = 'Permiso Singular'
verbose_name_plural = 'Permisos Singulares'
constraints = [
models.UniqueConstraint(fields=['user', 'permission'], name='unique_user_permission'),
]

176
api/rbac/roles.py Normal file
View File

@@ -0,0 +1,176 @@
# Catálogo de permisos y configuración de roles por defecto.
# Este módulo es importado tanto por la data migration como por el signal de Organizacion,
# por lo que NO debe importar modelos directamente al nivel de módulo.
# --- CATÁLOGO DE PERMISOS ---
# (codename, descripcion, modulo)
PERMISSIONS_CATALOG = [
# Usuarios
('usuarios.view', 'Ver usuarios de la organización', 'usuarios'),
('usuarios.create', 'Crear usuarios en la organización', 'usuarios'),
('usuarios.edit', 'Modificar usuarios de la organización', 'usuarios'),
('usuarios.delete', 'Eliminar usuarios de la organización', 'usuarios'),
('usuarios.manage_roles', 'Asignar y revocar roles a usuarios', 'usuarios'),
('usuarios.change_password', 'Cambiar contraseña de otro usuario', 'usuarios'),
# Pedimentos
('pedimentos.view', 'Ver pedimentos', 'pedimentos'),
('pedimentos.create', 'Crear e importar pedimentos', 'pedimentos'),
('pedimentos.edit', 'Modificar pedimentos', 'pedimentos'),
('pedimentos.delete', 'Eliminar pedimentos', 'pedimentos'),
('pedimentos.process', 'Procesar pedimentos contra VUCEM', 'pedimentos'),
# Importadores
('importadores.view', 'Ver importadores', 'importadores'),
('importadores.create', 'Crear importadores', 'importadores'),
('importadores.edit', 'Modificar importadores', 'importadores'),
('importadores.delete', 'Eliminar importadores', 'importadores'),
# Partidas
('partidas.view', 'Ver partidas', 'partidas'),
('partidas.create', 'Crear partidas', 'partidas'),
('partidas.edit', 'Modificar partidas', 'partidas'),
('partidas.delete', 'Eliminar partidas', 'partidas'),
# Remesas
('remesas.view', 'Ver remesas', 'remesas'),
# COVEs
('coves.view', 'Ver COVEs', 'coves'),
('coves.create', 'Crear COVEs', 'coves'),
('coves.edit', 'Modificar COVEs', 'coves'),
('coves.delete', 'Eliminar COVEs', 'coves'),
# E-Documents
('edocuments.view', 'Ver E-Documents', 'edocuments'),
('edocuments.create', 'Crear E-Documents', 'edocuments'),
('edocuments.edit', 'Modificar E-Documents', 'edocuments'),
('edocuments.delete', 'Eliminar E-Documents', 'edocuments'),
# Acuses
('acuses.view', 'Ver acuses', 'acuses'),
# Documentos (expediente)
('documentos.view', 'Ver documentos del expediente', 'documentos'),
('documentos.upload', 'Cargar documentos', 'documentos'),
('documentos.download', 'Descargar documentos y ZIPs', 'documentos'),
('documentos.delete', 'Eliminar documentos del expediente', 'documentos'),
# VUCEM
('vucem.view', 'Ver credenciales VUCEM', 'vucem'),
('vucem.manage', 'Gestionar credenciales VUCEM', 'vucem'),
# Reportes
('reportes.view', 'Ver reportes y dashboard', 'reportes'),
('reportes.export', 'Exportar reportes a CSV/Excel', 'reportes'),
# DataStage
('datastage.view', 'Ver DataStages', 'datastage'),
('datastage.create', 'Crear DataStages', 'datastage'),
('datastage.process', 'Procesar DataStages', 'datastage'),
('datastage.delete', 'Eliminar DataStages', 'datastage'),
# Organización
('organizacion.view', 'Ver datos de la organización', 'organizacion'),
('organizacion.edit', 'Modificar datos de la organización', 'organizacion'),
# Notificaciones
('notificaciones.view', 'Ver notificaciones propias', 'notificaciones'),
('notificaciones.receive', 'Recibir notificaciones automáticas de eventos', 'notificaciones'),
# Cards / Analytics
('cards.view', 'Ver dashboard y analytics', 'cards'),
# Auditoría
('auditoria.view', 'Ver estado y resultados de auditoría VUCEM', 'auditoria'),
('auditoria.process', 'Lanzar procesos de auditoría y reauditoría', 'auditoria'),
]
# Conjuntos reutilizables para armar la matriz de permisos por rol
_IMPORTADORES_FULL = ['importadores.view', 'importadores.create', 'importadores.edit', 'importadores.delete']
_PEDIMENTOS_FULL = ['pedimentos.view', 'pedimentos.create', 'pedimentos.edit', 'pedimentos.delete', 'pedimentos.process']
_PARTIDAS_FULL = ['partidas.view', 'partidas.create', 'partidas.edit', 'partidas.delete']
_COVES_FULL = ['coves.view', 'coves.create', 'coves.edit', 'coves.delete']
_EDOCUMENTS_FULL = ['edocuments.view', 'edocuments.create', 'edocuments.edit', 'edocuments.delete']
_DOCUMENTOS_FULL = ['documentos.view', 'documentos.upload', 'documentos.download', 'documentos.delete']
_VUCEM_FULL = ['vucem.view', 'vucem.manage']
_REPORTES_FULL = ['reportes.view', 'reportes.export']
_DATASTAGE_FULL = ['datastage.view', 'datastage.create', 'datastage.process']
# --- ROLES POR DEFECTO ---
# Cada entrada: nombre → { descripcion, is_admin_role, permissions }
DEFAULT_ROLES = {
'admin': {
'descripcion': 'Administrador de la organización',
'is_admin_role': True,
'permissions': [
'usuarios.view', 'usuarios.create', 'usuarios.edit', 'usuarios.delete',
'usuarios.manage_roles', 'usuarios.change_password',
*_PEDIMENTOS_FULL, *_PARTIDAS_FULL, 'remesas.view',
*_COVES_FULL, *_EDOCUMENTS_FULL, 'acuses.view',
*_DOCUMENTOS_FULL, *_VUCEM_FULL,
*_IMPORTADORES_FULL,
*_REPORTES_FULL, *_DATASTAGE_FULL,
'organizacion.view', 'organizacion.edit',
'notificaciones.view', 'notificaciones.receive', 'cards.view',
'auditoria.view', 'auditoria.process',
],
},
'developer': {
'descripcion': 'Desarrollador con acceso técnico avanzado',
'is_admin_role': False,
'permissions': [
'usuarios.view', 'usuarios.create',
*_PEDIMENTOS_FULL, *_PARTIDAS_FULL, 'remesas.view',
*_COVES_FULL, *_EDOCUMENTS_FULL, 'acuses.view',
*_DOCUMENTOS_FULL, *_VUCEM_FULL, *_IMPORTADORES_FULL,
*_REPORTES_FULL, *_DATASTAGE_FULL,
'organizacion.view',
'notificaciones.view', 'notificaciones.receive', 'cards.view',
'auditoria.view', 'auditoria.process',
],
},
'Agente Aduanal': {
'descripcion': 'Agente aduanal operativo',
'is_admin_role': False,
'permissions': [
*_PEDIMENTOS_FULL, *_PARTIDAS_FULL, 'remesas.view',
*_COVES_FULL, *_EDOCUMENTS_FULL, 'acuses.view',
*_DOCUMENTOS_FULL, *_VUCEM_FULL,
*_REPORTES_FULL,
'organizacion.view',
'notificaciones.view', 'notificaciones.receive', 'cards.view',
'auditoria.view',
],
},
'user': {
'descripcion': 'Usuario básico de la organización',
'is_admin_role': False,
'permissions': [
'pedimentos.view', 'pedimentos.process',
'partidas.view', 'remesas.view',
'coves.view', 'edocuments.view', 'acuses.view',
'documentos.view', 'documentos.upload', 'documentos.download',
'reportes.view', 'datastage.view',
'notificaciones.view', 'notificaciones.receive', 'cards.view',
],
},
'Importador': {
'descripcion': 'Importador con acceso filtrado por RFC',
'is_admin_role': False,
'permissions': [
'pedimentos.view', 'partidas.view', 'remesas.view',
'coves.view', 'edocuments.view', 'acuses.view',
'documentos.view', 'documentos.download',
'vucem.view', 'vucem.manage',
'reportes.view',
'notificaciones.view', 'notificaciones.receive', 'cards.view',
],
},
}
def crear_roles_para_organizacion(organizacion):
"""Crea los 5 roles por defecto para una organización, con sus permisos.
Usa get_or_create — seguro de ejecutar múltiples veces."""
from api.rbac.models import RolePermission, OrganizationRole
perms_map = {p.codename: p for p in RolePermission.objects.all()}
for nombre, config in DEFAULT_ROLES.items():
role, created = OrganizationRole.objects.get_or_create(
organizacion=organizacion,
nombre=nombre,
defaults={
'descripcion': config['descripcion'],
'is_admin_role': config.get('is_admin_role', False),
},
)
if created:
role_perms = [perms_map[c] for c in config['permissions'] if c in perms_map]
role.permissions.set(role_perms)

105
api/rbac/serializers.py Normal file
View File

@@ -0,0 +1,105 @@
from rest_framework import serializers
from api.rbac.models import OrganizationRole, RolePermission, UserPermission, UserRole
class RolePermissionSerializer(serializers.ModelSerializer):
class Meta:
model = RolePermission
fields = ['id', 'codename', 'descripcion', 'modulo']
class OrganizationRoleSerializer(serializers.ModelSerializer):
permissions = RolePermissionSerializer(many=True, read_only=True)
permission_ids = serializers.PrimaryKeyRelatedField(
queryset=RolePermission.objects.all(),
many=True,
write_only=True,
source='permissions',
required=False,
)
user_count = serializers.IntegerField(read_only=True)
class Meta:
model = OrganizationRole
fields = [
'id', 'nombre', 'descripcion', 'is_admin_role',
'permissions', 'permission_ids', 'user_count',
'created_at', 'updated_at',
]
read_only_fields = ['id', 'is_admin_role', 'created_at', 'updated_at']
class OrganizationRoleWriteSerializer(serializers.ModelSerializer):
"""Serializer para crear/editar roles — recibe lista de IDs de permisos."""
permission_ids = serializers.PrimaryKeyRelatedField(
queryset=RolePermission.objects.all(),
many=True,
source='permissions',
required=False,
)
class Meta:
model = OrganizationRole
fields = ['nombre', 'descripcion', 'permission_ids']
def create(self, validated_data):
perms = validated_data.pop('permissions', [])
role = OrganizationRole.objects.create(**validated_data)
role.permissions.set(perms)
return role
def update(self, instance, validated_data):
perms = validated_data.pop('permissions', None)
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
if perms is not None:
instance.permissions.set(perms)
return instance
class _UserMinimalSerializer(serializers.Serializer):
id = serializers.UUIDField()
username = serializers.CharField()
email = serializers.EmailField()
first_name = serializers.CharField()
last_name = serializers.CharField()
class _RoleMinimalSerializer(serializers.Serializer):
id = serializers.UUIDField()
nombre = serializers.CharField()
descripcion = serializers.CharField()
class UserRoleSerializer(serializers.ModelSerializer):
user = _UserMinimalSerializer(read_only=True)
role = _RoleMinimalSerializer(read_only=True)
# write
user_id = serializers.UUIDField(write_only=True, source='user')
role_id = serializers.UUIDField(write_only=True, source='role')
class Meta:
model = UserRole
fields = ['id', 'user', 'user_id', 'role', 'role_id', 'created_at']
read_only_fields = ['id', 'created_at']
class UserPermissionSerializer(serializers.ModelSerializer):
user = _UserMinimalSerializer(read_only=True)
permission = RolePermissionSerializer(read_only=True)
# write
user_id = serializers.UUIDField(write_only=True, source='user')
permission_id = serializers.IntegerField(write_only=True, source='permission')
class Meta:
model = UserPermission
fields = ['id', 'user', 'user_id', 'permission', 'permission_id', 'granted', 'created_at']
read_only_fields = ['id', 'created_at']
class MyPermissionsSerializer(serializers.Serializer):
"""Respuesta de /rbac/my-permissions/ — permisos efectivos del usuario autenticado."""
permissions = serializers.ListField(child=serializers.CharField())
roles = serializers.ListField(child=serializers.CharField())

23
api/rbac/urls.py Normal file
View File

@@ -0,0 +1,23 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from api.rbac.views import (
MyPermissionsView,
OrganizationRoleViewSet,
RolePermissionViewSet,
SwitchOrganizationView,
UserPermissionViewSet,
UserRoleViewSet,
)
router = DefaultRouter()
router.register(r'permissions', RolePermissionViewSet, basename='rbac-permission')
router.register(r'roles', OrganizationRoleViewSet, basename='rbac-role')
router.register(r'user-roles', UserRoleViewSet, basename='rbac-user-role')
router.register(r'user-permissions', UserPermissionViewSet, basename='rbac-user-permission')
urlpatterns = [
path('', include(router.urls)),
path('my-permissions/', MyPermissionsView.as_view(), name='rbac-my-permissions'),
path('switch-organization/', SwitchOrganizationView.as_view(), name='rbac-switch-org'),
]

412
api/rbac/views.py Normal file
View File

@@ -0,0 +1,412 @@
from django.db.models import Count
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
from api.rbac.models import OrganizationRole, RolePermission, UserPermission, UserRole
from api.rbac.serializers import (
MyPermissionsSerializer,
OrganizationRoleSerializer,
OrganizationRoleWriteSerializer,
RolePermissionSerializer,
UserPermissionSerializer,
UserRoleSerializer,
)
from core.permissions import OrgScopedPermission, get_org_context, is_internal_service_request, require_permission, user_has_permission
def _require_manage_roles(user):
"""Retorna True si el usuario puede gestionar roles/permisos en su org."""
return user.is_superuser or user_has_permission(user, 'usuarios.manage_roles')
# ---------------------------------------------------------------------------
# Catálogo de permisos (lectura para todos los autenticados con org)
# ---------------------------------------------------------------------------
class RolePermissionViewSet(ReadOnlyModelViewSet):
"""Lista el catálogo global de permisos disponibles, agrupados por módulo."""
my_tags = ['RBAC']
serializer_class = RolePermissionSerializer
permission_classes = [IsAuthenticated, require_permission('usuarios.manage_roles')]
def get_queryset(self):
return RolePermission.objects.all().order_by('modulo', 'codename')
@action(detail=False, methods=['get'], url_path='by-module')
def by_module(self, request):
"""Devuelve el catálogo agrupado por módulo."""
perms = self.get_queryset()
result = {}
for p in perms:
result.setdefault(p.modulo, []).append(
RolePermissionSerializer(p).data
)
return Response(result)
# ---------------------------------------------------------------------------
# Roles de la organización
# ---------------------------------------------------------------------------
class OrganizationRoleViewSet(ModelViewSet):
"""
CRUD de roles de la organización activa.
Solo usuarios con usuarios.manage_roles pueden crear/editar/eliminar.
"""
my_tags = ['RBAC']
permission_classes = [IsAuthenticated, require_permission('usuarios.manage_roles')]
def get_queryset(self):
if is_internal_service_request(self.request):
return (
OrganizationRole.objects
.annotate(user_count=Count('user_roles'))
.prefetch_related('permissions')
.order_by('nombre')
)
org = get_org_context(self.request.user)
if not org:
return OrganizationRole.objects.none()
return (
OrganizationRole.objects
.filter(organizacion=org)
.annotate(user_count=Count('user_roles'))
.prefetch_related('permissions')
.order_by('nombre')
)
def get_serializer_class(self):
if self.action in ('create', 'update', 'partial_update'):
return OrganizationRoleWriteSerializer
return OrganizationRoleSerializer
def _check_manage_roles(self):
if not _require_manage_roles(self.request.user):
return Response(
{'detail': 'Se requiere el permiso usuarios.manage_roles.'},
status=status.HTTP_403_FORBIDDEN,
)
return None
def create(self, request, *args, **kwargs):
err = self._check_manage_roles()
if err:
return err
org = get_org_context(request.user)
if not org:
return Response({'detail': 'Sin organización activa.'}, status=status.HTTP_403_FORBIDDEN)
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save(organizacion=org)
return Response(
OrganizationRoleSerializer(serializer.instance).data,
status=status.HTTP_201_CREATED,
)
def update(self, request, *args, **kwargs):
err = self._check_manage_roles()
if err:
return err
instance = self.get_object()
# No se puede cambiar nombre ni permisos de un rol is_admin_role
if instance.is_admin_role and not request.user.is_superuser:
return Response(
{'detail': 'No se puede modificar un rol de administrador.'},
status=status.HTTP_403_FORBIDDEN,
)
return super().update(request, *args, **kwargs)
def destroy(self, request, *args, **kwargs):
err = self._check_manage_roles()
if err:
return err
instance = self.get_object()
if instance.is_admin_role and not request.user.is_superuser:
return Response(
{'detail': 'No se puede eliminar un rol de administrador.'},
status=status.HTTP_403_FORBIDDEN,
)
if instance.user_roles.exists():
return Response(
{'detail': 'No se puede eliminar un rol con usuarios asignados.'},
status=status.HTTP_400_BAD_REQUEST,
)
return super().destroy(request, *args, **kwargs)
# ---------------------------------------------------------------------------
# Asignación de roles a usuarios
# ---------------------------------------------------------------------------
class UserRoleViewSet(ModelViewSet):
"""
Asigna y revoca roles de usuarios en la organización activa.
Solo usuarios con usuarios.manage_roles pueden modificar.
"""
my_tags = ['RBAC']
serializer_class = UserRoleSerializer
permission_classes = [IsAuthenticated, require_permission('usuarios.manage_roles')]
http_method_names = ['get', 'post', 'delete', 'head', 'options']
def get_queryset(self):
if is_internal_service_request(self.request):
qs = UserRole.objects.select_related('user', 'role')
user_id = self.request.query_params.get('user_id')
if user_id:
qs = qs.filter(user_id=user_id)
return qs
org = get_org_context(self.request.user)
if not org:
return UserRole.objects.none()
qs = (
UserRole.objects
.filter(role__organizacion=org)
.select_related('user', 'role')
)
user_id = self.request.query_params.get('user_id')
if user_id:
qs = qs.filter(user_id=user_id)
return qs
def create(self, request, *args, **kwargs):
if not _require_manage_roles(request.user):
return Response(
{'detail': 'Se requiere el permiso usuarios.manage_roles.'},
status=status.HTTP_403_FORBIDDEN,
)
org = get_org_context(request.user)
if not org:
return Response({'detail': 'Sin organización activa.'}, status=status.HTTP_403_FORBIDDEN)
user_id = request.data.get('user_id')
role_id = request.data.get('role_id')
if not user_id or not role_id:
return Response({'detail': 'user_id y role_id son requeridos.'}, status=status.HTTP_400_BAD_REQUEST)
# Verificar que el rol pertenece a la misma org
try:
role = OrganizationRole.objects.get(id=role_id, organizacion=org)
except OrganizationRole.DoesNotExist:
return Response({'detail': 'El rol no pertenece a esta organización.'}, status=status.HTTP_404_NOT_FOUND)
# Verificar que el usuario pertenece a la misma org
from api.cuser.models import CustomUser
try:
target_user = CustomUser.objects.get(id=user_id, organizacion=org)
except CustomUser.DoesNotExist:
return Response({'detail': 'El usuario no pertenece a esta organización.'}, status=status.HTTP_404_NOT_FOUND)
user_role, created = UserRole.objects.get_or_create(user=target_user, role=role)
serializer = self.get_serializer(user_role)
return Response(serializer.data, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
def destroy(self, request, *args, **kwargs):
if not _require_manage_roles(request.user):
return Response(
{'detail': 'Se requiere el permiso usuarios.manage_roles.'},
status=status.HTTP_403_FORBIDDEN,
)
instance = self.get_object()
org = get_org_context(request.user)
# Proteger al owner de la org: no se le puede quitar el rol admin
if org and hasattr(org, 'owner') and org.owner and instance.user == org.owner:
if instance.role.is_admin_role:
return Response(
{'detail': 'No se puede revocar el rol de administrador al propietario de la organización.'},
status=status.HTTP_403_FORBIDDEN,
)
return super().destroy(request, *args, **kwargs)
# ---------------------------------------------------------------------------
# Permisos singulares (overrides por usuario)
# ---------------------------------------------------------------------------
class UserPermissionViewSet(ModelViewSet):
"""
Otorga o deniega permisos singulares a usuarios, sin necesidad de crear un rol.
granted=true → otorgar; granted=false → denegar explícitamente (override sobre roles).
Solo usuarios con usuarios.manage_roles pueden modificar.
"""
my_tags = ['RBAC']
serializer_class = UserPermissionSerializer
permission_classes = [IsAuthenticated, require_permission('usuarios.manage_roles')]
http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options']
def get_queryset(self):
if is_internal_service_request(self.request):
qs = UserPermission.objects.select_related('user', 'permission')
user_id = self.request.query_params.get('user_id')
if user_id:
qs = qs.filter(user_id=user_id)
return qs
org = get_org_context(self.request.user)
if not org:
return UserPermission.objects.none()
qs = (
UserPermission.objects
.filter(user__organizacion=org)
.select_related('user', 'permission')
)
user_id = self.request.query_params.get('user_id')
if user_id:
qs = qs.filter(user_id=user_id)
return qs
def _check(self):
if not _require_manage_roles(self.request.user):
return Response(
{'detail': 'Se requiere el permiso usuarios.manage_roles.'},
status=status.HTTP_403_FORBIDDEN,
)
return None
def create(self, request, *args, **kwargs):
err = self._check()
if err:
return err
org = get_org_context(request.user)
if not org:
return Response({'detail': 'Sin organización activa.'}, status=status.HTTP_403_FORBIDDEN)
user_id = request.data.get('user_id')
permission_id = request.data.get('permission_id')
granted = request.data.get('granted', True)
if not user_id or not permission_id:
return Response({'detail': 'user_id y permission_id son requeridos.'}, status=status.HTTP_400_BAD_REQUEST)
from api.cuser.models import CustomUser
try:
target_user = CustomUser.objects.get(id=user_id, organizacion=org)
except CustomUser.DoesNotExist:
return Response({'detail': 'El usuario no pertenece a esta organización.'}, status=status.HTTP_404_NOT_FOUND)
try:
perm = RolePermission.objects.get(id=permission_id)
except RolePermission.DoesNotExist:
return Response({'detail': 'Permiso no encontrado.'}, status=status.HTTP_404_NOT_FOUND)
override, created = UserPermission.objects.update_or_create(
user=target_user,
permission=perm,
defaults={'granted': granted},
)
serializer = self.get_serializer(override)
return Response(serializer.data, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
def partial_update(self, request, *args, **kwargs):
err = self._check()
if err:
return err
return super().partial_update(request, *args, **kwargs)
def destroy(self, request, *args, **kwargs):
err = self._check()
if err:
return err
return super().destroy(request, *args, **kwargs)
# ---------------------------------------------------------------------------
# Mis permisos efectivos (para el frontend)
# ---------------------------------------------------------------------------
class MyPermissionsView(APIView):
"""
Retorna los permisos efectivos del usuario autenticado.
El frontend usa esto para decidir qué mostrar/ocultar.
"""
my_tags = ['RBAC']
permission_classes = [IsAuthenticated & OrgScopedPermission]
def get(self, request):
user = request.user
org = get_org_context(user)
if user.is_superuser:
all_perms = list(RolePermission.objects.values_list('codename', flat=True))
return Response({'permissions': all_perms, 'roles': ['superuser']})
if not org:
return Response({'permissions': [], 'roles': []})
# Roles del usuario en la org
roles = list(
UserRole.objects.filter(user=user, role__organizacion=org)
.values_list('role__nombre', flat=True)
)
# Permisos de roles
perms_set = set(
UserRole.objects.filter(user=user, role__organizacion=org)
.values_list('role__permissions__codename', flat=True)
)
perms_set.discard(None)
# Aplicar overrides singulares
for override in UserPermission.objects.filter(user=user).select_related('permission'):
if override.granted:
perms_set.add(override.permission.codename)
else:
perms_set.discard(override.permission.codename)
return Response({'permissions': sorted(perms_set), 'roles': roles})
# ---------------------------------------------------------------------------
# Switch de organización (solo superusuarios)
# ---------------------------------------------------------------------------
class SwitchOrganizationView(APIView):
"""
Permite a un superusuario cambiar su organización activa.
POST { "organization_id": "<uuid>" } → actualiza active_organization del superuser.
DELETE → limpia active_organization (el superuser queda sin contexto de org).
"""
my_tags = ['RBAC']
permission_classes = [IsAuthenticated]
def post(self, request):
if not request.user.is_superuser:
return Response(
{'detail': 'Solo superusuarios pueden cambiar de organización.'},
status=status.HTTP_403_FORBIDDEN,
)
org_id = request.data.get('organization_id')
if not org_id:
return Response({'detail': 'organization_id es requerido.'}, status=status.HTTP_400_BAD_REQUEST)
from api.organization.models import Organizacion
try:
import uuid as _uuid
org = Organizacion.objects.get(id=_uuid.UUID(str(org_id)))
except (Organizacion.DoesNotExist, ValueError):
return Response({'detail': 'Organización no encontrada.'}, status=status.HTTP_404_NOT_FOUND)
request.user.active_organization = org
request.user.save(update_fields=['active_organization'])
return Response({
'detail': f'Organización activa actualizada a: {org.nombre}',
'organization': {'id': str(org.id), 'nombre': org.nombre},
})
def delete(self, request):
if not request.user.is_superuser:
return Response(
{'detail': 'Solo superusuarios pueden limpiar la organización activa.'},
status=status.HTTP_403_FORBIDDEN,
)
request.user.active_organization = None
request.user.save(update_fields=['active_organization'])
return Response({'detail': 'Organización activa removida.'})