v1.15.1 - modulo de reportes implementado
- Nuevo módulo de reportes: backend/app/api/v1/endpoints/reports.py - Schemas de reportes: backend/app/api/schemas/reports.py - Frontend: frontend-internal/src/routes/reports/ - Mejoras al módulo de auditoría (audit.py, audit_helpers.py) - Modelo de auditoría actualizado - Sidebar actualizado con enlace a reportes
This commit is contained in:
@@ -1,4 +1,29 @@
|
||||
"""Audit Endpoints - ServiceManagerWeb"""
|
||||
"""
|
||||
Audit Endpoints - ServiceManagerWeb
|
||||
====================================
|
||||
Este archivo maneja todos los endpoints de auditoría y seguridad.
|
||||
Rutas disponibles:
|
||||
GET /audit/ → Lista de logs con filtros y paginación
|
||||
GET /audit/stats → Estadísticas generales de auditoría
|
||||
GET /audit/{log_id} → Detalle de un log específico
|
||||
GET /audit/security/analysis → Análisis de amenazas en tiempo real
|
||||
POST /audit/security/action → Ejecutar acción de seguridad (bloquear IP, etc.)
|
||||
GET /audit/security/incidents → Lista de incidentes detectados
|
||||
|
||||
CORRECCIONES APLICADAS:
|
||||
1. Todos los endpoints usan datetime.now(timezone.utc) para generar
|
||||
fechas aware (con timezone info en UTC), compatibles con la columna
|
||||
'timestamp with time zone' (TIMESTAMPTZ) de PostgreSQL.
|
||||
|
||||
2. audit_helpers.get_count_stat() convierte las fechas a aware UTC
|
||||
con _ensure_aware_utc() antes de usarlas en queries, resolviendo
|
||||
el bug donde los tres contadores (total, hoy, semana) devolvían
|
||||
el mismo valor porque el filtro de fecha se ignoraba.
|
||||
|
||||
3. critical_actions_today usa los mismos umbrales que /security/incidents
|
||||
para que el contador del dashboard coincida con la lista de detalles.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, or_, desc
|
||||
@@ -24,31 +49,83 @@ from app.api.v1.audit_helpers import (
|
||||
detect_mass_deletions, detect_brute_force, detect_privilege_escalation
|
||||
)
|
||||
|
||||
# Instancia del router de FastAPI para este módulo
|
||||
router = APIRouter()
|
||||
|
||||
# Logger estructurado para registrar eventos internos del sistema
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DEPENDENCIA DE AUTORIZACIÓN
|
||||
# =============================================================================
|
||||
|
||||
def require_auditor_role(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""Verifica que el usuario tenga rol de auditor"""
|
||||
"""
|
||||
Dependencia reutilizable que verifica que el usuario tenga permisos
|
||||
para ver logs de auditoría.
|
||||
|
||||
Solo pueden acceder los roles: ADMIN, SUPPORT_MANAGER, AUDITOR.
|
||||
Si no tiene el rol correcto, lanza un error 403 Forbidden.
|
||||
"""
|
||||
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")
|
||||
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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: LISTA DE LOGS DE AUDITORÍA
|
||||
# =============================================================================
|
||||
|
||||
@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})
|
||||
|
||||
async def get_audit_logs(
|
||||
# Paginación
|
||||
page: int = Query(default=1, ge=1),
|
||||
per_page: int = Query(default=50, ge=1, le=100),
|
||||
# Filtros opcionales
|
||||
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),
|
||||
# Dependencias de autenticación y base de datos
|
||||
current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Obtener el historial completo de logs de auditoría con filtros opcionales.
|
||||
|
||||
Soporta filtrar por usuario, tipo de acción, recurso afectado, fechas
|
||||
y búsqueda de texto. También soporta ver logs de todos los tenants
|
||||
si el usuario tiene permisos de ADMIN o SUPPORT_MANAGER.
|
||||
"""
|
||||
logger.info(
|
||||
"Obteniendo logs de auditoria",
|
||||
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
|
||||
}
|
||||
)
|
||||
|
||||
# Construir la query base con relación al usuario que hizo la acción
|
||||
query = select(AuditLog).options(selectinload(AuditLog.user))
|
||||
|
||||
# Aplicar filtro de tenant según permisos del usuario
|
||||
query = apply_tenant_filter(query, current_user, current_tenant, all_tenants, tenant_id)
|
||||
|
||||
|
||||
# Aplicar filtros opcionales uno por uno
|
||||
if user_id:
|
||||
query = query.where(AuditLog.user_id == user_id)
|
||||
if action:
|
||||
@@ -62,230 +139,610 @@ async def get_audit_logs(page: int = Query(default=1, ge=1), per_page: int = Que
|
||||
if date_to:
|
||||
query = query.where(AuditLog.created_at < date_to)
|
||||
if search:
|
||||
# Búsqueda parcial en el campo "action" (ej: "ticket" encuentra "ticket.create")
|
||||
query = query.where(AuditLog.action.ilike(f"%{search}%"))
|
||||
|
||||
|
||||
# Ordenar por fecha descendente (más reciente primero)
|
||||
query = query.order_by(desc(AuditLog.created_at))
|
||||
|
||||
|
||||
# Contar total de registros para calcular páginas
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_query)).scalar() or 0
|
||||
|
||||
|
||||
# Aplicar paginación
|
||||
offset = (page - 1) * per_page
|
||||
query = query.offset(offset).limit(per_page)
|
||||
|
||||
|
||||
# Ejecutar query y obtener resultados
|
||||
result = await db.execute(query)
|
||||
logs = result.scalars().all()
|
||||
|
||||
|
||||
# Calcular número total de páginas
|
||||
total_pages = (total + per_page - 1) // per_page
|
||||
|
||||
# Convertir modelos a schemas de respuesta
|
||||
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)
|
||||
|
||||
return AuditLogListResponse(
|
||||
logs=logs_response,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
total_pages=total_pages
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: ESTADÍSTICAS DE AUDITORÍA
|
||||
# =============================================================================
|
||||
|
||||
@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"""
|
||||
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 resumidas de auditoría para el dashboard.
|
||||
|
||||
Incluye:
|
||||
- Total de acciones registradas
|
||||
- Acciones de las últimas 24 horas
|
||||
- Acciones de los últimos 7 días
|
||||
- Incidentes críticos detectados hoy (alineado con /security/incidents)
|
||||
- Acciones más frecuentes
|
||||
- Usuarios más activos
|
||||
- Distribución por tipo de recurso
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
logger.info(
|
||||
"Obteniendo estadisticas de auditoria",
|
||||
user_id=str(current_user.id),
|
||||
tenant_id=str(current_tenant.id),
|
||||
all_tenants=all_tenants,
|
||||
can_see_all=can_see_all_tenants
|
||||
)
|
||||
|
||||
# datetime.now(timezone.utc) genera un datetime aware en UTC,
|
||||
# compatible con la columna TIMESTAMPTZ de PostgreSQL
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Determinar si se debe filtrar por tenant o ver todos
|
||||
apply_tenant = not (all_tenants and can_see_all_tenants)
|
||||
tenant_filter = current_tenant.id if apply_tenant else None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CONTADORES GENERALES
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Total histórico de acciones (sin filtro de fecha)
|
||||
total_actions = await get_count_stat(db, tenant_filter)
|
||||
|
||||
# Acciones en las últimas 24 horas
|
||||
# get_count_stat convierte internamente a aware UTC con _ensure_aware_utc()
|
||||
actions_today = await get_count_stat(db, tenant_filter, now - timedelta(days=1))
|
||||
|
||||
# Acciones en los últimos 7 días
|
||||
actions_this_week = await get_count_stat(db, tenant_filter, now - timedelta(days=7))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CONTADOR DE INCIDENTES CRÍTICOS
|
||||
# ------------------------------------------------------------------
|
||||
# Usa los mismos umbrales que los detectores de /security/incidents
|
||||
# para que el número del dashboard sea consistente con la lista.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
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
|
||||
|
||||
|
||||
# Contar intentos fallidos de login en las últimas 24 horas
|
||||
failed_login_count = (await db.execute(
|
||||
select(func.count()).select_from(AuditLog).where(
|
||||
AuditLog.action == 'user.login_failed',
|
||||
AuditLog.created_at >= today_start,
|
||||
*([AuditLog.tenant_id == tenant_filter] if apply_tenant else [])
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
# Contar eliminaciones en las últimas 24 horas
|
||||
deletion_count = (await db.execute(
|
||||
select(func.count()).select_from(AuditLog).where(
|
||||
AuditLog.action.like('%.delete'),
|
||||
AuditLog.created_at >= today_start,
|
||||
*([AuditLog.tenant_id == tenant_filter] if apply_tenant else [])
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
# Contar cambios de privilegios en las últimas 24 horas
|
||||
privilege_count = (await db.execute(
|
||||
select(func.count()).select_from(AuditLog).where(
|
||||
AuditLog.action == 'user.update',
|
||||
AuditLog.created_at >= today_start,
|
||||
*([AuditLog.tenant_id == tenant_filter] if apply_tenant else [])
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
# Calcular número real de incidentes usando los mismos umbrales
|
||||
# que los detectores en /security/incidents:
|
||||
# - Fuerza bruta: incidente si hay >= 20 intentos fallidos
|
||||
# - Eliminación masiva: incidente si hay >= 50 eliminaciones
|
||||
# - Escalada privilegios: incidente si hay >= 3 cambios de rol
|
||||
critical_actions_today = sum([
|
||||
1 if failed_login_count >= 20 else 0,
|
||||
1 if deletion_count >= 50 else 0,
|
||||
1 if privilege_count >= 3 else 0,
|
||||
])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DATOS PARA GRÁFICAS Y TABLAS DEL DASHBOARD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Top acciones más frecuentes (ej: "ticket.create", "user.login")
|
||||
top_actions = await get_top_items(db, AuditLog.action, tenant_filter)
|
||||
|
||||
# Distribución por tipo de recurso (ej: "ticket", "user", "tenant")
|
||||
by_resource_type = await get_top_items(db, AuditLog.resource_type, tenant_filter, limit=10)
|
||||
|
||||
# Usuarios más activos (hace join con tabla de usuarios)
|
||||
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)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: DETALLE DE UN LOG ESPECÍFICO
|
||||
# =============================================================================
|
||||
|
||||
@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"""
|
||||
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 el detalle completo de un log de auditoría por su ID.
|
||||
|
||||
Incluye información del usuario que realizó la acción, valores
|
||||
anteriores y nuevos (para cambios), IP de origen, user agent, etc.
|
||||
|
||||
Retorna 404 si el log no existe o no pertenece al tenant del usuario.
|
||||
"""
|
||||
# Buscar el log por ID incluyendo los datos del usuario relacionado
|
||||
query = select(AuditLog).where(AuditLog.id == log_id).options(selectinload(AuditLog.user))
|
||||
|
||||
# Aplicar filtro de tenant para garantizar aislamiento multi-tenant
|
||||
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")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Registro de auditoria {log_id} no encontrado"
|
||||
)
|
||||
|
||||
return AuditLogResponse(**audit_log_to_dict(log))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: ANÁLISIS DE SEGURIDAD EN TIEMPO REAL
|
||||
# =============================================================================
|
||||
|
||||
@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))
|
||||
|
||||
async def get_security_analysis(
|
||||
hours: int = Query(default=24, ge=1, le=720),
|
||||
all_tenants: bool = Query(False),
|
||||
current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Analizar los logs de auditoría para detectar patrones sospechosos.
|
||||
|
||||
Detecta tres tipos de amenazas:
|
||||
1. Fuerza bruta: Muchos intentos fallidos de login desde las mismas IPs
|
||||
2. Eliminación masiva: Gran cantidad de registros eliminados en poco tiempo
|
||||
3. Escalada privilegios: Cambios de roles sospechosos en usuarios
|
||||
|
||||
Calcula un nivel de riesgo general (low/medium/high/critical) y
|
||||
devuelve recomendaciones de acción.
|
||||
"""
|
||||
logger.info(
|
||||
"Analisis de seguridad solicitado",
|
||||
user_id=str(current_user.id),
|
||||
tenant_id=str(current_tenant.id),
|
||||
hours=hours
|
||||
)
|
||||
|
||||
# aware UTC para compatibilidad con TIMESTAMPTZ de PostgreSQL
|
||||
now = datetime.now(timezone.utc)
|
||||
analysis_start = now - timedelta(hours=24)
|
||||
|
||||
query = select(AuditLog).where(AuditLog.created_at >= analysis_start).options(selectinload(AuditLog.user))
|
||||
analysis_start = now - timedelta(hours=hours)
|
||||
|
||||
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()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CONTADORES DE EVENTOS SOSPECHOSOS
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
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)
|
||||
|
||||
privilege_changes = sum(
|
||||
1 for log in logs
|
||||
if log.action == 'user.update'
|
||||
and log.new_values
|
||||
and 'role' in log.new_values
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GENERACIÓN DE PATRONES DE AMENAZA
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
threat_patterns = []
|
||||
|
||||
|
||||
# Amenaza 1: Fuerza bruta (umbral mínimo: 5 intentos fallidos)
|
||||
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]
|
||||
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",
|
||||
description=(
|
||||
f"Se detectaron {failed_logins} intentos fallidos de "
|
||||
f"login en las ultimas {hours}h"
|
||||
),
|
||||
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),
|
||||
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"
|
||||
recommended_action="Considerar bloquear IPs con multiples fallos"
|
||||
))
|
||||
|
||||
|
||||
# Amenaza 2: Eliminación masiva (umbral mínimo: 10 eliminaciones)
|
||||
if mass_deletions >= 10:
|
||||
deleting_users = [log.user.email for log in logs if '.delete' in log.action and log.user]
|
||||
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",
|
||||
description=(
|
||||
f"Se detectaron {mass_deletions} eliminaciones en "
|
||||
f"las ultimas {hours}h"
|
||||
),
|
||||
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),
|
||||
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"
|
||||
recommended_action="Revisar que usuarios estan eliminando recursos masivamente"
|
||||
))
|
||||
|
||||
|
||||
# Amenaza 3: Escalada de privilegios (umbral mínimo: 3 cambios de rol)
|
||||
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]
|
||||
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",
|
||||
description=(
|
||||
f"Se detectaron {privilege_changes} cambios de "
|
||||
f"privilegios en las ultimas {hours}h"
|
||||
),
|
||||
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),
|
||||
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"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CÁLCULO DE NIVEL DE RIESGO GENERAL
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
risk_score = min(
|
||||
100,
|
||||
(failed_logins * 2) + (mass_deletions * 5) + (privilege_changes * 10)
|
||||
)
|
||||
|
||||
if risk_score >= 80:
|
||||
risk_level = "critical"
|
||||
elif risk_score >= 50:
|
||||
risk_level = "high"
|
||||
elif risk_score >= 20:
|
||||
risk_level = "medium"
|
||||
else:
|
||||
risk_level = "low"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# RECOMENDACIONES AUTOMÁTICAS
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
recommended_actions = []
|
||||
|
||||
if failed_logins >= 20:
|
||||
recommended_actions.append("Implementar bloqueo automático de IPs después de múltiples intentos fallidos")
|
||||
recommended_actions.append(
|
||||
"Implementar bloqueo automatico de IPs despues de multiples intentos fallidos"
|
||||
)
|
||||
if mass_deletions >= 50:
|
||||
recommended_actions.append("Activar confirmación adicional para eliminaciones masivas")
|
||||
recommended_actions.append(
|
||||
"Activar confirmacion adicional para eliminaciones masivas"
|
||||
)
|
||||
if privilege_changes >= 3:
|
||||
recommended_actions.append(
|
||||
"Revisar y aprobar manualmente los cambios de roles recientes"
|
||||
)
|
||||
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 == 'user.login_failed']))
|
||||
|
||||
# Contar acciones críticas (delete, privilege changes, etc)
|
||||
|
||||
suspicious_ips = len(set(
|
||||
log.ip_address
|
||||
for log in logs
|
||||
if log.ip_address and log.action == 'user.login_failed'
|
||||
))
|
||||
|
||||
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(),
|
||||
analysis_period_hours=hours,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
failed_login_attempts=failed_logins,
|
||||
suspicious_ips_count=suspicious_ips,
|
||||
critical_actions_count=critical_actions,
|
||||
recommended_actions=recommended_actions
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: EJECUTAR ACCIÓN DE SEGURIDAD
|
||||
# =============================================================================
|
||||
|
||||
@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"""
|
||||
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 una acción de seguridad manual sobre una amenaza detectada.
|
||||
|
||||
Acciones disponibles:
|
||||
- block_ip: Bloquear una dirección IP por X minutos
|
||||
- notify_admin: Enviar notificación a los administradores
|
||||
- force_password_reset: Forzar cambio de contraseña a un usuario
|
||||
- disable_user: Desactivar temporalmente una cuenta de usuario
|
||||
|
||||
Solo ADMIN y SUPPORT_MANAGER pueden ejecutar estas acciones.
|
||||
Todas las acciones quedan registradas en el log de auditoría.
|
||||
"""
|
||||
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)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo administradores pueden ejecutar acciones de seguridad"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Accion de seguridad solicitada",
|
||||
user_id=str(current_user.id),
|
||||
action_type=action.action_type,
|
||||
target=action.target
|
||||
)
|
||||
|
||||
# Registrar en auditoría para trazabilidad completa
|
||||
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 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))
|
||||
|
||||
logger.error("Fallo al registrar accion de seguridad en auditoria", 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}"
|
||||
"block_ip": (
|
||||
f"IP {action.target} bloqueada por "
|
||||
f"{action.duration_minutes or 60} minutos. Razon: {action.reason}"
|
||||
),
|
||||
"notify_admin": (
|
||||
f"Notificacion enviada a administradores sobre: {action.reason}"
|
||||
),
|
||||
"force_password_reset": (
|
||||
f"Se forzara cambio de contrasena para {action.target}. "
|
||||
f"Razon: {action.reason}"
|
||||
),
|
||||
"disable_user": (
|
||||
f"Usuario {action.target} desactivado temporalmente. "
|
||||
f"Razon: {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}")
|
||||
|
||||
message = action_messages.get(
|
||||
action.action_type,
|
||||
f"Tipo de accion no reconocida: {action.action_type}"
|
||||
)
|
||||
|
||||
return SecurityActionResponse(success=success, message=message, action_id=None)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINT: LISTA DE INCIDENTES DE SEGURIDAD
|
||||
# =============================================================================
|
||||
|
||||
@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})
|
||||
|
||||
async def get_security_incidents(
|
||||
# Paginación
|
||||
page: int = Query(default=1, ge=1),
|
||||
per_page: int = Query(default=20, ge=1, le=100),
|
||||
# Filtros opcionales
|
||||
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),
|
||||
# Dependencias
|
||||
current_user: User = Depends(require_auditor_role),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Obtener la lista de incidentes de seguridad detectados.
|
||||
|
||||
Los incidentes se generan dinámicamente analizando los logs de
|
||||
auditoría de los últimos 7 días usando tres detectores:
|
||||
|
||||
1. detect_brute_force: Analiza intentos fallidos de login
|
||||
2. detect_mass_deletions: Analiza eliminaciones masivas
|
||||
3. detect_privilege_escalation: Analiza cambios de rol sospechosos
|
||||
|
||||
Los umbrales son los mismos que usa /stats para critical_actions_today,
|
||||
garantizando consistencia entre el contador y la lista.
|
||||
"""
|
||||
logger.info(
|
||||
"Obteniendo incidentes de seguridad",
|
||||
user_id=str(current_user.id),
|
||||
tenant_id=str(current_tenant.id),
|
||||
filters={
|
||||
"severity": severity,
|
||||
"status": status,
|
||||
"type": incident_type,
|
||||
"page": page
|
||||
}
|
||||
)
|
||||
|
||||
# aware UTC para compatibilidad con TIMESTAMPTZ de PostgreSQL
|
||||
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 = (
|
||||
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)))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DETECTOR 1: ELIMINACIONES MASIVAS
|
||||
# ------------------------------------------------------------------
|
||||
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)))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DETECTOR 2: FUERZA BRUTA
|
||||
# ------------------------------------------------------------------
|
||||
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)))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DETECTOR 3: ESCALADA DE PRIVILEGIOS
|
||||
# El operador '?' verifica si el campo JSON contiene la clave 'role'
|
||||
# ------------------------------------------------------------------
|
||||
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)]
|
||||
|
||||
|
||||
# Combinar todos los incidentes
|
||||
incidents = [
|
||||
SecurityIncidentResponse(**inc)
|
||||
for inc in (deletion_incidents + brute_force_incidents + privilege_incidents)
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# FILTROS EN MEMORIA (los incidentes son generados dinámicamente)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
if severity:
|
||||
incidents = [i for i in incidents if i.severity == severity]
|
||||
if status:
|
||||
@@ -294,14 +751,29 @@ async def get_security_incidents(page: int = Query(default=1, ge=1), per_page: i
|
||||
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 = [
|
||||
i for i in incidents
|
||||
if search_lower in i.title.lower()
|
||||
or (i.description and search_lower in i.description.lower())
|
||||
]
|
||||
|
||||
# Ordenar por fecha descendente
|
||||
incidents.sort(key=lambda x: x.created_at, reverse=True)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PAGINACIÓN MANUAL
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
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)
|
||||
|
||||
return SecurityIncidentListResponse(
|
||||
incidents=paginated_incidents,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
total_pages=total_pages
|
||||
)
|
||||
Reference in New Issue
Block a user