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

@@ -57,6 +57,7 @@ class AuditLogResponse(AuditLogBase):
class SecurityThreatPattern(BaseModel):
"""Patrón de amenaza detectado."""
id: str = Field(description="ID único de la amenaza (pattern_id)")
type: str = Field(description="Tipo de amenaza (brute_force, privilege_escalation, etc.)")
severity: str = Field(description="Severidad: low, medium, high, critical")
description: str = Field(description="Descripción de la amenaza")
@@ -65,7 +66,7 @@ class SecurityThreatPattern(BaseModel):
affected_users: list[str] = Field(default=[], description="Usuarios afectados")
first_seen: datetime = Field(description="Primera ocurrencia")
last_seen: datetime = Field(description="Última ocurrencia")
recommendations: list[str] = Field(default=[], description="Recomendaciones de acción")
recommended_action: str = Field(default="", description="Acción recomendada")
class SecurityAnalysisResponse(BaseModel):

View File

@@ -123,7 +123,7 @@ def detect_mass_deletions(logs: List[AuditLog], now: datetime) -> List[dict]:
"status": status,
"incident_type": "mass_deletion",
"affected_user": group['user'],
"source_ip": group['logs'][0].ip_address,
"source_ip": str(group['logs'][0].ip_address) if group['logs'][0].ip_address else None,
"evidence": [f"{log.action} - {log.resource_type} - {log.created_at.strftime('%H:%M:%S')}" for log in group['logs'][:5]],
"metadata": {
"total_deletions": group['count'],
@@ -207,7 +207,7 @@ def detect_privilege_escalation(logs: List[AuditLog]) -> List[dict]:
"status": "investigating",
"incident_type": "privilege_escalation",
"affected_user": log.user.email,
"source_ip": log.ip_address,
"source_ip": str(log.ip_address) if log.ip_address else None,
"evidence": [f"Cambio de rol: {old_role}{new_role} - {log.created_at.strftime('%Y-%m-%d %H:%M')}"],
"metadata": {
"old_role": old_role,

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