feat: Version 1.11.0 - Mejoras en auditoría, SLA, frontend y correcciones de sincronización

- Refactorización de endpoints de auditoría y helpers
- Mejoras en esquemas de auditoría (audit.py)
- Correcciones en endpoint SLA
- Actualizaciones en múltiples rutas del frontend interno:
  layout, tickets, usuarios, tenants, categorías, sistemas,
  SLA (at-risk, violations), auditoría (main + security), login, perfil
- Actualización de tailwind.config.js
- Eliminación de docs de versiones anteriores (CAMBIOS_v1.10.0, v1.8.0, OPTIMIZACIONES)
- Nuevos scripts de prueba: generate_security_test_data.py, generate_sla_test_data.py
- Script de prueba de sincronización crítica (test_critical_sync.ps1)
- README actualizado en scripts/
This commit is contained in:
2026-02-20 10:53:53 -07:00
parent 517297e89a
commit ceea67eb2b
32 changed files with 5715 additions and 3324 deletions

View File

@@ -152,38 +152,47 @@ async def get_security_analysis(all_tenants: bool = Query(False), current_user:
threat_patterns = []
if failed_logins >= 5:
affected_ips_list = [str(log.ip_address) for log in logs if log.action == 'user.login_failed' and log.ip_address]
threat_patterns.append(SecurityThreatPattern(
pattern_id="brute_force_attempt",
id="brute_force_attempt",
type="brute_force",
description=f"Se detectaron {failed_logins} intentos fallidos de login en las últimas 24h",
severity="high" if failed_logins >= 20 else "medium",
occurrences=failed_logins,
first_seen=min((log.created_at for log in logs if log.action == 'user.login_failed'), default=now),
last_seen=max((log.created_at for log in logs if log.action == 'user.login_failed'), default=now),
affected_resources=[str(log.ip_address) for log in logs if log.action == 'user.login_failed' and log.ip_address][:5],
affected_ips=list(set(affected_ips_list))[:5],
affected_users=[],
recommended_action="Considerar bloquear IPs con múltiples fallos"
))
if mass_deletions >= 10:
deleting_users = [log.user.email for log in logs if '.delete' in log.action and log.user]
threat_patterns.append(SecurityThreatPattern(
pattern_id="mass_deletion",
id="mass_deletion",
type="mass_deletion",
description=f"Se detectaron {mass_deletions} eliminaciones en las últimas 24h",
severity="critical" if mass_deletions >= 50 else "high",
occurrences=mass_deletions,
first_seen=min((log.created_at for log in logs if '.delete' in log.action), default=now),
last_seen=max((log.created_at for log in logs if '.delete' in log.action), default=now),
affected_resources=[log.resource_type for log in logs if '.delete' in log.action][:5],
affected_ips=[],
affected_users=list(set(deleting_users))[:5],
recommended_action="Revisar qué usuarios están eliminando recursos"
))
if privilege_changes >= 3:
affected_users_list = [log.user.email for log in logs if log.action == 'user.update' and log.user and log.new_values and 'role' in log.new_values]
threat_patterns.append(SecurityThreatPattern(
pattern_id="suspicious_privilege_changes",
id="suspicious_privilege_changes",
type="privilege_escalation",
description=f"Se detectaron {privilege_changes} cambios de privilegios en las últimas 24h",
severity="high",
occurrences=privilege_changes,
first_seen=min((log.created_at for log in logs if log.action == 'user.update' and log.new_values and 'role' in log.new_values), default=now),
last_seen=max((log.created_at for log in logs if log.action == 'user.update' and log.new_values and 'role' in log.new_values), default=now),
affected_resources=[log.user.email for log in logs if log.action == 'user.update' and log.user and log.new_values and 'role' in log.new_values][:5],
affected_ips=[],
affected_users=list(set(affected_users_list))[:5],
recommended_action="Auditar cambios de roles recientes"
))

File diff suppressed because it is too large Load Diff

View File

@@ -91,7 +91,7 @@ async def get_sla_dashboard(
days=days
)
now = datetime.now(timezone.utc)
now = datetime.now(timezone.utc).replace(tzinfo=None)
period_start = now - timedelta(days=days)
# Usar func.now() para comparaciones en SQL (evita timezone issues)
@@ -357,7 +357,7 @@ async def get_sla_violations(
sla_type=sla_type
)
now = datetime.now(timezone.utc)
now = datetime.now(timezone.utc).replace(tzinfo=None)
db_now = func.now()
# Base query con carga de relaciones
@@ -417,18 +417,16 @@ async def get_sla_violations(
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Aplicar paginación
query = query.order_by(desc(Ticket.created_at)).offset(skip).limit(limit)
# Obtener todos los tickets sin paginación primero (los ordenaremos por tiempo vencido después)
result = await db.execute(query)
tickets = result.scalars().all()
# Formatear response
violations = []
for ticket in tickets:
# Asegurar que los datetimes de BD sean timezone-aware
sla_response_due = ticket.sla_response_due.replace(tzinfo=timezone.utc) if ticket.sla_response_due and ticket.sla_response_due.tzinfo is None else ticket.sla_response_due
sla_resolution_due = ticket.sla_resolution_due.replace(tzinfo=timezone.utc) if ticket.sla_resolution_due and ticket.sla_resolution_due.tzinfo is None else ticket.sla_resolution_due
# Todos los campos son timezone-naive (TIMESTAMP WITHOUT TIME ZONE)
sla_response_due = ticket.sla_response_due
sla_resolution_due = ticket.sla_resolution_due
# Determinar tipo de violación
response_violated = ticket.first_response_at is None and sla_response_due and now > sla_response_due
@@ -479,10 +477,16 @@ async def get_sla_violations(
resolved_at=ticket.resolved_at
))
# Ordenar por tiempo vencido (de mayor a menor)
violations.sort(key=lambda v: v.hours_overdue, reverse=True)
# Aplicar paginación en Python
paginated_violations = violations[skip:skip + limit]
total_pages = (total + limit - 1) // limit
return SLAViolationsListResponse(
violations=violations,
violations=paginated_violations,
total=total,
page=(skip // limit) + 1,
per_page=limit,
@@ -515,13 +519,16 @@ async def get_tickets_at_risk(
threshold=threshold
)
now = datetime.now(timezone.utc)
now = datetime.now(timezone.utc).replace(tzinfo=None)
db_now = func.now()
threshold_decimal = threshold / 100.0
# Query para tickets en riesgo
# Query para tickets en riesgo con relaciones precargadas
# Un ticket está en riesgo si: (now - created_at) / (due_at - created_at) >= threshold
query = select(Ticket).where(
query = select(Ticket).options(
selectinload(Ticket.assigned_to_user),
selectinload(Ticket.category)
).where(
and_(
Ticket.tenant_id == current_tenant.id,
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
@@ -576,14 +583,15 @@ async def get_tickets_at_risk(
else:
continue
# Normalizar created_at a timezone-naive para evitar errores de comparación
created_at = ticket.created_at.replace(tzinfo=None) if ticket.created_at.tzinfo else ticket.created_at
time_remaining = (due_at - now).total_seconds() / 3600
total_time = (due_at - ticket.created_at).total_seconds() / 3600
total_time = (due_at - created_at).total_seconds() / 3600
elapsed_time = total_time - time_remaining
risk_percentage = (elapsed_time / total_time * 100) if total_time > 0 else 0
# Cargar relaciones
await db.refresh(ticket, ['assigned_to', 'category'])
# Las relaciones ya están cargadas por selectinload
at_risk_tickets.append(SLATicketAtRisk(
ticket=TicketBasicInfo(
id=ticket.id,
@@ -599,11 +607,11 @@ async def get_tickets_at_risk(
sla_resolution_hours=ticket.category.sla_resolution_hours
) if ticket.category else None,
assigned_to=UserBasicInfo(
id=ticket.assigned_to.id,
first_name=ticket.assigned_to.first_name,
last_name=ticket.assigned_to.last_name,
email=ticket.assigned_to.email
) if ticket.assigned_to else None,
id=ticket.assigned_to_user.id,
first_name=ticket.assigned_to_user.first_name,
last_name=ticket.assigned_to_user.last_name,
email=ticket.assigned_to_user.email
) if ticket.assigned_to_user else None,
sla_type=sla_type,
sla_due_at=due_at,
time_remaining_hours=time_remaining,

File diff suppressed because it is too large Load Diff