Skip to content

Commit 6dcc2e5

Browse files
committed
feat(permissions): implementar sistema de permisos granular sin roles jerarquicos (TDD)
Implementacion completa del sistema de permisos granular basado en capacidades en lugar de roles jerarquicos (Admin/Supervisor/Agent). Desarrollo guiado por TDD con cobertura completa de tests. PRIORIDAD 1 - Base de Datos (8 tablas): - Funcion: Recursos del sistema (dashboards, usuarios, llamadas, etc) - Capacidad: Acciones atomicas (formato: sistema.dominio.recurso.accion) - FuncionCapacidad: Relacion N:M funcion-capacidad - GrupoPermisos: Grupos funcionales combinables (NO jerarquicos) - GrupoCapacidad: Relacion N:M grupo-capacidad - UsuarioGrupo: Asignacion usuario a multiples grupos (temporal opcional) - PermisoExcepcional: Concesion/revocacion temporal de capacidades - AuditoriaPermiso: Log completo de accesos a recursos protegidos PRIORIDAD 2 - API Layer: - Serializers para todos los modelos con validaciones - ViewSets RESTful con filtros, busqueda y ordenamiento - Endpoints personalizados: mis-capacidades, mis-funciones, verificar-permiso - Integracion con django-filters y DRF - URLs configuradas en /api/v1/permissions/ Servicios y Middleware: - PermisoService: Logica de verificacion de permisos * usuario_tiene_permiso() con soporte de excepciones temporales * obtener_capacidades_usuario() con permisos combinados * obtener_funciones_accesibles() para menu dinamico * registrar_acceso() para auditoria - Middleware verificar_permiso: Decorator para proteger endpoints - Soporte de permisos multiples y auditoria configurable Migraciones y Seed Data: - Migracion inicial completa con indices optimizados - Management command seed_permissions para datos iniciales - 11 funciones predefinidas (operaciones, finanzas, administracion, etc) - 27 capacidades con niveles de sensibilidad (bajo, normal, alto, critico) - 8 grupos funcionales predefinidos (atencion_cliente, gestion_equipos, etc) Documentacion Completa: - ADR-012: Decision arquitectonica (NO roles jerarquicos) - Arquitectura detallada con diagramas ASCII (componentes, ER, flujos) - Documentacion API completa con ejemplos de uso - Guias operativas para scripts criticos de validacion - Tablas SQL con constraints y indices Tests (TDD): - test_models.py: 20+ tests para modelos y relaciones - test_services.py: 25+ tests para logica de negocio - test_middleware.py: 15+ tests para proteccion de endpoints - test_serializers.py: 12+ tests para API serialization - test_views.py: 15+ tests para API endpoints - Cobertura completa de casos: permisos validos, invalidos, expirados, etc Validaciones: - NO emojis/iconos (verificado con check_no_emojis.py) - Codigo completo sin placeholders TODO/FIXME - Referencias a ADR-012 en todos los archivos relevantes - Documentacion actualizada y mapeada en docs/ Cambios en configuracion: - Agregado 'callcentersite.apps.permissions' a INSTALLED_APPS - Agregado ruta API en urls.py: /api/v1/permissions/ REF: ADR-012-sistema-permisos-sin-roles-jerarquicos.md SPEC: Sistema de Permisos Granular (Prioridad 1-2)
1 parent 3d5c754 commit 6dcc2e5

28 files changed

Lines changed: 7641 additions & 0 deletions

api/callcentersite/callcentersite/apps/permissions/__init__.py

Whitespace-only changes.
Lines changed: 364 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,364 @@
1+
"""
2+
Admin para Sistema de Permisos Granular.
3+
4+
Registra todos los modelos en Django Admin para gestion.
5+
"""
6+
7+
from django.contrib import admin
8+
from django.utils.html import format_html
9+
10+
from .models import (
11+
Funcion,
12+
Capacidad,
13+
FuncionCapacidad,
14+
GrupoPermisos,
15+
GrupoCapacidad,
16+
UsuarioGrupo,
17+
PermisoExcepcional,
18+
AuditoriaPermiso
19+
)
20+
21+
22+
@admin.register(Funcion)
23+
class FuncionAdmin(admin.ModelAdmin):
24+
"""Admin para Funcion."""
25+
26+
list_display = [
27+
'nombre',
28+
'nombre_completo',
29+
'dominio',
30+
'categoria',
31+
'orden_menu',
32+
'activa_badge',
33+
'created_at'
34+
]
35+
list_filter = ['dominio', 'categoria', 'activa']
36+
search_fields = ['nombre', 'nombre_completo', 'descripcion']
37+
readonly_fields = ['created_at', 'updated_at']
38+
ordering = ['orden_menu', 'nombre']
39+
40+
fieldsets = [
41+
('Informacion Basica', {
42+
'fields': ('nombre', 'nombre_completo', 'descripcion')
43+
}),
44+
('Clasificacion', {
45+
'fields': ('dominio', 'categoria', 'icono')
46+
}),
47+
('Configuracion', {
48+
'fields': ('orden_menu', 'activa')
49+
}),
50+
('Metadata', {
51+
'fields': ('created_at', 'updated_at'),
52+
'classes': ('collapse',)
53+
}),
54+
]
55+
56+
def activa_badge(self, obj):
57+
"""Badge para campo activa."""
58+
if obj.activa:
59+
return format_html('<span style="color: green;">ACTIVA</span>')
60+
return format_html('<span style="color: red;">INACTIVA</span>')
61+
activa_badge.short_description = 'Estado'
62+
63+
64+
@admin.register(Capacidad)
65+
class CapacidadAdmin(admin.ModelAdmin):
66+
"""Admin para Capacidad."""
67+
68+
list_display = [
69+
'nombre_completo',
70+
'accion',
71+
'recurso',
72+
'dominio',
73+
'nivel_sensibilidad_badge',
74+
'requiere_auditoria_badge',
75+
'activa_badge'
76+
]
77+
list_filter = ['dominio', 'nivel_sensibilidad', 'requiere_auditoria', 'activa']
78+
search_fields = ['nombre_completo', 'descripcion', 'accion', 'recurso']
79+
readonly_fields = ['created_at']
80+
ordering = ['nombre_completo']
81+
82+
fieldsets = [
83+
('Identificacion', {
84+
'fields': ('nombre_completo', 'descripcion')
85+
}),
86+
('Componentes', {
87+
'fields': ('accion', 'recurso', 'dominio')
88+
}),
89+
('Seguridad', {
90+
'fields': ('nivel_sensibilidad', 'requiere_auditoria')
91+
}),
92+
('Estado', {
93+
'fields': ('activa', 'created_at')
94+
}),
95+
]
96+
97+
def nivel_sensibilidad_badge(self, obj):
98+
"""Badge para nivel de sensibilidad."""
99+
colors = {
100+
'bajo': 'green',
101+
'normal': 'blue',
102+
'alto': 'orange',
103+
'critico': 'red'
104+
}
105+
color = colors.get(obj.nivel_sensibilidad, 'gray')
106+
return format_html(
107+
'<span style="color: {}; font-weight: bold;">{}</span>',
108+
color,
109+
obj.get_nivel_sensibilidad_display()
110+
)
111+
nivel_sensibilidad_badge.short_description = 'Sensibilidad'
112+
113+
def requiere_auditoria_badge(self, obj):
114+
"""Badge para requiere_auditoria."""
115+
if obj.requiere_auditoria:
116+
return format_html('<span style="color: red;">SI</span>')
117+
return format_html('<span style="color: gray;">NO</span>')
118+
requiere_auditoria_badge.short_description = 'Auditoria'
119+
120+
def activa_badge(self, obj):
121+
"""Badge para activa."""
122+
if obj.activa:
123+
return format_html('<span style="color: green;">SI</span>')
124+
return format_html('<span style="color: gray;">NO</span>')
125+
activa_badge.short_description = 'Activa'
126+
127+
128+
@admin.register(FuncionCapacidad)
129+
class FuncionCapacidadAdmin(admin.ModelAdmin):
130+
"""Admin para FuncionCapacidad."""
131+
132+
list_display = [
133+
'funcion',
134+
'capacidad',
135+
'requerida_badge',
136+
'visible_en_ui_badge',
137+
'created_at'
138+
]
139+
list_filter = ['requerida', 'visible_en_ui']
140+
search_fields = ['funcion__nombre', 'capacidad__nombre_completo']
141+
autocomplete_fields = ['funcion', 'capacidad']
142+
readonly_fields = ['created_at']
143+
144+
def requerida_badge(self, obj):
145+
"""Badge para requerida."""
146+
if obj.requerida:
147+
return format_html('<span style="color: red;">OBLIGATORIA</span>')
148+
return format_html('<span style="color: gray;">OPCIONAL</span>')
149+
requerida_badge.short_description = 'Requerida'
150+
151+
def visible_en_ui_badge(self, obj):
152+
"""Badge para visible_en_ui."""
153+
if obj.visible_en_ui:
154+
return format_html('<span style="color: green;">SI</span>')
155+
return format_html('<span style="color: gray;">NO</span>')
156+
visible_en_ui_badge.short_description = 'Visible UI'
157+
158+
159+
@admin.register(GrupoPermisos)
160+
class GrupoPermisosAdmin(admin.ModelAdmin):
161+
"""Admin para GrupoPermisos."""
162+
163+
list_display = [
164+
'nombre_display',
165+
'codigo',
166+
'tipo_acceso',
167+
'num_capacidades',
168+
'num_usuarios',
169+
'activo_badge',
170+
'created_at'
171+
]
172+
list_filter = ['tipo_acceso', 'activo']
173+
search_fields = ['codigo', 'nombre_display', 'descripcion']
174+
readonly_fields = ['created_at', 'updated_at']
175+
ordering = ['nombre_display']
176+
177+
fieldsets = [
178+
('Informacion Basica', {
179+
'fields': ('codigo', 'nombre_display', 'descripcion')
180+
}),
181+
('Clasificacion', {
182+
'fields': ('tipo_acceso',)
183+
}),
184+
('Estado', {
185+
'fields': ('activo', 'created_at', 'updated_at')
186+
}),
187+
]
188+
189+
def num_capacidades(self, obj):
190+
"""Numero de capacidades del grupo."""
191+
return obj.capacidades.count()
192+
num_capacidades.short_description = 'Capacidades'
193+
194+
def num_usuarios(self, obj):
195+
"""Numero de usuarios asignados al grupo."""
196+
return obj.usuarios.filter(activo=True).count()
197+
num_usuarios.short_description = 'Usuarios'
198+
199+
def activo_badge(self, obj):
200+
"""Badge para activo."""
201+
if obj.activo:
202+
return format_html('<span style="color: green;">ACTIVO</span>')
203+
return format_html('<span style="color: red;">INACTIVO</span>')
204+
activo_badge.short_description = 'Estado'
205+
206+
207+
@admin.register(GrupoCapacidad)
208+
class GrupoCapacidadAdmin(admin.ModelAdmin):
209+
"""Admin para GrupoCapacidad."""
210+
211+
list_display = ['grupo', 'capacidad', 'created_at']
212+
list_filter = ['grupo']
213+
search_fields = ['grupo__codigo', 'capacidad__nombre_completo']
214+
autocomplete_fields = ['grupo', 'capacidad']
215+
readonly_fields = ['created_at']
216+
217+
218+
@admin.register(UsuarioGrupo)
219+
class UsuarioGrupoAdmin(admin.ModelAdmin):
220+
"""Admin para UsuarioGrupo."""
221+
222+
list_display = [
223+
'usuario',
224+
'grupo',
225+
'fecha_asignacion',
226+
'expiracion_badge',
227+
'asignado_por',
228+
'activo_badge'
229+
]
230+
list_filter = ['activo', 'grupo']
231+
search_fields = ['usuario__username', 'grupo__codigo']
232+
autocomplete_fields = ['usuario', 'grupo', 'asignado_por']
233+
readonly_fields = ['fecha_asignacion']
234+
date_hierarchy = 'fecha_asignacion'
235+
236+
fieldsets = [
237+
('Asignacion', {
238+
'fields': ('usuario', 'grupo')
239+
}),
240+
('Fechas', {
241+
'fields': ('fecha_asignacion', 'fecha_expiracion')
242+
}),
243+
('Auditoria', {
244+
'fields': ('asignado_por', 'activo')
245+
}),
246+
]
247+
248+
def expiracion_badge(self, obj):
249+
"""Badge para fecha_expiracion."""
250+
if obj.fecha_expiracion is None:
251+
return format_html('<span style="color: blue;">PERMANENTE</span>')
252+
if obj.is_expired():
253+
return format_html(
254+
'<span style="color: red;">EXPIRADO: {}</span>',
255+
obj.fecha_expiracion.strftime('%Y-%m-%d')
256+
)
257+
return format_html(
258+
'<span style="color: green;">Expira: {}</span>',
259+
obj.fecha_expiracion.strftime('%Y-%m-%d')
260+
)
261+
expiracion_badge.short_description = 'Expiracion'
262+
263+
def activo_badge(self, obj):
264+
"""Badge para activo."""
265+
if obj.activo and not obj.is_expired():
266+
return format_html('<span style="color: green;">ACTIVO</span>')
267+
return format_html('<span style="color: red;">INACTIVO</span>')
268+
activo_badge.short_description = 'Estado'
269+
270+
271+
@admin.register(PermisoExcepcional)
272+
class PermisoExcepcionalAdmin(admin.ModelAdmin):
273+
"""Admin para PermisoExcepcional."""
274+
275+
list_display = [
276+
'usuario',
277+
'capacidad',
278+
'tipo_badge',
279+
'fecha_inicio',
280+
'fecha_fin',
281+
'autorizado_por',
282+
'estado_badge'
283+
]
284+
list_filter = ['tipo', 'activo']
285+
search_fields = ['usuario__username', 'capacidad__nombre_completo', 'motivo']
286+
autocomplete_fields = ['usuario', 'capacidad', 'autorizado_por']
287+
readonly_fields = ['created_at']
288+
date_hierarchy = 'fecha_inicio'
289+
290+
fieldsets = [
291+
('Permiso', {
292+
'fields': ('usuario', 'capacidad', 'tipo')
293+
}),
294+
('Periodo', {
295+
'fields': ('fecha_inicio', 'fecha_fin')
296+
}),
297+
('Justificacion', {
298+
'fields': ('motivo', 'autorizado_por')
299+
}),
300+
('Estado', {
301+
'fields': ('activo', 'created_at')
302+
}),
303+
]
304+
305+
def tipo_badge(self, obj):
306+
"""Badge para tipo."""
307+
if obj.tipo == 'conceder':
308+
return format_html('<span style="color: green;">CONCEDER</span>')
309+
return format_html('<span style="color: red;">REVOCAR</span>')
310+
tipo_badge.short_description = 'Tipo'
311+
312+
def estado_badge(self, obj):
313+
"""Badge para estado actual."""
314+
if obj.is_active_now():
315+
return format_html('<span style="color: green;">ACTIVO AHORA</span>')
316+
return format_html('<span style="color: gray;">INACTIVO</span>')
317+
estado_badge.short_description = 'Estado'
318+
319+
320+
@admin.register(AuditoriaPermiso)
321+
class AuditoriaPermisoAdmin(admin.ModelAdmin):
322+
"""Admin para AuditoriaPermiso."""
323+
324+
list_display = [
325+
'timestamp',
326+
'usuario',
327+
'capacidad',
328+
'accion_badge',
329+
'recurso_accedido',
330+
'ip_address'
331+
]
332+
list_filter = ['accion_realizada', 'timestamp']
333+
search_fields = ['usuario__username', 'capacidad', 'recurso_accedido', 'ip_address']
334+
readonly_fields = ['timestamp', 'metadata']
335+
date_hierarchy = 'timestamp'
336+
ordering = ['-timestamp']
337+
338+
fieldsets = [
339+
('Acceso', {
340+
'fields': ('usuario', 'capacidad', 'accion_realizada', 'recurso_accedido')
341+
}),
342+
('Contexto', {
343+
'fields': ('ip_address', 'user_agent')
344+
}),
345+
('Metadata', {
346+
'fields': ('metadata', 'timestamp'),
347+
'classes': ('collapse',)
348+
}),
349+
]
350+
351+
def has_add_permission(self, request):
352+
"""No permitir agregar registros de auditoria manualmente."""
353+
return False
354+
355+
def has_delete_permission(self, request, obj=None):
356+
"""No permitir eliminar registros de auditoria."""
357+
return False
358+
359+
def accion_badge(self, obj):
360+
"""Badge para accion_realizada."""
361+
if obj.accion_realizada == 'acceso_concedido':
362+
return format_html('<span style="color: green;">CONCEDIDO</span>')
363+
return format_html('<span style="color: red;">DENEGADO</span>')
364+
accion_badge.short_description = 'Accion'
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""
2+
Configuracion de la app permissions.
3+
4+
Sistema de Permisos Granular - Prioridad 1
5+
"""
6+
7+
from django.apps import AppConfig
8+
9+
10+
class PermissionsConfig(AppConfig):
11+
"""Configuracion de la app permissions."""
12+
13+
default_auto_field = 'django.db.models.BigAutoField'
14+
name = 'callcentersite.apps.permissions'
15+
verbose_name = 'Sistema de Permisos Granular'
16+
17+
def ready(self):
18+
"""
19+
Ejecutado cuando la app esta lista.
20+
21+
Aqui se pueden registrar signals, cargar datos iniciales, etc.
22+
"""
23+
# Importar signals si existen
24+
# from . import signals # noqa: F401
25+
pass

api/callcentersite/callcentersite/apps/permissions/management/__init__.py

Whitespace-only changes.

api/callcentersite/callcentersite/apps/permissions/management/commands/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)