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:
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
))
|
||||
|
||||
|
||||
1033
backend/app/api/v1/endpoints/audit_backup.py
Normal file
1033
backend/app/api/v1/endpoints/audit_backup.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
1072
backend/app/api/v1/endpoints/tickets_backup.py
Normal file
1072
backend/app/api/v1/endpoints/tickets_backup.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user