Compare commits

...

15 Commits

Author SHA1 Message Date
be762585d2 feat: Mejoras en auditoría - incidentes de seguridad y esquema de colores
- Backend:
  * Agregado endpoint /v1/audit/security/incidents con paginación y filtros
  * Nuevos schemas SecurityIncidentResponse y SecurityIncidentListResponse
  * Fix timezone: datetime.utcnow() → datetime.now(timezone.utc) en 4 ubicaciones
  * Detección automática de incidentes: mass deletion, brute force, privilege escalation

- Frontend (Internal):
  * Nueva sección de Incidentes de Seguridad con modal de detalles
  * Filtros por severidad, estado y tipo de incidente
  * Conversión completa a esquema grayscale (gray-100 a gray-900)
  * Eliminados todos los emojis de páginas audit y security
  * Implementada paginación para incidentes

- Fixes:
  * Resuelto error 500: TypeError con datetimes timezone-aware/naive
  * Resuelto error 404: endpoint de incidentes faltante
2026-02-16 12:45:33 -07:00
32cc8b6ccd Merge: Integrar Sistema de Análisis de Seguridad v1.6.0 a main
CARACTERÍSTICAS PRINCIPALES v1.6.0:
- Sistema de auditoría multi-tenant completo
- Análisis de seguridad con detección de amenazas en tiempo real
- Panel de seguridad con 4 algoritmos de detección:
  * Ataques de fuerza bruta
  * Escalada de privilegios
  * Eliminaciones masivas
  * Cuentas comprometidas
- Acciones de seguridad: bloquear IP, resetear contraseña, notificar admin
- Cross-tenant viewing para ADMIN/SUPPORT_MANAGER
- Frontend completamente funcional con nuevo menú Seguridad
- Sistema completamente verificado y operativo

Resolución de conflictos:
- Versiones actualizadas a 1.6.0 en todos los package.json y pyproject.toml
- Menú de seguridad integrado en Sidebar
- Tickets endpoint actualizado con auditoría
- Correcciones de middleware y queries SQL aplicadas
2026-02-16 11:04:30 -07:00
caeac3e96c Fix: Correcciones en análisis de seguridad y middleware tenant
- Corregido query SQL para detección de escalada de privilegios (JSONB operator)
- Añadidas rutas de autenticación faltantes al middleware tenant
- Sistema completamente verificado y funcional v1.6.0
2026-02-16 10:59:08 -07:00
6af80f1960 Feature: Sistema de Análisis de Seguridad y Detección de Vulnerabilidades v1.6.0
Nuevas funcionalidades:
- Sistema completo de análisis de seguridad con detección de amenazas
- Detección de patrones: fuerza bruta, escalada de privilegios, eliminaciones masivas, cuentas comprometidas
- Panel de vulnerabilidades con visualización detallada
- Acciones de seguridad: bloqueo de IPs, notificaciones, reset de contraseñas
- Análisis configurable (24h, 48h, 7 días)

Backend (/audit/security/):
- GET /analysis: Análisis completo de seguridad con amenazas detectadas
- POST /action: Ejecutar acciones de seguridad (solo ADMIN/SUPPORT_MANAGER)
- Schemas nuevos: SecurityAnalysisResponse, SecurityThreatPattern, SecurityActionRequest

Frontend (/audit/security):
- Panel completo de análisis con nivel de riesgo general
- Visualización de amenazas con severidad (critical, high, medium, low)
- Estadísticas: amenazas, intentos fallidos, IPs sospechosas, acciones críticas
- Opciones de acción por amenaza: bloquear IP, resetear contraseña, notificar admin
- Modal de ejecución de acciones de seguridad

Mejoras:
- Sidebar actualizado con enlace 'Seguridad'
- Permisos: Solo ADMIN/SUPPORT_MANAGER/AUDITOR pueden ver análisis
- Solo ADMIN/SUPPORT_MANAGER pueden ejecutar acciones
- Audit log de todas las acciones de seguridad ejecutadas
2026-02-16 10:09:36 -07:00
57944b364c Configure v1.6.0 display across application
- backend/app/core/config.py: Add APP_VERSION = '1.6.0' setting
- backend/app/main.py: Update health and root endpoints to show APP_VERSION
- frontend-internal/Sidebar.svelte: Add version display in sidebar footer
- frontend-client/+layout.svelte: Add version in page footer
- All endpoints now return both app_version (1.6.0) and api_version (v1)
2026-02-16 09:56:02 -07:00
3a061a005c Release v1.6.0 - Audit System Cross-Tenant Viewing
Features:
-  Cross-tenant audit log viewing for ADMIN/SUPPORT_MANAGER
-  New 'all_tenants' parameter in audit endpoints
-  Frontend toggle to view all clients' logs
-  Multi-tenant stats support in /audit/stats
-  Fixed timezone issue with date filters (UTC consistency)
-  Fixed date_to filter overlap causing duplicate records

Changes:
- backend/app/api/v1/endpoints/audit.py:
  * Added tenant_id and all_tenants query parameters
  * Permission checks for cross-tenant viewing
  * Dynamic tenant filtering based on user role
  * Fixed date_to filter (removed +1 day overlap)
  * Updated all stats queries for multi-tenant support

- frontend-internal/src/routes/audit/+page.svelte:
  * Import auth store for role detection
  * Added 'Ver todos los clientes' toggle for admins
  * Pass all_tenants parameter to API calls
  * UI badge indicating multi-tenant mode

- Version bump: 1.5.1.2 → 1.6.0 across all packages
2026-02-16 09:50:30 -07:00
d9b783107f fix: Corregir filtro de fechas en auditoría por zona horaria
- El filtro usaba hora local que se convertía incorrectamente a UTC
- Los registros de 'hoy' aparecían en 'ayer' por desfase horario
- Ahora trabaja directamente en UTC para consistencia
- Afecta todos los filtros: today, yesterday, last7days, last30days
- Custom dates también parseados correctamente como UTC
2026-02-16 09:26:43 -07:00
5a292daa0b feat: Script para crear usuario de prueba con contraseña conocida
- Permite crear/actualizar usuario admin@aduanasoft.com
- Password: admin123
- Facilita testing del sistema de auditoría
- Genera hash correcto con Argon2
2026-02-16 09:21:06 -07:00
87e094b668 feat: Integrar AuditService en todos los endpoints críticos
- Auth: login, logout, login_failed con registro automático
- Tickets: create, update, delete, assign con old/new values
- Users: create, update, delete con sanitización de passwords
- Stats: implementar top_users con JOIN a tabla users
- Schema: agregar índices compuestos para mejor performance
- Frontend: limpiar logs de debug en viewDetail
- Manejo de errores: audit logs no afectan flujo principal
2026-02-16 09:08:03 -07:00
2cb8b58808 Fix: Corregir funcionalidad del botón Ver en auditoría
- Eliminar click en fila completa que causaba conflicto
- Eliminar stopPropagation innecesario de botones
- Agregar type='button' a todos los botones Ver
- Mejorar hover states y transiciones
- Agregar títulos para accesibilidad
- Simplificar lógica de eventos de click
- Botones Ver ahora funcionan correctamente en tabla y tarjetas
- Tarjeta de vulnerabilidad: solo botón Ver es clickeable
2026-02-16 08:43:03 -07:00
9f973464b9 Implementar tarjeta de Vulnerabilidad y mejorar interactividad
- Reemplazar tarjeta 'Más Común' por 'Vulnerabilidad'
- Backend: Agregar campo critical_actions_today al schema de stats
- Backend: Calcular acciones críticas (delete, update sensibles, logout)
- Frontend: Mostrar contador de acciones críticas con código de color
- Frontend: Click en tarjeta filtra por acciones críticas del día
- Frontend: Botón 'Ver' funcional con icono
- Color rojo si >10 críticas, ámbar si >5, verde si <=5
- Función filterCriticalActions() para búsqueda rápida
- UX mejorada con hover effects y transiciones
2026-02-16 08:38:39 -07:00
90f9c9c6e6 Modernizar UI de registros de auditoría (UX mejorada)
- Diseño responsivo: tabla en desktop, tarjetas en móvil/tablet
- Altura máxima con scroll interno (evita scroll de página completa)
- Avatares circulares con iniciales del usuario
- Filas/tarjetas más compactas y eficientes
- Paginación sticky (siempre visible al fondo)
- Botones primera/última página para navegación rápida
- Página actual resaltada en color primary
- Click en toda la fila para ver detalles
- Diseño más moderno y limpio
- Mejor uso del espacio vertical
2026-02-16 08:31:07 -07:00
51a8515b40 Fix: Corregir formato de fechas en auditoría
- Enviar datetime ISO completo en lugar de solo fecha (YYYY-MM-DD)
- Backend requiere formato datetime ISO 8601
- Agregar hora 00:00:00 para fecha inicio y 23:59:59 para fecha fin
- Manejar correctamente fechas custom vacías
- Soluciona error 422 (Unprocessable Entity)
2026-02-16 08:24:14 -07:00
ff9a8998b2 Mejora UI de página de auditoría
- Por defecto muestra solo logs del día actual
- Agregado selector de período rápido (Hoy, Ayer, 7 días, 30 días, Personalizado)
- Filtros avanzados colapsables para mejor UX
- Interfaz más limpia y organizada
- Formato de fechas mejorado (hora corta para hoy)
- Estadísticas visuales mejoradas
- Tabla simplificada con información esencial
- Modal de detalles mantiene toda la información completa
2026-02-16 08:17:39 -07:00
1543397212 v1.5.1.2: Integración completa del sistema de auditoría
- Agregado módulo de auditoría con AuditLog model
- Implementado endpoint /api/v1/audit para consulta de logs
- Integrado audit_service para registro automático de acciones
- Agregado token_service para gestión de refresh tokens
- Frontend: Vista de auditoría en panel interno
- Actualizada versión en pyproject.toml y package.json
- Sistema de auditoría completamente funcional y testeado
2026-02-16 08:05:25 -07:00
28 changed files with 4918 additions and 111 deletions

View File

@@ -0,0 +1,191 @@
"""
Audit Schemas - ServiceManagerWeb
Schemas Pydantic para endpoints de auditoría
"""
from pydantic import BaseModel, Field, UUID4
from typing import Optional, Dict, Any
from datetime import datetime
class AuditLogBase(BaseModel):
"""Schema base para audit logs."""
action: str = Field(..., description="Acci├│n realizada (ej: ticket.create)")
resource_type: str = Field(..., description="Tipo de recurso (ticket, user, etc.)")
resource_id: Optional[UUID4] = Field(None, description="ID del recurso afectado")
extra_metadata: Optional[Dict[str, Any]] = Field(None, description="Metadata adicional", alias="metadata")
class AuditLogResponse(AuditLogBase):
"""
Schema de respuesta para audit logs.
Incluye toda la informaci├│n del log con datos del usuario.
"""
id: UUID4
tenant_id: UUID4
user_id: Optional[UUID4]
# Informaci├│n del usuario (si existe)
user_email: Optional[str] = None
user_name: Optional[str] = None
user_role: Optional[str] = None
# Contexto de la acci├│n
ip_address: Optional[str]
user_agent: Optional[str]
correlation_id: Optional[UUID4]
# Cambios realizados
old_values: Optional[Dict[str, Any]]
new_values: Optional[Dict[str, Any]]
# Timestamp
created_at: datetime
# Display friendly
action_display: str = Field(description="Acci├│n en formato amigable")
class Config:
from_attributes = True
# ===================================
# SECURITY ANALYSIS SCHEMAS
# ===================================
class SecurityThreatPattern(BaseModel):
"""Patrón de amenaza detectado."""
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")
occurrences: int = Field(description="Número de ocurrencias")
affected_ips: list[str] = Field(default=[], description="IPs involucradas")
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")
class SecurityAnalysisResponse(BaseModel):
"""Análisis completo de seguridad."""
overall_risk_level: str = Field(description="Nivel de riesgo general: safe, low, medium, high, critical")
total_threats_detected: int = Field(description="Total de amenazas detectadas")
threats: list[SecurityThreatPattern] = Field(description="Lista de amenazas detectadas")
analysis_period_hours: int = Field(description="Período de análisis en horas")
generated_at: datetime = Field(description="Timestamp del análisis")
# Estadísticas de seguridad
failed_login_attempts: int = Field(description="Intentos fallidos de login")
suspicious_ips_count: int = Field(description="IPs sospechosas detectadas")
critical_actions_count: int = Field(description="Acciones críticas realizadas")
# Opciones de acción
recommended_actions: list[str] = Field(default=[], description="Acciones recomendadas")
class SecurityActionRequest(BaseModel):
"""Solicitud de acción de seguridad."""
action_type: str = Field(description="Tipo de acción: block_ip, notify_admin, reset_password, etc.")
target: str = Field(description="Objetivo de la acción (IP, email, etc.)")
reason: str = Field(description="Razón de la acción")
duration_minutes: Optional[int] = Field(None, description="Duración del bloqueo en minutos")
class SecurityActionResponse(BaseModel):
"""Respuesta de acción de seguridad."""
success: bool = Field(description="Si la acción fue exitosa")
message: str = Field(description="Mensaje descriptivo")
action_id: Optional[UUID4] = Field(None, description="ID de la acción registrada")
class SecurityIncidentResponse(BaseModel):
"""Respuesta para incidentes de seguridad."""
id: str = Field(description="ID único del incidente")
title: str = Field(description="Título del incidente")
description: Optional[str] = Field(None, description="Descripción detallada")
severity: str = Field(description="Severidad: low, medium, high, critical")
status: str = Field(description="Estado: active, investigating, resolved")
incident_type: str = Field(description="Tipo de incidente")
affected_user: Optional[str] = Field(None, description="Usuario afectado")
source_ip: Optional[str] = Field(None, description="IP origen del incidente")
evidence: list[str] = Field(default=[], description="Evidencia del incidente")
metadata: Optional[Dict[str, Any]] = Field(None, description="Metadata adicional")
created_at: datetime = Field(description="Fecha de creación")
updated_at: Optional[datetime] = Field(None, description="Última actualización")
resolved_at: Optional[datetime] = Field(None, description="Fecha de resolución")
class Config:
from_attributes = True
class SecurityIncidentListResponse(BaseModel):
"""Respuesta paginada de incidentes de seguridad."""
incidents: list[SecurityIncidentResponse]
total: int = Field(description="Total de incidentes")
page: int = Field(description="Página actual")
per_page: int = Field(description="Incidentes por página")
total_pages: int = Field(description="Total de páginas")
class Config:
from_attributes = True
class AuditLogFilters(BaseModel):
"""
Filtros para consulta de audit logs.
Permite filtrar por m├║ltiples criterios.
"""
# Paginaci├│n
page: int = Field(default=1, ge=1, description="Número de página")
per_page: int = Field(default=50, ge=1, le=100, description="Elementos por página")
# Filtros
user_id: Optional[UUID4] = Field(None, description="Filtrar por usuario")
action: Optional[str] = Field(None, description="Filtrar por acción específica")
resource_type: Optional[str] = Field(None, description="Filtrar por tipo de recurso")
resource_id: Optional[UUID4] = Field(None, description="Filtrar por ID de recurso")
# Rango de fechas
date_from: Optional[datetime] = Field(None, description="Fecha inicio (ISO 8601)")
date_to: Optional[datetime] = Field(None, description="Fecha fin (ISO 8601)")
# B├║squeda
search: Optional[str] = Field(None, description="B├║squeda en acciones o recursos")
class AuditLogStats(BaseModel):
"""
Estadísticas de auditoría.
Resumen de actividad del sistema.
"""
total_actions: int = Field(description="Total de acciones registradas")
actions_today: int = Field(description="Acciones en las ├║ltimas 24 horas")
actions_this_week: int = Field(description="Acciones en los últimos 7 días")
critical_actions_today: int = Field(description="Acciones críticas hoy (delete, cambios sensibles)")
# Top acciones
top_actions: Dict[str, int] = Field(description="Acciones más frecuentes")
# Top usuarios
top_users: Dict[str, int] = Field(description="Usuarios más activos")
# Actividad por tipo de recurso
by_resource_type: Dict[str, int] = Field(description="Acciones por tipo de recurso")
class AuditLogListResponse(BaseModel):
"""
Respuesta paginada de audit logs.
"""
logs: list[AuditLogResponse]
total: int = Field(description="Total de registros")
page: int = Field(description="Página actual")
per_page: int = Field(description="Registros por página")
total_pages: int = Field(description="Total de páginas")
class Config:
from_attributes = True

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,7 @@ from app.core.security import security
from app.core.config import get_settings
from app.models.user import User
from app.models.tenant import Tenant
from app.services.audit_service import AuditService
router = APIRouter()
logger = structlog.get_logger(__name__)
@@ -99,6 +100,23 @@ async def login(
"Login failed - invalid credentials",
email=login_data.email
)
# Registrar intento fallido en auditoría (si el usuario existe)
if user:
try:
await AuditService.log(
db=db,
tenant_id=user.tenant_id,
user_id=None, # Login fallido = sin user_id
action="user.login_failed",
resource_type="user",
resource_id=user.id,
metadata={"email": login_data.email, "reason": "invalid_password"}
)
await db.commit()
except Exception as e:
logger.warning("Failed to log audit entry", error=str(e))
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenciales inválidas"
@@ -126,6 +144,21 @@ async def login(
access_token = security.create_access_token(token_data)
refresh_token = security.create_refresh_token(token_data)
# Registrar login exitoso en auditoría
try:
await AuditService.log(
db=db,
tenant_id=user.tenant_id,
user_id=user.id,
action="user.login",
resource_type="user",
resource_id=user.id,
metadata={"email": user.email, "success": True}
)
await db.commit()
except Exception as e:
logger.warning("Failed to log audit entry", error=str(e))
logger.info(
"Login successful",
email=login_data.email,
@@ -227,6 +260,25 @@ async def logout(
# TODO: Revoke refresh token in database
# Registrar logout en auditoría
try:
import uuid
user_id = uuid.UUID(payload["sub"])
tenant_id = uuid.UUID(payload["tenant_id"])
await AuditService.log(
db=db,
tenant_id=tenant_id,
user_id=user_id,
action="user.logout",
resource_type="user",
resource_id=user_id,
metadata={"email": payload.get("email")}
)
await db.commit()
except Exception as e:
logger.warning("Failed to log audit entry", error=str(e))
logger.info("Logout successful", user_id=payload["sub"])
return {"message": "Successfully logged out"}

View File

@@ -21,6 +21,7 @@ from app.models.comment import TicketComment
from app.models.attachment import TicketAttachment
from app.api.schemas.attachment import AttachmentResponse
from app.core.file_handler import file_handler
from app.services.audit_service import AuditService
import uuid
router = APIRouter()
@@ -78,85 +79,209 @@ async def create_ticket(
"""
Crear un nuevo ticket
"""
try:
# Generar número de ticket único
result = await db.execute(
select(func.count(Ticket.id)).where(Ticket.tenant_id == current_user.tenant_id)
)
count = result.scalar() or 0
ticket_number = f"TK-{count + 1:06d}"
# Generar número de ticket único
result = await db.execute(
select(func.count(Ticket.id)).where(Ticket.tenant_id == current_user.tenant_id)
)
count = result.scalar() or 0
ticket_number = f"TK-{count + 1:06d}"
# Convertir IDs de string a UUID si son proporcionados
category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None
system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None # ✅ CORREGIDO
# ✅ CORREGIDO: Validar en la tabla correcta con el nombre correcto del modelo
if category_uuid:
category = await db.get(Category, category_uuid) # ✅ Category, no TicketCategory
if not category:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"La categoría con ID {ticket.category_id} no existe."
)
# Retry logic para evitar race conditions en generación de ticket_number
max_retries = 3
last_error = None
for attempt in range(max_retries):
try:
# Generar número de ticket único basado en el máximo existente
result = await db.execute(
select(Ticket.ticket_number)
.where(Ticket.tenant_id == current_user.tenant_id)
.order_by(Ticket.ticket_number.desc())
.limit(1)
)
last_ticket_number = result.scalar_one_or_none()
if last_ticket_number:
# Extraer el número del formato TK-XXXXXX
last_number = int(last_ticket_number.split('-')[1])
next_number = last_number + 1
else:
next_number = 1
ticket_number = f"TK-{next_number:06d}"
# Convertir IDs de string a UUID si son proporcionados
category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None
system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None
# Validar categoría
if category_uuid:
category = await db.get(Category, category_uuid)
if not category:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"La categoría con ID {ticket.category_id} no existe."
)
# Validar si el system_id existe en la tabla affected_systems
if system_uuid:
system = await db.get(System, system_uuid)
if not system:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"El sistema con ID {ticket.affected_system_id} no existe."
# Validar sistema
if system_uuid:
system = await db.get(System, system_uuid)
if not system:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"El sistema con ID {ticket.affected_system_id} no existe."
)
db_ticket = Ticket(
id=uuid.uuid4(),
tenant_id=current_user.tenant_id,
ticket_number=ticket_number,
subject=ticket.subject,
description=ticket.description,
category_id=category_uuid,
affected_system_id=system_uuid,
priority=TicketPriority[ticket.priority.upper()],
created_by=current_user.id,
status=TicketStatus.NEW,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
db.add(db_ticket)
await db.commit()
await db.refresh(db_ticket)
# Registrar creación en auditoría
try:
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="ticket.create",
resource_type="ticket",
resource_id=db_ticket.id,
new_values={
"ticket_number": db_ticket.ticket_number,
"subject": db_ticket.subject,
"priority": db_ticket.priority.value,
"status": db_ticket.status.value
}
)
db_ticket = Ticket(
id=uuid.uuid4(),
tenant_id=current_user.tenant_id,
ticket_number=ticket_number,
subject=ticket.subject,
description=ticket.description,
category_id=category_uuid,
affected_system_id=system_uuid, # ✅ CORREGIDO: Nombre correcto del campo
priority=TicketPriority[ticket.priority.upper()],
created_by=current_user.id,
status=TicketStatus.NEW,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
db.add(db_ticket)
await db.commit()
await db.refresh(db_ticket)
# ✅ CORREGIDO: Usar affected_system_id en respuesta
return {
"id": str(db_ticket.id),
"ticket_number": db_ticket.ticket_number,
"subject": db_ticket.subject,
"title": db_ticket.subject,
"description": db_ticket.description,
"status": db_ticket.status.value,
"priority": db_ticket.priority.value,
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
"affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None, # ✅ CORREGIDO
"created_by": str(db_ticket.created_by),
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
"created_at": db_ticket.created_at,
"updated_at": db_ticket.updated_at
await db.commit()
except Exception as e:
# No fallar si falla el audit log
pass
# ✅ Éxito - retornar ticket creado
return {
"id": str(db_ticket.id),
"ticket_number": db_ticket.ticket_number,
"subject": db_ticket.subject,
"title": db_ticket.subject,
"description": db_ticket.description,
"status": db_ticket.status.value,
"priority": db_ticket.priority.value,
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
"affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
"created_by": str(db_ticket.created_by),
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
"created_at": db_ticket.created_at,
"updated_at": db_ticket.updated_at
}
except ValueError as e:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid UUID format: {str(e)}"
)
except HTTPException:
# Re-lanzar HTTPExceptions directamente
await db.rollback()
raise
except Exception as e:
await db.rollback()
last_error = e
# Si es un error de llave duplicada, reintentar
if "duplicate key" in str(e).lower() and "ticket_number" in str(e).lower():
if attempt < max_retries - 1:
continue # Reintentar
# Para cualquier otro error, fallar inmediatamente
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Error creating ticket: {str(e)}"
)
# Si llegamos aquí después de todos los reintentos
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"No se pudo crear el ticket después de {max_retries} intentos: {str(last_error)}"
)
@router.get("/", response_model=List[TicketResponse])
async def get_tickets(
skip: int = 0,
limit: int = 100,
status_filter: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Obtener tickets
Roles ADMIN/SUPPORT_MANAGER/AGENT: Ven todos los tickets del tenant
Roles CLIENT_USER/CLIENT_ADMIN: Solo ven sus propios tickets
"""
# Construir query base filtrado por tenant
query = select(Ticket).where(
Ticket.tenant_id == current_user.tenant_id
)
# Si es cliente, solo puede ver sus propios tickets
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
query = query.where(Ticket.created_by == current_user.id)
if status_filter:
try:
status_enum = TicketStatus[status_filter.upper()]
query = query.where(Ticket.status == status_enum)
except KeyError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid status: {status_filter}"
)
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
tickets = result.scalars().all()
# ✅ CORREGIDO: Usar affected_system_id
return [
{
"id": str(t.id),
"ticket_number": t.ticket_number,
"subject": t.subject,
"title": t.subject,
"description": t.description,
"status": t.status.value,
"priority": t.priority.value,
"category_id": str(t.category_id) if t.category_id else None,
"affected_system_id": str(t.affected_system_id) if t.affected_system_id else None,
"created_by": str(t.created_by),
"assigned_to": str(t.assigned_to) if t.assigned_to else None,
"created_at": t.created_at,
"updated_at": t.updated_at
}
except ValueError as e:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid UUID format: {str(e)}"
)
for t in tickets
]
@router.get("/admin/all", response_model=List[dict])
async def get_all_tickets_admin(
skip: int = 0,
limit: int = 100,
status_filter: Optional[str] = None,
priority_filter: Optional[str] = None,
tenant_id_filter: Optional[str] = None,
category_filter: Optional[str] = None,
assigned_to_filter: Optional[str] = None,
search: Optional[str] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
db: AsyncSession = Depends(get_db),
@@ -311,6 +436,8 @@ async def get_ticket(
):
"""
Obtener un ticket específico
Roles ADMIN/SUPPORT_MANAGER/AGENT: Pueden ver todos los tickets del tenant
Roles CLIENT_USER/CLIENT_ADMIN: Solo pueden ver sus propios tickets
"""
try:
ticket_uuid = uuid.UUID(ticket_id)
@@ -320,12 +447,16 @@ async def get_ticket(
detail="Invalid ticket ID format"
)
# Construir query basado en el rol del usuario
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
Ticket.tenant_id == current_user.tenant_id
)
# Si es cliente, solo puede ver sus propios tickets
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
query = query.where(Ticket.created_by == current_user.id)
result = await db.execute(query)
ticket = result.scalars().first()
@@ -362,6 +493,8 @@ async def update_ticket(
):
"""
Actualizar un ticket
Roles ADMIN/SUPPORT_MANAGER/AGENT: Pueden actualizar cualquier ticket del tenant
Roles CLIENT_USER/CLIENT_ADMIN: Solo pueden actualizar sus propios tickets
"""
try:
ticket_uuid = uuid.UUID(ticket_id)
@@ -371,12 +504,16 @@ async def update_ticket(
detail="Invalid ticket ID format"
)
# Construir query basado en el rol del usuario
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
Ticket.tenant_id == current_user.tenant_id
)
# Si es cliente, solo puede actualizar sus propios tickets
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
query = query.where(Ticket.created_by == current_user.id)
result = await db.execute(query)
db_ticket = result.scalars().first()
@@ -386,6 +523,15 @@ async def update_ticket(
detail=f"Ticket {ticket_id} not found"
)
# Guardar valores anteriores para audit
old_values = {
"subject": db_ticket.subject,
"description": db_ticket.description,
"status": db_ticket.status.value,
"priority": db_ticket.priority.value,
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None
}
try:
update_data = ticket_update.dict(exclude_unset=True)
@@ -404,6 +550,34 @@ async def update_ticket(
await db.commit()
await db.refresh(db_ticket)
# Registrar actualización en auditoría
try:
new_values = {
"subject": db_ticket.subject,
"description": db_ticket.description,
"status": db_ticket.status.value,
"priority": db_ticket.priority.value,
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None
}
# Si cambió assigned_to, registrar como acción de asignación
action = "ticket.assign" if old_values["assigned_to"] != new_values["assigned_to"] else "ticket.update"
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action=action,
resource_type="ticket",
resource_id=db_ticket.id,
old_values=old_values,
new_values=new_values
)
await db.commit()
except Exception as e:
# No fallar si falla el audit log
pass
# ✅ CORREGIDO: Usar affected_system_id
return {
"id": str(db_ticket.id),
@@ -438,6 +612,8 @@ async def close_ticket(
):
"""
Cerrar un ticket
Roles ADMIN/SUPPORT_MANAGER/AGENT: Pueden cerrar cualquier ticket del tenant
Roles CLIENT_USER/CLIENT_ADMIN: Solo pueden cerrar sus propios tickets
"""
try:
ticket_uuid = uuid.UUID(ticket_id)
@@ -447,12 +623,16 @@ async def close_ticket(
detail="Invalid ticket ID format"
)
# Construir query basado en el rol del usuario
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
Ticket.tenant_id == current_user.tenant_id
)
# Si es cliente, solo puede cerrar sus propios tickets
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
query = query.where(Ticket.created_by == current_user.id)
result = await db.execute(query)
db_ticket = result.scalars().first()
@@ -666,9 +846,33 @@ async def delete_ticket(
detail=f"Ticket {ticket_id} not found"
)
# Guardar datos del ticket antes de eliminar para audit
old_values = {
"ticket_number": db_ticket.ticket_number,
"subject": db_ticket.subject,
"status": db_ticket.status.value,
"priority": db_ticket.priority.value
}
await db.delete(db_ticket)
await db.commit()
# Registrar eliminación en auditoría
try:
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="ticket.delete",
resource_type="ticket",
resource_id=ticket_uuid,
old_values=old_values
)
await db.commit()
except Exception as e:
# No fallar si falla el audit log
pass
return {"message": "Ticket deleted successfully"}
# ===================================

View File

@@ -9,6 +9,7 @@ import uuid
from app.core.database import get_db
from app.core.security import security
from app.models.user import User, UserRole
from app.services.audit_service import AuditService
from app.api import deps
router = APIRouter()
@@ -147,6 +148,28 @@ async def create_user(
db.add(db_user)
await db.commit()
await db.refresh(db_user)
# Registrar creación en auditoría
try:
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="user.create",
resource_type="user",
resource_id=db_user.id,
new_values=AuditService.sanitize_values({
"email": db_user.email,
"first_name": db_user.first_name,
"last_name": db_user.last_name,
"role": db_user.role.value
})
)
await db.commit()
except Exception as e:
# No fallar si falla el audit log
pass
return db_user
@@ -214,6 +237,15 @@ async def update_user(
detail="User not found"
)
# Guardar valores anteriores para audit
old_values = {
"email": db_user.email,
"first_name": db_user.first_name,
"last_name": db_user.last_name,
"role": db_user.role.value,
"is_active": db_user.is_active
}
# Verificar email único si se está cambiando
update_data = user_update.model_dump(exclude_unset=True)
if "email" in update_data and update_data["email"] != db_user.email:
@@ -239,6 +271,32 @@ async def update_user(
await db.commit()
await db.refresh(db_user)
# Registrar actualización en auditoría
try:
new_values = {
"email": db_user.email,
"first_name": db_user.first_name,
"last_name": db_user.last_name,
"role": db_user.role.value,
"is_active": db_user.is_active
}
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="user.update",
resource_type="user",
resource_id=db_user.id,
old_values=AuditService.sanitize_values(old_values),
new_values=AuditService.sanitize_values(new_values)
)
await db.commit()
except Exception as e:
# No fallar si falla el audit log
pass
return db_user
@@ -306,6 +364,28 @@ async def delete_user(
# Soft delete
db_user.is_active = False
await db.commit()
# Registrar eliminación en auditoría
try:
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="user.delete",
resource_type="user",
resource_id=db_user.id,
old_values={
"email": db_user.email,
"role": db_user.role.value,
"was_active": True
},
metadata={"action_type": "soft_delete"}
)
await db.commit()
except Exception as e:
# No fallar si falla el audit log
pass
return None

View File

@@ -6,7 +6,7 @@ Router principal para la API v1
from fastapi import APIRouter
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit
api_router = APIRouter()
@@ -59,4 +59,11 @@ api_router.include_router(
client_profile.router,
prefix="/client-profile",
tags=["client-profile"]
)
# Audit routes
api_router.include_router(
audit.router,
prefix="/audit",
tags=["audit"]
)

View File

@@ -27,6 +27,7 @@ class Settings(BaseSettings):
DEBUG: bool = Field(default=False)
SECRET_KEY: str = Field(...)
API_VERSION: str = Field(default="v1")
APP_VERSION: str = Field(default="1.6.0")
# ===================================
# DATABASE

View File

@@ -23,6 +23,8 @@ from app.models.user import User
from app.models.ticket import Ticket
from app.models.comment import TicketComment
from app.models.attachment import TicketAttachment
from app.models.audit import AuditLog
from app.models.refresh_token import RefreshToken
from app.core.logging import setup_logging
from app.api.v1.router import api_router
@@ -54,7 +56,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="ServiceManagerWeb API",
description="Mesa de Ayuda B2B multi-tenant para Aduanasoft",
version=settings.API_VERSION,
version=settings.APP_VERSION,
lifespan=lifespan,
docs_url=f"/{settings.API_VERSION}/docs" if settings.ENVIRONMENT == "development" else None,
redoc_url=f"/{settings.API_VERSION}/redoc" if settings.ENVIRONMENT == "development" else None,
@@ -161,7 +163,8 @@ async def health_check():
return {
"status": "healthy",
"service": "ServiceManagerWeb API",
"version": settings.API_VERSION,
"version": settings.APP_VERSION,
"api_version": settings.API_VERSION,
"environment": settings.ENVIRONMENT
}
@@ -172,7 +175,8 @@ async def root():
"""Endpoint raíz con información básica."""
return {
"service": "ServiceManagerWeb API",
"version": settings.API_VERSION,
"version": settings.APP_VERSION,
"api_version": settings.API_VERSION,
"docs": f"/{settings.API_VERSION}/docs",
"environment": settings.ENVIRONMENT
}

View File

@@ -24,10 +24,17 @@ class TenantMiddleware(BaseHTTPMiddleware):
EXCLUDED_PATHS = {
"/health",
"/",
"/api/v1/auth/login",
"/v1/auth/login",
"/docs",
"/api/v1/docs",
"/v1/docs",
"/openapi.json",
"/redoc"
"/api/v1/openapi.json",
"/v1/openapi.json",
"/redoc",
"/api/v1/redoc",
"/v1/redoc"
}
async def dispatch(self, request: Request, call_next) -> Response:

View File

@@ -8,6 +8,8 @@ from .system import System
from .category import Category
from .client_profile import ClientProfile
from .attachment import TicketAttachment
from .audit import AuditLog
from .refresh_token import RefreshToken
__all__ = [
"User",
@@ -17,5 +19,7 @@ __all__ = [
"System",
"Category",
"ClientProfile",
"TicketAttachment"
"TicketAttachment",
"AuditLog",
"RefreshToken"
]

View File

@@ -0,0 +1,148 @@
"""
Audit Log Model - ServiceManagerWeb
Modelo para bitácora de auditoría y compliance.
Registra todas las acciones importantes del sistema.
"""
from sqlalchemy import String, Text, DateTime, ForeignKey, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, INET, JSONB
from typing import Optional, Dict, Any, TYPE_CHECKING
import uuid
from datetime import datetime
from app.core.database import Base
if TYPE_CHECKING:
from app.models.tenant import Tenant
from app.models.user import User
class AuditLog(Base):
"""
Bitácora de auditoría para tracking completo de acciones.
Registra:
- Qui├®n hizo la acci├│n (user_id)
- Qu├® hizo (action)
- Sobre qu├® recurso (resource_type + resource_id)
- Cuándo lo hizo (created_at)
- Desde d├│nde (ip_address, user_agent)
- Qu├® cambi├│ (old_values, new_values)
"""
__tablename__ = "audit_logs"
# Multi-tenancy
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
index=True
)
# Usuario que ejecut├│ la acci├│n (NULL = acci├│n del sistema)
user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
index=True
)
# Acci├│n realizada (ej: "user.login", "ticket.create", "ticket.assign")
action: Mapped[str] = mapped_column(
String(100),
nullable=False,
index=True
)
# Tipo de recurso afectado (user, ticket, comment, category, etc.)
resource_type: Mapped[str] = mapped_column(
String(50),
nullable=False,
index=True
)
# ID del recurso afectado
resource_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
nullable=True
)
# Contexto de la request
ip_address: Mapped[Optional[str]] = mapped_column(INET, nullable=True)
user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Correlation ID para rastrear requests relacionadas
correlation_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
nullable=True,
index=True
)
# Valores antes del cambio (JSON)
old_values: Mapped[Optional[Dict[str, Any]]] = mapped_column(
JSONB,
nullable=True
)
# Valores despu├®s del cambio (JSON)
new_values: Mapped[Optional[Dict[str, Any]]] = mapped_column(
JSONB,
nullable=True
)
# Metadata adicional (cualquier info relevante)
# Nota: 'metadata' está reservado en SQLAlchemy, usamos 'extra_metadata'
extra_metadata: Mapped[Optional[Dict[str, Any]]] = mapped_column(
'metadata', # Nombre real de la columna en BD
JSONB,
nullable=True
)
# Timestamp
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
nullable=False,
index=True
)
# Relaciones
tenant: Mapped["Tenant"] = relationship("Tenant", foreign_keys=[tenant_id])
user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[user_id])
# Índices compuestos para queries comunes
__table_args__ = (
Index('idx_audit_logs_tenant_action', 'tenant_id', 'action'),
Index('idx_audit_logs_resource', 'resource_type', 'resource_id'),
Index('idx_audit_logs_user_created', 'user_id', 'created_at'),
)
# Configuraci├│n del mapper: excluir updated_at porque audit logs son inmutables
__mapper_args__ = {
"exclude_properties": ["updated_at"]
}
def __repr__(self) -> str:
return f"<AuditLog(action='{self.action}', resource='{self.resource_type}:{self.resource_id}')>"
@property
def action_display(self) -> str:
"""Formato amigable de la acci├│n."""
parts = self.action.split('.')
if len(parts) == 2:
resource, verb = parts
verb_map = {
'create': 'cre├│',
'update': 'actualiz├│',
'delete': 'elimin├│',
'login': 'inici├│ sesi├│n',
'logout': 'cerr├│ sesi├│n',
'assign': 'asign├│',
'close': 'cerr├│',
'reopen': 'reabri├│'
}
return f"{verb_map.get(verb, verb)} {resource}"
return self.action

View File

@@ -0,0 +1,171 @@
"""
Refresh Token Model - ServiceManagerWeb
Modelo para persistencia de refresh tokens con revocaci├│n y tracking.
"""
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Index, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from typing import Optional, TYPE_CHECKING
import uuid
from datetime import datetime
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
class RefreshToken(Base):
"""
Refresh Token persistente para gesti├│n de sesiones.
Almacena refresh tokens con informaci├│n de dispositivo y permite
revocaci├│n para mejorar la seguridad.
Características:
- Token hasheado (no se guarda en texto plano)
- Device fingerprinting
- Revocaci├│n individual con tracking
- Auto-expiraci├│n
- Tracking de IP y uso
"""
__tablename__ = "refresh_tokens"
# User relationship
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True
)
# Token JWT (almacenado directamente - firmado y verificable)
# VARCHAR(500) para acomodar JWTs con payload extenso
token: Mapped[str] = mapped_column(
String(500),
nullable=False,
unique=True
)
# Device information
device_id: Mapped[Optional[str]] = mapped_column(
String(100),
nullable=True
)
device_name: Mapped[Optional[str]] = mapped_column(
String(200),
nullable=True
)
user_agent: Mapped[Optional[str]] = mapped_column(
String(500),
nullable=True
)
# IP address del cliente (varchar(45) para IPv6)
ip_address: Mapped[Optional[str]] = mapped_column(
String(45),
nullable=True
)
# Expiraci├│n del token
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
index=True
)
# Estado de revocaci├│n
revoked: Mapped[bool] = mapped_column(
Boolean,
default=False,
nullable=False
)
revoked_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True
)
revoked_by: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True
)
# Tracking de uso
last_used_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True
)
usage_count: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False
)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
nullable=False,
server_default="NOW()"
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
onupdate=datetime.utcnow,
nullable=False,
server_default="NOW()"
)
# Relaci├│n con usuario
user: Mapped["User"] = relationship("User", foreign_keys=[user_id], back_populates="refresh_tokens")
revoker: Mapped[Optional["User"]] = relationship("User", foreign_keys=[revoked_by])
# Índices compuestos
__table_args__ = (
Index('idx_refresh_tokens_user_expires', 'user_id', 'expires_at'),
)
def __repr__(self) -> str:
return f"<RefreshToken(user_id='{self.user_id}', revoked={self.revoked}, expires={self.expires_at})>"
@property
def is_valid(self) -> bool:
"""
Verificar si el token es válido.
Un token es válido si:
- No está revocado
- No ha expirado
"""
return not self.revoked and self.expires_at > datetime.utcnow()
@property
def is_expired(self) -> bool:
"""Verificar si el token ha expirado."""
return datetime.utcnow() >= self.expires_at
def revoke(self, revoked_by: Optional[uuid.UUID] = None) -> None:
"""
Marcar el token como revocado.
Args:
revoked_by: ID del usuario que revoc├│ el token
"""
self.revoked = True
self.revoked_at = datetime.utcnow()
if revoked_by:
self.revoked_by = revoked_by
def track_usage(self) -> None:
"""Registrar uso del token."""
self.last_used_at = datetime.utcnow()
self.usage_count += 1

View File

@@ -78,6 +78,12 @@ class User(Base):
back_populates="assigned_to_user",
foreign_keys="Ticket.assigned_to"
)
refresh_tokens: Mapped[List["RefreshToken"]] = relationship(
"RefreshToken",
back_populates="user",
foreign_keys="RefreshToken.user_id",
cascade="all, delete-orphan"
)
# Unique constraint por tenant
__table_args__ = (

View File

@@ -0,0 +1,311 @@
"""
Audit Service - ServiceManagerWeb
Funciones helper para facilitar el registro de auditoría.
Simplifica el proceso de logging en toda la aplicaci├│n.
"""
from typing import Optional, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import Request
import uuid
import structlog
from app.models.audit import AuditLog
from app.models.user import User
logger = structlog.get_logger(__name__)
class AuditService:
"""
Servicio centralizado para registro de auditoría.
Uso básico:
await AuditService.log(
db=db,
tenant_id=tenant.id,
user_id=current_user.id,
action="ticket.create",
resource_type="ticket",
resource_id=new_ticket.id,
new_values={"subject": "...", "status": "NEW"}
)
"""
@staticmethod
async def log(
db: AsyncSession,
tenant_id: uuid.UUID,
action: str,
resource_type: str,
resource_id: Optional[uuid.UUID] = None,
user_id: Optional[uuid.UUID] = None,
old_values: Optional[Dict[str, Any]] = None,
new_values: Optional[Dict[str, Any]] = None,
metadata: Optional[Dict[str, Any]] = None,
request: Optional[Request] = None
) -> AuditLog:
"""
Registra una acción en la bitácora de auditoría.
Args:
db: Sesi├│n de base de datos
tenant_id: ID del tenant
action: Acci├│n realizada (formato: "recurso.verbo")
Ejemplos: "user.login", "ticket.create", "ticket.assign"
resource_type: Tipo de recurso ("user", "ticket", "comment", etc.)
resource_id: ID del recurso afectado (opcional)
user_id: ID del usuario que ejecut├│ la acci├│n (opcional = sistema)
old_values: Valores antes del cambio (opcional)
new_values: Valores despu├®s del cambio (opcional)
metadata: Informaci├│n adicional (opcional)
request: Request de FastAPI para extraer IP y user agent (opcional)
Returns:
AuditLog creado
"""
# Extraer información del request si está disponible
ip_address = None
user_agent = None
correlation_id = None
if request:
# IP del cliente
if request.client:
ip_address = request.client.host
# User agent
user_agent = request.headers.get("user-agent")
# Correlation ID (si existe en el request state)
correlation_id = getattr(request.state, "correlation_id", None)
# Crear registro de auditoría
audit_log = AuditLog(
tenant_id=tenant_id,
user_id=user_id,
action=action,
resource_type=resource_type,
resource_id=resource_id,
ip_address=ip_address,
user_agent=user_agent,
correlation_id=correlation_id,
old_values=old_values,
new_values=new_values,
extra_metadata=metadata # Mapeo metadata -> extra_metadata
)
db.add(audit_log)
await db.flush() # No commit, se hará con la transacción principal
# Log estructurado para debugging
logger.info(
"Audit log created",
action=action,
resource_type=resource_type,
resource_id=str(resource_id) if resource_id else None,
user_id=str(user_id) if user_id else "system",
tenant_id=str(tenant_id)
)
return audit_log
@staticmethod
async def log_login(
db: AsyncSession,
user: User,
request: Request,
success: bool = True
) -> AuditLog:
"""
Registra un intento de login.
Args:
db: Sesi├│n de base de datos
user: Usuario que intent├│ loguearse
request: Request de FastAPI
success: Si el login fue exitoso
Returns:
AuditLog creado
"""
return await AuditService.log(
db=db,
tenant_id=user.tenant_id,
user_id=user.id if success else None,
action="user.login" if success else "user.login_failed",
resource_type="user",
resource_id=user.id,
metadata={
"success": success,
"email": user.email
},
request=request
)
@staticmethod
async def log_logout(
db: AsyncSession,
user: User,
request: Request
) -> AuditLog:
"""
Registra un logout.
Args:
db: Sesi├│n de base de datos
user: Usuario que cerr├│ sesi├│n
request: Request de FastAPI
Returns:
AuditLog creado
"""
return await AuditService.log(
db=db,
tenant_id=user.tenant_id,
user_id=user.id,
action="user.logout",
resource_type="user",
resource_id=user.id,
request=request
)
@staticmethod
async def log_create(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
resource_type: str,
resource_id: uuid.UUID,
new_values: Dict[str, Any],
request: Optional[Request] = None
) -> AuditLog:
"""
Registra la creaci├│n de un recurso.
Args:
db: Sesi├│n de base de datos
tenant_id: ID del tenant
user_id: ID del usuario que cre├│ el recurso
resource_type: Tipo de recurso ("ticket", "user", etc.)
resource_id: ID del recurso creado
new_values: Valores del nuevo recurso
request: Request de FastAPI (opcional)
Returns:
AuditLog creado
"""
return await AuditService.log(
db=db,
tenant_id=tenant_id,
user_id=user_id,
action=f"{resource_type}.create",
resource_type=resource_type,
resource_id=resource_id,
new_values=new_values,
request=request
)
@staticmethod
async def log_update(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
resource_type: str,
resource_id: uuid.UUID,
old_values: Dict[str, Any],
new_values: Dict[str, Any],
request: Optional[Request] = None
) -> AuditLog:
"""
Registra la actualizaci├│n de un recurso.
Args:
db: Sesi├│n de base de datos
tenant_id: ID del tenant
user_id: ID del usuario que actualiz├│
resource_type: Tipo de recurso
resource_id: ID del recurso
old_values: Valores anteriores
new_values: Valores nuevos
request: Request de FastAPI (opcional)
Returns:
AuditLog creado
"""
return await AuditService.log(
db=db,
tenant_id=tenant_id,
user_id=user_id,
action=f"{resource_type}.update",
resource_type=resource_type,
resource_id=resource_id,
old_values=old_values,
new_values=new_values,
request=request
)
@staticmethod
async def log_delete(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
resource_type: str,
resource_id: uuid.UUID,
old_values: Dict[str, Any],
request: Optional[Request] = None
) -> AuditLog:
"""
Registra la eliminaci├│n de un recurso.
Args:
db: Sesi├│n de base de datos
tenant_id: ID del tenant
user_id: ID del usuario que elimin├│
resource_type: Tipo de recurso
resource_id: ID del recurso eliminado
old_values: Valores del recurso antes de eliminar
request: Request de FastAPI (opcional)
Returns:
AuditLog creado
"""
return await AuditService.log(
db=db,
tenant_id=tenant_id,
user_id=user_id,
action=f"{resource_type}.delete",
resource_type=resource_type,
resource_id=resource_id,
old_values=old_values,
request=request
)
@staticmethod
def sanitize_values(values: Dict[str, Any]) -> Dict[str, Any]:
"""
Sanitiza valores sensibles antes de guardarlos en audit log.
Remueve campos como passwords, tokens, etc.
Args:
values: Diccionario de valores
Returns:
Diccionario sanitizado
"""
sensitive_fields = {
'password',
'password_hash',
'totp_secret',
'backup_codes',
'token',
'access_token',
'refresh_token'
}
return {
key: '***REDACTED***' if key in sensitive_fields else value
for key, value in values.items()
}

View File

@@ -0,0 +1,270 @@
"""
Token Service - ServiceManagerWeb
Servicio para gesti├│n de refresh tokens persistentes.
"""
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from datetime import datetime, timedelta
from typing import Optional
import uuid
import structlog
from app.models.refresh_token import RefreshToken
from app.models.user import User
from app.core.config import get_settings
logger = structlog.get_logger(__name__)
settings = get_settings()
class TokenService:
"""
Servicio para gesti├│n de refresh tokens.
Proporciona m├®todos para crear, validar, revocar y limpiar
refresh tokens persistentes.
NOTA: Los tokens se almacenan directamente en BD (no hash)
ya que los JWTs son firmados y verificables.
"""
@staticmethod
async def create_refresh_token(
db: AsyncSession,
user: User,
refresh_token: str,
device_id: Optional[str] = None,
device_name: Optional[str] = None,
user_agent: Optional[str] = None,
ip_address: Optional[str] = None
) -> RefreshToken:
"""
Crear y persistir un refresh token.
Args:
db: Sesi├│n de base de datos
user: Usuario propietario del token
refresh_token: Token JWT generado (se almacena directamente)
device_id: ID ├║nico del dispositivo (UUID generado por cliente)
device_name: Nombre del dispositivo (ej: "Chrome en Windows")
user_agent: User agent completo del navegador
ip_address: IP del cliente
Returns:
RefreshToken creado
"""
# Calcular expiraci├│n
expires_at = datetime.utcnow() + timedelta(
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
)
# Crear registro - almacena JWT directamente (columna UNIQUE)
db_token = RefreshToken(
user_id=user.id,
token=refresh_token, # JWT almacenado directamente
device_id=device_id,
device_name=device_name,
user_agent=user_agent,
ip_address=ip_address,
expires_at=expires_at,
revoked=False,
usage_count=0
)
db.add(db_token)
await db.flush()
logger.info(
"Refresh token created",
user_id=str(user.id),
token_id=str(db_token.id),
device_name=device_name,
expires_at=expires_at.isoformat()
)
return db_token
@staticmethod
async def verify_refresh_token(
db: AsyncSession,
refresh_token: str
) -> Optional[RefreshToken]:
"""
Verificar que el refresh token exista y sea válido.
Busca el JWT directamente en la BD y verifica su estado.
Args:
db: Sesi├│n de base de datos
refresh_token: Token JWT a verificar
Returns:
RefreshToken si es válido, None si no existe o está revocado/expirado
"""
# Buscar token directamente en BD (sin hash)
query = select(RefreshToken).where(
RefreshToken.token == refresh_token
)
result = await db.execute(query)
db_token = result.scalar_one_or_none()
if not db_token:
logger.warning("Refresh token not found in database")
return None
# Verificar si es válido (usa property is_valid del modelo)
if not db_token.is_valid:
logger.warning(
"Invalid refresh token",
token_id=str(db_token.id),
revoked=db_token.revoked,
expired=db_token.is_expired
)
return None
# Actualizar estadísticas de uso
db_token.track_usage()
await db.flush()
logger.info(
"Refresh token verified and usage tracked",
token_id=str(db_token.id),
usage_count=db_token.usage_count
)
return db_token
@staticmethod
async def revoke_token(
db: AsyncSession,
refresh_token: str,
revoked_by_user_id: Optional[uuid.UUID] = None
) -> bool:
"""
Revocar un refresh token específico.
Args:
db: Sesi├│n de base de datos
refresh_token: Token JWT a revocar
revoked_by_user_id: ID del usuario que revoca (para auditoría)
Returns:
True si se revoc├│, False si no se encontr├│
"""
# Buscar token directamente (sin hash)
query = select(RefreshToken).where(
RefreshToken.token == refresh_token
)
result = await db.execute(query)
db_token = result.scalar_one_or_none()
if not db_token:
logger.warning("Refresh token not found for revocation")
return False
# Revocar usando m├®todo del modelo
db_token.revoke(revoked_by=revoked_by_user_id)
await db.flush()
logger.info(
"Refresh token revoked",
token_id=str(db_token.id),
revoked_by=str(revoked_by_user_id) if revoked_by_user_id else None
)
return True
@staticmethod
async def revoke_all_user_tokens(
db: AsyncSession,
user_id: uuid.UUID,
revoked_by_user_id: Optional[uuid.UUID] = None
) -> int:
"""
Revocar todos los tokens activos de un usuario.
Útil para logout en todos los dispositivos.
Args:
db: Sesi├│n de base de datos
user_id: ID del usuario
revoked_by_user_id: ID del usuario que ejecuta la revocación (para auditoría)
Returns:
N├║mero de tokens revocados
"""
# Buscar todos los tokens activos del usuario
query = select(RefreshToken).where(
RefreshToken.user_id == user_id,
RefreshToken.revoked == False
)
result = await db.execute(query)
tokens = result.scalars().all()
count = 0
for token in tokens:
token.revoke(revoked_by=revoked_by_user_id)
count += 1
await db.flush()
logger.info(
"All user tokens revoked",
user_id=str(user_id),
count=count,
revoked_by=str(revoked_by_user_id) if revoked_by_user_id else None
)
return count
@staticmethod
async def cleanup_expired_tokens(
db: AsyncSession
) -> int:
"""
Eliminar tokens expirados de la base de datos.
Tarea de mantenimiento para limpiar tokens antiguos.
Args:
db: Sesi├│n de base de datos
Returns:
N├║mero de tokens eliminados
"""
# Eliminar tokens expirados hace más de 7 días
cutoff_date = datetime.utcnow() - timedelta(days=7)
query = delete(RefreshToken).where(
RefreshToken.expires_at < cutoff_date
)
result = await db.execute(query)
await db.flush()
deleted_count = result.rowcount
logger.info("Expired tokens cleaned up", count=deleted_count)
return deleted_count
@staticmethod
async def get_user_tokens(
db: AsyncSession,
user_id: uuid.UUID
) -> list[RefreshToken]:
"""
Obtener todos los tokens activos de un usuario.
Args:
db: Sesi├│n de base de datos
user_id: ID del usuario
Returns:
Lista de RefreshTokens activos
"""
query = select(RefreshToken).where(
RefreshToken.user_id == user_id,
RefreshToken.revoked == False,
RefreshToken.expires_at > datetime.utcnow()
).order_by(RefreshToken.created_at.desc())
result = await db.execute(query)
return list(result.scalars().all())

View File

@@ -0,0 +1,67 @@
"""
Script para crear/actualizar usuario de prueba con contraseña conocida
"""
import asyncio
from sqlalchemy import select, update
from app.core.database import AsyncSessionLocal
from app.core.security import security
from app.models.user import User, UserRole
from app.models.tenant import Tenant
import uuid
async def create_test_user():
async with AsyncSessionLocal() as db:
# Buscar tenant
tenant_query = select(Tenant).where(Tenant.slug.like('%aduanasoft%')).limit(1)
result = await db.execute(tenant_query)
tenant = result.scalar_one_or_none()
if not tenant:
print("❌ No se encontró tenant")
return
print(f"✅ Tenant encontrado: {tenant.name} ({tenant.slug})")
# Buscar o crear usuario admin
user_query = select(User).where(
User.email == "admin@aduanasoft.com",
User.tenant_id == tenant.id
)
result = await db.execute(user_query)
user = result.scalar_one_or_none()
# Hash de la contraseña "admin123"
password_hash = security.hash_password("admin123")
if user:
# Actualizar contraseña
user.password_hash = password_hash
user.is_active = True
user.email_verified = True
await db.commit()
print(f"✅ Usuario actualizado: {user.email}")
else:
# Crear usuario nuevo
user = User(
id=uuid.uuid4(),
tenant_id=tenant.id,
email="admin@aduanasoft.com",
first_name="Admin",
last_name="Sistema",
password_hash=password_hash,
role=UserRole.ADMIN,
is_active=True,
email_verified=True
)
db.add(user)
await db.commit()
print(f"✅ Usuario creado: {user.email}")
print(f"\n📋 Credenciales de prueba:")
print(f" Email: admin@aduanasoft.com")
print(f" Password: admin123")
print(f" Tenant: {tenant.slug}")
print(f" Role: ADMIN")
if __name__ == "__main__":
asyncio.run(create_test_user())

View File

@@ -0,0 +1,81 @@
"""add_audit_logs_table
Revision ID: a1b2c3d4e5f6
Revises: 13362e8c493a
Create Date: 2026-02-12 10:00:00.000000
Registra el modelo AuditLog en Alembic.
La tabla audit_logs ya existe en schema.sql, esta migraci├│n solo
la registra en el control de versiones de Alembic.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'a1b2c3d4e5f6'
down_revision = '13362e8c493a'
branch_labels = None
depends_on = None
def upgrade():
"""
Verificar que audit_logs existe y registrarla en Alembic.
La tabla fue creada por schema.sql, esta migraci├│n solo verifica
que exista y está disponible para usar.
"""
from sqlalchemy import inspect
bind = op.get_bind()
inspector = inspect(bind)
tables = inspector.get_table_names()
if 'audit_logs' in tables:
print(" Tabla audit_logs encontrada (creada por schema.sql)")
print(" Modelo AuditLog registrado en Alembic")
# Verificar que tenga los índices necesarios
existing_indexes = [idx['name'] for idx in inspector.get_indexes('audit_logs')]
missing_indexes = []
required_indexes = [
'idx_audit_logs_tenant_id',
'idx_audit_logs_user_id',
'idx_audit_logs_action',
'idx_audit_logs_correlation_id',
'idx_audit_logs_created_at'
]
for idx in required_indexes:
if idx not in existing_indexes:
missing_indexes.append(idx)
if missing_indexes:
print(f"ÔÜá´©Å ├ìndices faltantes: {', '.join(missing_indexes)}")
print(" (Esto es normal si usaste schema.sql completo)")
else:
print("Ô£à Todos los ├¡ndices necesarios est├ín presentes")
else:
print("ÔÜá´©Å La tabla audit_logs NO existe")
print(" Ejecuta: docker-compose exec -T postgres psql -U postgres -d servicemanager < db/schema.sql")
print(" O crea la tabla manualmente desde schema.sql")
# No crear la tabla aquí - debe venir de schema.sql para mantener consistencia
raise Exception(
"La tabla audit_logs no existe. "
"Por favor ejecuta el schema.sql completo primero."
)
def downgrade():
"""
No eliminar la tabla - fue creada por schema.sql.
Solo des-registrar de Alembic.
"""
print("Ôä╣´©Å Tabla audit_logs NO ser├í eliminada (creada por schema.sql)")
print(" Modelo AuditLog des-registrado de Alembic")

View File

@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "servicemanager-backend"
version = "0.1.0"
version = "1.6.0"
description = "ServiceManagerWeb Backend - Mesa de Ayuda B2B"
authors = [
{name = "Aduanasoft", email = "dev@aduanasoft.com"}

View File

@@ -369,10 +369,14 @@ CREATE TABLE audit_logs (
CREATE INDEX idx_audit_logs_tenant_id ON audit_logs(tenant_id);
CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id);
CREATE INDEX idx_audit_logs_action ON audit_logs(action);
CREATE INDEX idx_audit_logs_resource ON audit_logs(resource_type, resource_id);
CREATE INDEX idx_audit_logs_correlation_id ON audit_logs(correlation_id);
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at);
-- Índices compuestos para queries comunes de auditoría
CREATE INDEX idx_audit_logs_tenant_action ON audit_logs(tenant_id, action);
CREATE INDEX idx_audit_logs_resource ON audit_logs(resource_type, resource_id);
CREATE INDEX idx_audit_logs_user_created ON audit_logs(user_id, created_at);
-- ===================================
-- FUNCIONES Y TRIGGERS
-- ===================================

View File

@@ -1,6 +1,6 @@
{
"name": "@servicemanager/client-frontend",
"version": "0.1.0",
"version": "1.6.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { auth } from '$lib/stores/auth.js';
import Icon from './Icon.svelte';
@@ -17,8 +18,8 @@
}
function handleLogout() {
auth.logout();
isMenuOpen = false;
auth.logout(); // El store maneja la redirección automática
}
</script>

View File

@@ -1,5 +1,5 @@
import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
import { writable } from 'svelte/store';
// Types
export interface User {
@@ -49,13 +49,13 @@ function createAuthStore() {
return {
subscribe,
// Initialize auth from localStorage
init: () => {
if (typeof window !== 'undefined') {
const token = localStorage.getItem('auth_token');
const user = localStorage.getItem('auth_user');
if (token && user) {
try {
const parsedUser = JSON.parse(user);
@@ -77,7 +77,7 @@ function createAuthStore() {
// Login
login: async (credentials: LoginRequest): Promise<void> => {
update(state => ({ ...state, isLoading: true }));
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
@@ -93,7 +93,7 @@ function createAuthStore() {
}
const data: LoginResponse = await response.json();
// Store auth data
if (typeof window !== 'undefined') {
localStorage.setItem('auth_token', data.access_token);
@@ -117,6 +117,8 @@ function createAuthStore() {
if (typeof window !== 'undefined') {
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
// Immediate redirect after cleanup
window.location.href = '/login';
}
set(initialState);
},

View File

@@ -23,6 +23,11 @@
<slot />
</main>
<!-- Footer with version -->
<footer class="py-4 text-center border-t border-gray-200 bg-white">
<p class="text-xs text-gray-400">ServiceManagerWeb v1.6.0 · © 2026 Aduanasoft</p>
</footer>
<!-- Toast notifications -->
{#each $toast.toasts as toastMessage (toastMessage.id)}
<Toast

View File

@@ -1,6 +1,6 @@
{
"name": "@servicemanager/internal-frontend",
"version": "0.1.0",
"version": "1.6.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,8 +1,9 @@
<script>
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
import { createEventDispatcher } from 'svelte';
export let open = false;
export let title = '';
export let size = 'lg'; // sm, md, lg, xl, 2xl
const dispatch = createEventDispatcher();
@@ -15,6 +16,20 @@
close();
}
}
function handleBackdropClick(e) {
if (e.target === e.currentTarget) {
close();
}
}
const sizeClasses = {
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl'
};
</script>
<svelte:window on:keydown={handleKeydown}/>
@@ -23,21 +38,45 @@
<div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" aria-hidden="true" on:click={close}></div>
<!-- Backdrop -->
<div
class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75"
aria-hidden="true"
on:click={handleBackdropClick}
></div>
<!-- Center trick -->
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div class="inline-block px-4 pt-5 pb-4 overflow-hidden text-left align-bottom transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
<h3 class="text-lg leading-6 font-medium text-gray-900" id="modal-title">
{title}
</h3>
<div class="mt-2 text-sm text-gray-500">
<slot />
</div>
</div>
<!-- Modal panel -->
<div class="inline-block w-full align-bottom bg-white rounded-lg shadow-xl transform transition-all sm:my-8 sm:align-middle {sizeClasses[size]} sm:w-full">
<!-- Header -->
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900" id="modal-title">
{title}
</h3>
<button
type="button"
on:click={close}
class="text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-lg p-1"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Body -->
<div class="px-6 py-4 max-h-[70vh] overflow-y-auto">
<slot />
</div>
<!-- Footer (optional) -->
{#if $$slots.footer}
<div class="px-6 py-4 bg-gray-50 border-t border-gray-200 rounded-b-lg">
<slot name="footer" />
</div>
{/if}
</div>
</div>
</div>

View File

@@ -62,7 +62,21 @@
name: 'Auditoría',
href: '/audit',
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z'
}
},
{
name: 'Seguridad',
href: '/audit/security',
icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z'
}
);
}
return baseNavigation;
}
function isCurrentPage(href: string) {
return $page.url.pathname === href || ($page.url.pathname.startsWith(href) && href !== '/');
}
</script>
<!-- Mobile sidebar backdrop -->
@@ -169,5 +183,10 @@
</div>
</div>
</div>
<!-- Version info -->
<div class="px-4 py-2 border-t border-gray-100">
<p class="text-xs text-gray-400 text-center">v1.6.0</p>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,532 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/utils/api';
import { toast } from '$lib/stores/toast';
import { auth } from '$lib/stores/auth';
import Modal from '$lib/components/Modal.svelte';
// Estado
let isLoading = false;
let analysis = null;
let analysisHours = 24;
let selectedThreat = null;
let showActionModal = false;
let actionType = '';
let actionTarget = '';
let actionReason = '';
let actionDuration = 60;
// Usuario actual
$: currentUser = $auth.user;
$: canExecuteActions = currentUser && (currentUser.role === 'ADMIN' || currentUser.role === 'SUPPORT_MANAGER');
/**
* Cargar análisis de seguridad
*/
async function loadSecurityAnalysis() {
isLoading = true;
try {
analysis = await api.get('/audit/security/analysis', { hours: analysisHours });
console.log('Security analysis loaded:', analysis);
} catch (e: any) {
toast.error('Error cargando análisis de seguridad: ' + (e.message || 'Error desconocido'));
} finally {
isLoading = false;
}
}
/**
* Cambiar período de análisis
*/
function changeAnalysisPeriod(hours: number) {
analysisHours = hours;
loadSecurityAnalysis();
}
/**
* Obtener color según nivel de riesgo (escala de grises)
*/
function getRiskColor(level: string) {
const colors: any = {
safe: 'bg-gray-100 text-gray-800 border-gray-300',
low: 'bg-gray-200 text-gray-800 border-gray-400',
medium: 'bg-gray-400 text-white border-gray-500',
high: 'bg-gray-600 text-white border-gray-700',
critical: 'bg-gray-900 text-white border-gray-900'
};
return colors[level] || colors.low;
}
/**
* Obtener color de severidad de amenaza (escala de grises)
*/
function getSeverityColor(severity: string) {
const colors: any = {
low: 'bg-gray-200 text-gray-800',
medium: 'bg-gray-400 text-white',
high: 'bg-gray-600 text-white',
critical: 'bg-gray-900 text-white'
};
return colors[severity] || colors.low;
}
/**
* Obtener icono de tipo de amenaza
*/
function getThreatIcon(type: string) {
const icons: any = {
brute_force_attack: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z',
privilege_escalation: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
mass_deletion: 'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16',
account_compromise: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z'
};
return icons[type] || icons.account_compromise;
}
/**
* Obtener texto legible del tipo de amenaza
*/
function getThreatTypeText(type: string) {
const texts: any = {
brute_force_attack: 'Ataque de Fuerza Bruta',
privilege_escalation: 'Escalada de Privilegios',
mass_deletion: 'Eliminación Masiva',
account_compromise: 'Cuenta Comprometida'
};
return texts[type] || type;
}
/**
* Abrir modal para acción de seguridad
*/
function openActionModal(threat: any, action: string) {
if (!canExecuteActions) {
toast.error('No tienes permisos para ejecutar acciones de seguridad');
return;
}
selectedThreat = threat;
actionType = action;
// Pre-llenar campos según el tipo de acción
if (action === 'block_ip' && threat.affected_ips.length > 0) {
actionTarget = threat.affected_ips[0];
actionReason = `Bloqueo automático: ${threat.description}`;
} else if (action === 'force_password_reset' && threat.affected_users.length > 0) {
actionTarget = threat.affected_users[0];
actionReason = `Reseteo de seguridad: ${threat.description}`;
} else {
actionTarget = '';
actionReason = threat.description;
}
showActionModal = true;
}
/**
* Ejecutar acción de seguridad
*/
async function executeSecurityAction() {
if (!actionTarget || !actionReason) {
toast.error('Completa todos los campos requeridos');
return;
}
try {
const response = await api.post('/audit/security/action', {
action_type: actionType,
target: actionTarget,
reason: actionReason,
duration_minutes: actionDuration
});
if (response.success) {
toast.success(response.message);
showActionModal = false;
loadSecurityAnalysis(); // Recargar análisis
} else {
toast.error(response.message);
}
} catch (e: any) {
toast.error('Error ejecutando acción: ' + (e.message || 'Error desconocido'));
}
}
/**
* Formatear fecha
*/
function formatDate(dateString: string) {
const date = new Date(dateString);
return new Intl.DateTimeFormat('es-MX', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'UTC',
timeZoneName: 'short'
}).format(date);
}
onMount(() => {
loadSecurityAnalysis();
});
</script>
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<!-- Header -->
<div class="mb-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center gap-2">
<svg class="w-8 h-8 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
Análisis de Seguridad
</h1>
<p class="mt-1 text-sm text-gray-500">
Detección de amenazas y análisis de vulnerabilidades
</p>
</div>
<button
on:click={() => loadSecurityAnalysis()}
class="px-4 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-500 flex items-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Actualizar
</button>
</div>
</div>
<!-- Selector de Período -->
<div class="bg-white shadow rounded-lg p-4 mb-6">
<div class="flex items-center gap-2 mb-2">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm font-medium text-gray-700">Período de Análisis</span>
</div>
<div class="flex flex-wrap gap-2">
<button
on:click={() => changeAnalysisPeriod(24)}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 24 ? 'bg-gray-700 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}"
>
Últimas 24 horas
</button>
<button
on:click={() => changeAnalysisPeriod(48)}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 48 ? 'bg-gray-700 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}"
>
Últimas 48 horas
</button>
<button
on:click={() => changeAnalysisPeriod(168)}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 168 ? 'bg-gray-700 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}"
>
Última semana
</button>
</div>
</div>
{#if isLoading}
<div class="flex justify-center items-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-600"></div>
</div>
{:else if analysis}
<!-- Resumen de Riesgo -->
<div class="bg-white shadow rounded-lg p-6 mb-6 border-l-4 {getRiskColor(analysis.overall_risk_level)}">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-900">Nivel de Riesgo General</h3>
<p class="text-sm text-gray-600 mt-1">Análisis de {analysis.analysis_period_hours} horas</p>
</div>
<div class="text-right">
<span class="inline-block px-4 py-2 text-2xl font-bold rounded-lg {getRiskColor(analysis.overall_risk_level)}">
{analysis.overall_risk_level.toUpperCase()}
</span>
<p class="text-xs text-gray-500 mt-1">Generado: {formatDate(analysis.generated_at)}</p>
</div>
</div>
</div>
<!-- Estadísticas Rápidas -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-lg shadow p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">Amenazas Detectadas</p>
<p class="text-2xl font-bold text-gray-800">{analysis.total_threats_detected}</p>
</div>
<svg class="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
</div>
<div class="bg-white rounded-lg shadow p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">Intentos Fallidos</p>
<p class="text-2xl font-bold text-gray-700">{analysis.failed_login_attempts}</p>
</div>
<svg class="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
</div>
<div class="bg-white rounded-lg shadow p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">IPs Sospechosas</p>
<p class="text-2xl font-bold text-gray-700">{analysis.suspicious_ips_count}</p>
</div>
<svg class="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
</div>
</div>
<div class="bg-white rounded-lg shadow p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">Acciones Críticas</p>
<p class="text-2xl font-bold text-gray-700">{analysis.critical_actions_count}</p>
</div>
<svg class="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
</div>
</div>
<!-- Recomendaciones Generales -->
{#if analysis.recommended_actions && analysis.recommended_actions.length > 0}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
<div class="flex items-start gap-3">
<svg class="w-6 h-6 text-gray-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="flex-1">
<h4 class="text-sm font-semibold text-gray-900 mb-2">Acciones Recomendadas</h4>
<ul class="space-y-1">
{#each analysis.recommended_actions as action}
<li class="text-sm text-gray-700 flex items-start gap-2">
<svg class="w-4 h-4 text-gray-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
{action}
</li>
{/each}
</ul>
</div>
</div>
</div>
{/if}
<!-- Lista de Amenazas -->
{#if analysis.threats && analysis.threats.length > 0}
<div class="space-y-4">
<h3 class="text-lg font-semibold text-gray-900">Amenazas Detectadas</h3>
{#each analysis.threats as threat}
<div class="bg-white shadow rounded-lg p-6 border-l-4 {threat.severity === 'critical' ? 'border-gray-900' : threat.severity === 'high' ? 'border-gray-600' : threat.severity === 'medium' ? 'border-gray-400' : 'border-gray-200'}">
<!-- Header de Amenaza -->
<div class="flex items-start justify-between mb-4">
<div class="flex items-start gap-3 flex-1">
<div class="p-2 rounded-lg {threat.severity === 'critical' ? 'bg-gray-100' : threat.severity === 'high' ? 'bg-gray-100' : threat.severity === 'medium' ? 'bg-gray-100' : 'bg-gray-50'}">
<svg class="w-6 h-6 {threat.severity === 'critical' ? 'text-gray-900' : threat.severity === 'high' ? 'text-gray-700' : threat.severity === 'medium' ? 'text-gray-600' : 'text-gray-500'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={getThreatIcon(threat.type)} />
</svg>
</div>
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<h4 class="text-lg font-semibold text-gray-900">{getThreatTypeText(threat.type)}</h4>
<span class="px-2 py-1 text-xs font-semibold rounded-full {getSeverityColor(threat.severity)}">
{threat.severity.toUpperCase()}
</span>
</div>
<p class="text-sm text-gray-700">{threat.description}</p>
</div>
</div>
</div>
<!-- Detalles -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4 text-sm">
<div>
<span class="font-medium text-gray-600">Ocurrencias:</span>
<span class="ml-2 text-gray-900 font-semibold">{threat.occurrences}</span>
</div>
<div>
<span class="font-medium text-gray-600">Primera detección:</span>
<span class="ml-2 text-gray-900">{formatDate(threat.first_seen)}</span>
</div>
<div>
<span class="font-medium text-gray-600">Última detección:</span>
<span class="ml-2 text-gray-900">{formatDate(threat.last_seen)}</span>
</div>
</div>
<!-- IPs y Usuarios Afectados -->
{#if threat.affected_ips.length > 0 || threat.affected_users.length > 0}
<div class="mb-4 text-sm">
{#if threat.affected_ips.length > 0}
<div class="mb-2">
<span class="font-medium text-gray-600">IPs involucradas:</span>
<div class="mt-1 flex flex-wrap gap-1">
{#each threat.affected_ips as ip}
<code class="px-2 py-1 bg-gray-100 rounded text-xs font-mono">{ip}</code>
{/each}
</div>
</div>
{/if}
{#if threat.affected_users.length > 0}
<div>
<span class="font-medium text-gray-600">Usuarios afectados:</span>
<div class="mt-1 flex flex-wrap gap-1">
{#each threat.affected_users as user}
<span class="px-2 py-1 bg-gray-100 rounded text-xs">{user}</span>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<!-- Recomendaciones -->
{#if threat.recommendations && threat.recommendations.length > 0}
<div class="bg-gray-50 rounded-lg p-3 mb-4">
<p class="text-xs font-semibold text-gray-700 mb-2">Recomendaciones:</p>
<ul class="space-y-1">
{#each threat.recommendations as rec}
<li class="text-xs text-gray-600 flex items-start gap-2">
<svg class="w-3 h-3 text-gray-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
{rec}
</li>
{/each}
</ul>
</div>
{/if}
<!-- Acciones -->
{#if canExecuteActions}
<div class="flex flex-wrap gap-2">
{#if threat.affected_ips.length > 0}
<button
on:click={() => openActionModal(threat, 'block_ip')}
class="px-3 py-1.5 bg-gray-800 text-white text-sm rounded hover:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-gray-500 flex items-center gap-1"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
Bloquear IP
</button>
{/if}
{#if threat.affected_users.length > 0}
<button
on:click={() => openActionModal(threat, 'force_password_reset')}
class="px-3 py-1.5 bg-gray-600 text-white text-sm rounded hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 flex items-center gap-1"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
Resetear Contraseña
</button>
{/if}
<button
on:click={() => openActionModal(threat, 'notify_admin')}
class="px-3 py-1.5 bg-gray-500 text-white text-sm rounded hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-500 flex items-center gap-1"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
Notificar Admin
</button>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<!-- No hay amenazas -->
<div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
<svg class="w-16 h-16 text-gray-500 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Sistema Seguro</h3>
<p class="text-sm text-gray-600">No se detectaron amenazas en el período analizado</p>
</div>
{/if}
{/if}
</div>
<!-- Modal de Acción de Seguridad -->
{#if showActionModal}
<Modal open={showActionModal} size="lg" title="Ejecutar Acción de Seguridad" on:close={() => showActionModal = false}>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Tipo de Acción</label>
<input
type="text"
bind:value={actionType}
readonly
class="block w-full rounded-md border-gray-300 bg-gray-50 shadow-sm sm:text-sm"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Objetivo {#if actionType === 'block_ip'}(IP){:else if actionType === 'force_password_reset'}(Email){/if}
</label>
<input
type="text"
bind:value={actionTarget}
placeholder="IP o email del usuario"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500 sm:text-sm"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Razón</label>
<textarea
bind:value={actionReason}
rows="3"
placeholder="Razón de la acción de seguridad"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500 sm:text-sm"
></textarea>
</div>
{#if actionType === 'block_ip'}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Duración del Bloqueo (minutos)</label>
<input
type="number"
bind:value={actionDuration}
min="1"
max="10080"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500 sm:text-sm"
/>
</div>
{/if}
<div class="flex justify-end gap-3 pt-4 border-t">
<button
on:click={() => showActionModal = false}
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-500"
>
Cancelar
</button>
<button
on:click={executeSecurityAction}
class="px-4 py-2 text-sm font-medium text-white bg-gray-700 rounded-md hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-500"
>
Ejecutar Acción
</button>
</div>
</div>
</Modal>
{/if}