- 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/
308 lines
16 KiB
Python
308 lines
16 KiB
Python
"""Audit Endpoints - ServiceManagerWeb"""
|
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, func, and_, or_, desc
|
|
from sqlalchemy.orm import selectinload
|
|
from typing import Optional, List
|
|
from datetime import datetime, timedelta, timezone
|
|
import uuid
|
|
import structlog
|
|
|
|
from app.core.database import get_db
|
|
from app.api.deps import get_current_user, get_current_tenant
|
|
from app.models.user import User, UserRole
|
|
from app.models.tenant import Tenant
|
|
from app.models.audit import AuditLog
|
|
from app.services.audit_service import AuditService
|
|
from app.api.schemas.audit import (
|
|
AuditLogResponse, AuditLogListResponse, AuditLogFilters, AuditLogStats,
|
|
SecurityAnalysisResponse, SecurityThreatPattern, SecurityActionRequest,
|
|
SecurityActionResponse, SecurityIncidentResponse, SecurityIncidentListResponse
|
|
)
|
|
from app.api.v1.audit_helpers import (
|
|
audit_log_to_dict, apply_tenant_filter, get_count_stat, get_top_items,
|
|
detect_mass_deletions, detect_brute_force, detect_privilege_escalation
|
|
)
|
|
|
|
router = APIRouter()
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
def require_auditor_role(current_user: User = Depends(get_current_user)) -> User:
|
|
"""Verifica que el usuario tenga rol de auditor"""
|
|
if current_user.role not in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR]:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Solo usuarios con rol ADMIN, SUPPORT_MANAGER o AUDITOR pueden acceder a logs de auditoría")
|
|
return current_user
|
|
|
|
@router.get("/", response_model=AuditLogListResponse)
|
|
async def get_audit_logs(page: int = Query(default=1, ge=1), per_page: int = Query(default=50, ge=1, le=100),
|
|
user_id: Optional[uuid.UUID] = Query(None), action: Optional[str] = Query(None),
|
|
resource_type: Optional[str] = Query(None), resource_id: Optional[uuid.UUID] = Query(None),
|
|
date_from: Optional[datetime] = Query(None), date_to: Optional[datetime] = Query(None),
|
|
search: Optional[str] = Query(None), tenant_id: Optional[uuid.UUID] = Query(None),
|
|
all_tenants: bool = Query(False), current_user: User = Depends(require_auditor_role),
|
|
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
|
|
"""Obtener logs de auditoría con filtros y paginación"""
|
|
logger.info("Fetching audit logs", user_id=str(current_user.id), tenant_id=str(current_tenant.id),
|
|
filters={"user_id": str(user_id) if user_id else None, "action": action, "page": page, "all_tenants": all_tenants})
|
|
|
|
query = select(AuditLog).options(selectinload(AuditLog.user))
|
|
query = apply_tenant_filter(query, current_user, current_tenant, all_tenants, tenant_id)
|
|
|
|
if user_id:
|
|
query = query.where(AuditLog.user_id == user_id)
|
|
if action:
|
|
query = query.where(AuditLog.action == action)
|
|
if resource_type:
|
|
query = query.where(AuditLog.resource_type == resource_type)
|
|
if resource_id:
|
|
query = query.where(AuditLog.resource_id == resource_id)
|
|
if date_from:
|
|
query = query.where(AuditLog.created_at >= date_from)
|
|
if date_to:
|
|
query = query.where(AuditLog.created_at < date_to)
|
|
if search:
|
|
query = query.where(AuditLog.action.ilike(f"%{search}%"))
|
|
|
|
query = query.order_by(desc(AuditLog.created_at))
|
|
|
|
count_query = select(func.count()).select_from(query.subquery())
|
|
total = (await db.execute(count_query)).scalar() or 0
|
|
|
|
offset = (page - 1) * per_page
|
|
query = query.offset(offset).limit(per_page)
|
|
|
|
result = await db.execute(query)
|
|
logs = result.scalars().all()
|
|
|
|
total_pages = (total + per_page - 1) // per_page
|
|
logs_response = [AuditLogResponse(**audit_log_to_dict(log)) for log in logs]
|
|
|
|
return AuditLogListResponse(logs=logs_response, total=total, page=page, per_page=per_page, total_pages=total_pages)
|
|
|
|
@router.get("/stats", response_model=AuditLogStats)
|
|
async def get_audit_stats(all_tenants: bool = Query(False), current_user: User = Depends(require_auditor_role),
|
|
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
|
|
"""Obtener estadísticas de auditoría"""
|
|
can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]
|
|
logger.info("Fetching audit stats", user_id=str(current_user.id), tenant_id=str(current_tenant.id),
|
|
all_tenants=all_tenants, can_see_all=can_see_all_tenants)
|
|
|
|
now = datetime.now(timezone.utc)
|
|
apply_tenant = not (all_tenants and can_see_all_tenants)
|
|
tenant_filter = current_tenant.id if apply_tenant else None
|
|
|
|
total_actions = await get_count_stat(db, tenant_filter)
|
|
actions_today = await get_count_stat(db, tenant_filter, now - timedelta(days=1))
|
|
actions_this_week = await get_count_stat(db, tenant_filter, now - timedelta(days=7))
|
|
|
|
today_start = now - timedelta(days=1)
|
|
critical_conditions = [
|
|
AuditLog.created_at >= today_start,
|
|
or_(AuditLog.action.like('%.delete'), AuditLog.action.like('user.update'),
|
|
AuditLog.action.like('%.assign'), AuditLog.action.in_(['user.login_failed', 'user.logout']))
|
|
]
|
|
if apply_tenant:
|
|
critical_conditions.append(AuditLog.tenant_id == tenant_filter)
|
|
|
|
critical_actions_today = (await db.execute(select(func.count()).select_from(AuditLog).where(and_(*critical_conditions)))).scalar() or 0
|
|
|
|
top_actions = await get_top_items(db, AuditLog.action, tenant_filter)
|
|
by_resource_type = await get_top_items(db, AuditLog.resource_type, tenant_filter, limit=10)
|
|
top_users = await get_top_items(db, None, tenant_filter, join_user=True)
|
|
|
|
return AuditLogStats(total_actions=total_actions, actions_today=actions_today,
|
|
actions_this_week=actions_this_week, critical_actions_today=critical_actions_today,
|
|
top_actions=top_actions, top_users=top_users, by_resource_type=by_resource_type)
|
|
|
|
@router.get("/{log_id}", response_model=AuditLogResponse)
|
|
async def get_audit_log_detail(log_id: uuid.UUID, current_user: User = Depends(require_auditor_role),
|
|
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
|
|
"""Obtener detalle de un log de auditoría"""
|
|
query = select(AuditLog).where(AuditLog.id == log_id).options(selectinload(AuditLog.user))
|
|
query = apply_tenant_filter(query, current_user, current_tenant)
|
|
|
|
result = await db.execute(query)
|
|
log = result.scalar_one_or_none()
|
|
|
|
if not log:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Audit log {log_id} not found")
|
|
|
|
return AuditLogResponse(**audit_log_to_dict(log))
|
|
|
|
@router.get("/security/analysis", response_model=SecurityAnalysisResponse)
|
|
async def get_security_analysis(all_tenants: bool = Query(False), current_user: User = Depends(require_auditor_role),
|
|
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
|
|
"""Análisis de seguridad basado en logs de auditoría"""
|
|
logger.info("Security analysis requested", user_id=str(current_user.id), tenant_id=str(current_tenant.id))
|
|
|
|
now = datetime.now(timezone.utc)
|
|
analysis_start = now - timedelta(hours=24)
|
|
|
|
query = select(AuditLog).where(AuditLog.created_at >= analysis_start).options(selectinload(AuditLog.user))
|
|
query = apply_tenant_filter(query, current_user, current_tenant, all_tenants)
|
|
|
|
result = await db.execute(query)
|
|
logs = result.scalars().all()
|
|
|
|
failed_logins = sum(1 for log in logs if log.action == 'user.login_failed')
|
|
mass_deletions = sum(1 for log in logs if '.delete' in log.action)
|
|
privilege_changes = sum(1 for log in logs if log.action == 'user.update' and log.new_values and 'role' in log.new_values)
|
|
|
|
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(
|
|
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_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(
|
|
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_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(
|
|
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_ips=[],
|
|
affected_users=list(set(affected_users_list))[:5],
|
|
recommended_action="Auditar cambios de roles recientes"
|
|
))
|
|
|
|
risk_score = min(100, (failed_logins * 2) + (mass_deletions * 5) + (privilege_changes * 10))
|
|
risk_level = "critical" if risk_score >= 80 else "high" if risk_score >= 50 else "medium" if risk_score >= 20 else "low"
|
|
|
|
recommended_actions = []
|
|
if failed_logins >= 20:
|
|
recommended_actions.append("Implementar bloqueo automático de IPs después de múltiples intentos fallidos")
|
|
if mass_deletions >= 50:
|
|
recommended_actions.append("Activar confirmación adicional para eliminaciones masivas")
|
|
if not recommended_actions:
|
|
recommended_actions.append("Continuar monitoreando actividad del sistema")
|
|
|
|
# Calcular IPs sospechosas (más de 5 intentos fallidos)
|
|
suspicious_ips = len(set([log.ip_address for log in logs if log.ip_address and log.action == 'auth.login.failed']))
|
|
|
|
# Contar acciones críticas (delete, privilege changes, etc)
|
|
critical_actions = mass_deletions + privilege_changes
|
|
|
|
return SecurityAnalysisResponse(
|
|
overall_risk_level=risk_level,
|
|
total_threats_detected=len(threat_patterns),
|
|
threats=threat_patterns,
|
|
analysis_period_hours=24,
|
|
generated_at=datetime.utcnow(),
|
|
failed_login_attempts=failed_logins,
|
|
suspicious_ips_count=suspicious_ips,
|
|
critical_actions_count=critical_actions,
|
|
recommended_actions=recommended_actions
|
|
)
|
|
|
|
@router.post("/security/action", response_model=SecurityActionResponse)
|
|
async def execute_security_action(action: SecurityActionRequest, current_user: User = Depends(require_auditor_role),
|
|
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
|
|
"""Ejecutar acción de seguridad"""
|
|
if current_user.role not in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Solo administradores pueden ejecutar acciones de seguridad")
|
|
|
|
logger.info("Security action requested", user_id=str(current_user.id),
|
|
action_type=action.action_type, target=action.target)
|
|
|
|
try:
|
|
await AuditService.log(db=db, tenant_id=current_tenant.id, user_id=current_user.id,
|
|
action=f"security.{action.action_type}", resource_type="security", resource_id=None,
|
|
metadata={"target": action.target, "reason": action.reason, "duration_minutes": action.duration_minutes})
|
|
await db.commit()
|
|
except Exception as e:
|
|
logger.error("Failed to log security action", error=str(e))
|
|
|
|
action_messages = {
|
|
"block_ip": f"IP {action.target} bloqueada por {action.duration_minutes or 60} minutos. Razón: {action.reason}",
|
|
"notify_admin": f"Notificación enviada a administradores sobre: {action.reason}",
|
|
"force_password_reset": f"Se forzará cambio de contraseña para {action.target}. Razón: {action.reason}",
|
|
"disable_user": f"Usuario {action.target} desactivado temporalmente. Razón: {action.reason}"
|
|
}
|
|
|
|
success = action.action_type in action_messages
|
|
message = action_messages.get(action.action_type, f"Tipo de acción no reconocida: {action.action_type}")
|
|
|
|
return SecurityActionResponse(success=success, message=message, action_id=None)
|
|
|
|
@router.get("/security/incidents", response_model=SecurityIncidentListResponse)
|
|
async def get_security_incidents(page: int = Query(default=1, ge=1), per_page: int = Query(default=20, ge=1, le=100),
|
|
severity: Optional[str] = Query(None), status: Optional[str] = Query(None),
|
|
incident_type: Optional[str] = Query(None), search: Optional[str] = Query(None),
|
|
all_tenants: bool = Query(False), current_user: User = Depends(require_auditor_role),
|
|
current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)):
|
|
"""Obtener incidentes de seguridad"""
|
|
logger.info("Fetching security incidents", user_id=str(current_user.id), tenant_id=str(current_tenant.id),
|
|
filters={"severity": severity, "status": status, "type": incident_type, "page": page})
|
|
|
|
now = datetime.now(timezone.utc)
|
|
analysis_start = now - timedelta(days=7)
|
|
|
|
base_query = select(AuditLog).options(selectinload(AuditLog.user)).where(AuditLog.created_at >= analysis_start)
|
|
base_query = apply_tenant_filter(base_query, current_user, current_tenant, all_tenants)
|
|
|
|
deletion_result = await db.execute(base_query.where(AuditLog.action.like('%.delete')).order_by(desc(AuditLog.created_at)))
|
|
deletion_logs = deletion_result.scalars().all()
|
|
deletion_incidents = detect_mass_deletions(deletion_logs, now)
|
|
|
|
failed_login_result = await db.execute(base_query.where(AuditLog.action == 'user.login_failed').order_by(desc(AuditLog.created_at)))
|
|
failed_login_logs = failed_login_result.scalars().all()
|
|
brute_force_incidents = detect_brute_force(failed_login_logs, now)
|
|
|
|
privilege_result = await db.execute(base_query.where(and_(AuditLog.action == 'user.update', AuditLog.new_values.op('?')('role'))).order_by(desc(AuditLog.created_at)))
|
|
privilege_logs = privilege_result.scalars().all()
|
|
privilege_incidents = detect_privilege_escalation(privilege_logs)
|
|
|
|
incidents = [SecurityIncidentResponse(**inc) for inc in (deletion_incidents + brute_force_incidents + privilege_incidents)]
|
|
|
|
if severity:
|
|
incidents = [i for i in incidents if i.severity == severity]
|
|
if status:
|
|
incidents = [i for i in incidents if i.status == status]
|
|
if incident_type:
|
|
incidents = [i for i in incidents if i.incident_type == incident_type]
|
|
if search:
|
|
search_lower = search.lower()
|
|
incidents = [i for i in incidents if search_lower in i.title.lower() or (i.description and search_lower in i.description.lower())]
|
|
|
|
incidents.sort(key=lambda x: x.created_at, reverse=True)
|
|
|
|
total = len(incidents)
|
|
total_pages = (total + per_page - 1) // per_page
|
|
start_idx = (page - 1) * per_page
|
|
end_idx = start_idx + per_page
|
|
paginated_incidents = incidents[start_idx:end_idx]
|
|
|
|
return SecurityIncidentListResponse(incidents=paginated_incidents, total=total, page=page, per_page=per_page, total_pages=total_pages)
|