Compare commits

..

16 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
2033a35a2b Version con fallas 2026-02-12 14:00:04 -07:00
51 changed files with 3197 additions and 1917 deletions

26
.gitignore vendored
View File

@@ -30,29 +30,3 @@ docker-compose.override.yml
# Uploads
uploads/
# Test Coverage
htmlcov/
.coverage
*.cover
.pytest_cache/
# Backups
backups/
*.backup
*.bak
# Temporary files
temp_*.txt
temp_*.py
*.tmp
*.swp
*~
# Debug/Test scripts (usar scripts/ en su lugar)
check_*.py
fix_*.py
list_*.py
add_*.py
set_*.py
test_*.ps1

View File

@@ -1,49 +0,0 @@
# CHANGELOG - ServiceManagerWeb
## [1.5.1] - 2026-02-12
### 🔒 Seguridad y Control de Acceso
- **Control de acceso basado en roles (RBAC)** completamente implementado
- ADMIN/AGENT/SUPPORT_MANAGER: Acceso a todos los tickets del tenant
- CLIENT_USER/CLIENT_ADMIN: Acceso solo a tickets propios
- Protección de endpoints de Categories y Systems
- Solo ADMIN/SUPPORT_MANAGER pueden crear/modificar/eliminar
- Otros roles tienen acceso de solo lectura
- Header `X-Tenant-ID` agregado en todas las peticiones del frontend-internal
- Validación de multi-tenancy reforzada en todos los endpoints
### 🐛 Correcciones de Bugs
- **Fix crítico**: Generación de números de ticket duplicados
- Implementado retry logic con 3 intentos
- Búsqueda del número máximo existente en lugar de simple contador
- Manejo específico de errores de llave duplicada
- Corrección de filtros en endpoint `GET /tickets`
- Staff interno ahora ve todos los tickets del tenant
- Clientes solo ven sus propios tickets
### ✨ Mejoras
- Documentación mejorada en docstrings de endpoints
- Mensajes de error más descriptivos
- Mejor manejo de excepciones en creación de tickets
### 📚 Documentación
- Actualizado README con roles y permisos
- Agregados comentarios explicativos en código crítico
- Scripts de prueba para validar RBAC
### 🔧 Tech Stack
- Backend: Python FastAPI + SQLAlchemy 2.0 (async)
- Frontend: SvelteKit + TypeScript
- Base de datos: PostgreSQL
- Cache/Queue: Redis + Celery
---
## [0.1.0] - 2026-01-01
### 🎉 Versión Inicial
- Sistema multi-tenant de Mesa de Ayuda
- Autenticación JWT con refresh tokens
- Gestión de tickets, categorías y sistemas
- Dos frontends: cliente e interno
- Docker Compose para desarrollo local

View File

@@ -94,40 +94,6 @@ docker-compose ps
- API Docs: http://localhost:8000/docs
- Adminer (DB): http://localhost:8080
## Utilidades Administrativas
Para gestión y debugging de la base de datos, usa el script consolidado:
```bash
# Ver todos los comandos disponibles
python scripts/db_utils.py --help
# Listar todos los usuarios
python scripts/db_utils.py list-users
# Verificar información de un usuario
python scripts/db_utils.py check-user admin@example.com
# Resetear contraseña de un usuario
python scripts/db_utils.py reset-password admin@example.com --password admin123
# Listar últimos 10 tickets
python scripts/db_utils.py list-tickets --limit 10
# Verificar información de un ticket específico
python scripts/db_utils.py check-ticket <TICKET_ID>
# Filtrar por tenant
python scripts/db_utils.py list-users --tenant-id <TENANT_UUID>
python scripts/db_utils.py list-tickets --tenant-id <TENANT_UUID>
```
**💡 Alternativas para debugging:**
- **PostgreSQL directo**: Conectarte con pgAdmin, DBeaver o `psql`
- **Python Shell**: `python -m asyncio` desde el directorio backend
- **Tests**: Crear tests específicos en `backend/tests/`
- **API Docs**: Usar Swagger UI en http://localhost:8000/docs
## Scripts de Desarrollo
```bash

View File

@@ -1,254 +0,0 @@
# Release Notes - ServiceManagerWeb v1.5.1
**Fecha**: 12 de Febrero, 2026
**Rama**: main
**Commit**: 771b6eb
---
## 📦 Información de la Versión
**Versión Anterior**: v1.4.1.4
**Versión Actual**: v1.5.1
**Tipo de Release**: Minor (Funcionalidades + Correcciones Críticas)
---
## 🔒 Seguridad y Control de Acceso
### Control de Acceso Basado en Roles (RBAC)
#### Implementación Completa
- **Staff Interno** (ADMIN, AGENT, SUPPORT_MANAGER)
- ✅ Acceso a todos los tickets del tenant
- ✅ Puede ver/modificar cualquier ticket
- ✅ Control total sobre recursos compartidos
- **Clientes** (CLIENT_USER, CLIENT_ADMIN)
- ✅ Acceso solo a sus propios tickets
- ✅ No pueden ver tickets de otros clientes del mismo tenant
- ✅ Restricciones adecuadas implementadas
#### Endpoints Protegidos
**Categories** (`/api/v1/categories`)
- GET: Todos los roles (lectura)
- POST/PUT/DELETE: Solo ADMIN y SUPPORT_MANAGER
**Systems** (`/api/v1/systems`)
- GET: Todos los roles (lectura)
- POST/PUT/DELETE: Solo ADMIN y SUPPORT_MANAGER
**Tickets** (`/api/v1/tickets`)
- GET (listado): Filtrado según rol
- GET (detalle): Validación de permisos por rol
- POST: Todos (según su alcance)
- PATCH/DELETE: Validación por rol y propiedad
### Multi-Tenancy Reforzado
- ✅ Header `X-Tenant-ID` agregado en frontend-internal
- ✅ Validación de tenant en todos los endpoints
- ✅ Aislamiento estricto de datos entre tenants
- ✅ Prevención de acceso cruzado entre organizaciones
---
## 🐛 Correcciones Críticas
### Fix: Números de Ticket Duplicados
**Problema Original**:
- Generación de números con simple contador
- Race conditions en creación simultánea
- Violación de constraint unique `uq_tickets_tenant_number`
**Solución Implementada**:
```python
# Retry logic con 3 intentos
# Búsqueda del MAX número existente
# Manejo específico de errores de llave duplicada
for attempt in range(max_retries):
last_number = get_max_ticket_number()
next_number = last_number + 1
try:
create_ticket(next_number)
break
except DuplicateKeyError:
if attempt < max_retries - 1:
continue # Reintentar
```
**Resultado**:
- ✅ 0% fallos por duplicados
- ✅ Manejo robusto de alta concurrencia
- ✅ Recuperación automática de errores
---
## ✨ Mejoras de Código
### Backend
1. **Validación Robusta**
- Type hints completos en todos los endpoints
- Validación de permisos antes de queries
- Mensajes de error descriptivos
2. **Manejo de Excepciones**
- Try/catch específicos por tipo de error
- Rollback automático en fallos
- Logging estructurado
3. **Documentación**
- Docstrings actualizados con información de permisos
- Comentarios explicativos en lógica compleja
- Ejemplos de uso en código
### Frontend
1. **API Client**
- Header `X-Tenant-ID` en todas las peticiones
- Manejo consistente de errores
- Type safety mejorado
---
## 📚 Documentación
### Archivos Nuevos
1. **CHANGELOG.md**
- Historial completo de versiones
- Formato estándar Keep a Changelog
- Categorización por tipo de cambio
2. **test_rbac.py**
- Script de validación de permisos
- Tests automatizados de RBAC
- Verificación de aislamiento multi-tenant
### Archivos Actualizados
- `backend/pyproject.toml` → v1.5.1
- `frontend-internal/package.json` → v1.5.1
- `frontend-client/package.json` → v1.5.1
- Endpoints: tickets.py, categories.py, systems.py
---
## 🧪 Testing
### Scripts de Validación
```bash
# Test de control de acceso
python backend/test_rbac.py
# Test de creación de tickets
python backend/test_ticket_numbers.py
# Verificar usuarios y roles
python backend/list_all_users.py
```
### Cobertura
- ✅ RBAC implementado y validado
- ✅ Multi-tenancy verificado
- ✅ Generación de números probada
- ✅ Endpoints protegidos confirmados
---
## 💾 Backup
**Ubicación**: `../backups/ServiceManagerWeb_v1.5.1_backup_20260212_085751`
**Contenido**:
- Código fuente completo
- Configuraciones
- Scripts y utilidades
- Documentación
**Exclusiones**:
- node_modules/
- .git/
- __pycache__/
- logs/
- uploads/
---
## 🚀 Despliegue
### Para Subir al Repositorio Remoto
```bash
# Subir commit
git push origin main
# Subir tag
git push origin v1.5.1
```
### Para Desplegar en Producción
1. Pull de la versión
```bash
git fetch --tags
git checkout v1.5.1
```
2. Actualizar dependencias
```bash
docker-compose pull
docker-compose build
```
3. Reiniciar servicios
```bash
docker-compose down
docker-compose up -d
```
4. Verificar estado
```bash
docker-compose ps
curl http://localhost:8000/health
```
---
## ⚠️ Breaking Changes
**Ninguno**: Esta versión es completamente compatible con v1.4.x
---
## 📊 Estadísticas
- **Archivos modificados**: 8
- **Líneas agregadas**: 336
- **Líneas eliminadas**: 101
- **Commits**: 1
- **Tags**: 1
---
## 👥 Contribuidores
- **Autor**: icamarillo <icamarillo@aduanasoft.com.mx>
- **Fecha**: Thu Feb 12 09:00:16 2026 -0700
---
## 🔗 Referencias
- **Commit**: 771b6eba30e183fafbff6d2f074a41c378170730
- **Tag**: v1.5.1
- **Rama**: main
- **Changelog**: CHANGELOG.md
---
_Generado automáticamente el 12 de Febrero, 2026_

View File

@@ -1,7 +1,7 @@
"""
"""
Audit Schemas - ServiceManagerWeb
Schemas Pydantic para endpoints de auditoría
Schemas Pydantic para endpoints de auditoría
"""
from pydantic import BaseModel, Field, UUID4
@@ -11,7 +11,7 @@ from datetime import datetime
class AuditLogBase(BaseModel):
"""Schema base para audit logs."""
action: str = Field(..., description="Acción realizada (ej: ticket.create)")
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")
@@ -21,18 +21,18 @@ class AuditLogResponse(AuditLogBase):
"""
Schema de respuesta para audit logs.
Incluye toda la información del log con datos del usuario.
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)
# 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
# Contexto de la acci├│n
ip_address: Optional[str]
user_agent: Optional[str]
correlation_id: Optional[UUID4]
@@ -45,25 +45,106 @@ class AuditLogResponse(AuditLogBase):
created_at: datetime
# Display friendly
action_display: str = Field(description="Acción en formato amigable")
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.
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")
# 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")
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")
@@ -71,25 +152,26 @@ class AuditLogFilters(BaseModel):
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")
# B├║squeda
search: Optional[str] = Field(None, description="B├║squeda en acciones o recursos")
class AuditLogStats(BaseModel):
"""
Estadísticas de auditoría.
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")
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_actions: Dict[str, int] = Field(description="Acciones más frecuentes")
# Top usuarios
top_users: Dict[str, int] = Field(description="Usuarios más activos")
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")
@@ -101,9 +183,9 @@ class AuditLogListResponse(BaseModel):
"""
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")
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

View File

@@ -133,10 +133,10 @@ class ClientProfileUpdate(ClientProfileBase):
class ClientProfileResponse(ClientProfileBase):
"""Schema de respuesta para ClientProfile."""
id: Optional[uuid.UUID] = None
id: uuid.UUID
tenant_id: uuid.UUID
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True

File diff suppressed because it is too large Load Diff

View File

@@ -4,14 +4,13 @@ Authentication Endpoints - ServiceManagerWeb
Endpoints para autenticación y autorización
"""
from fastapi import APIRouter, HTTPException, status, Depends, Request
from fastapi import APIRouter, HTTPException, status, Depends
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from pydantic import BaseModel, EmailStr
from typing import Optional
from datetime import datetime # Para actualizar last_login
import structlog
from app.core.database import get_db
@@ -19,8 +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 # Servicio de auditoría
from app.services.token_service import TokenService # Servicio de tokens
from app.services.audit_service import AuditService
router = APIRouter()
logger = structlog.get_logger(__name__)
@@ -70,17 +68,13 @@ class TokenResponse(BaseModel):
@router.post("/login", response_model=LoginResponse)
async def login(
login_data: LoginRequest,
request: Request, # Agregar Request para capturar IP y user agent
db: AsyncSession = Depends(get_db)
):
"""
Authenticate user and return access/refresh tokens.
**Auditoría**: Registra login exitoso y fallido en audit_logs
Args:
login_data: Login credentials
request: FastAPI Request (para auditoría)
db: Database session
Returns:
@@ -107,22 +101,21 @@ async def login(
email=login_data.email
)
# 🔍 AUDITORÍA: Registrar intento fallido
if user: # Solo si el usuario existe (password incorrecto)
await AuditService.log(
db=db,
tenant_id=user.tenant_id,
user_id=None, # NULL porque aún no autenticado
action="user.login_failed",
resource_type="user",
resource_id=user.id,
metadata={
"email": login_data.email,
"reason": "invalid_password"
},
request=request
)
await db.commit() # Commit del audit log
# 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,
@@ -135,31 +128,11 @@ async def login(
"Login failed - user inactive",
email=login_data.email
)
# 🔍 AUDITORÍA: Registrar intento con usuario inactivo
await AuditService.log(
db=db,
tenant_id=user.tenant_id,
user_id=None,
action="user.login_failed",
resource_type="user",
resource_id=user.id,
metadata={
"email": login_data.email,
"reason": "user_inactive"
},
request=request
)
await db.commit()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Usuario inactivo"
)
# Actualizar último login
user.last_login = datetime.utcnow()
# Create tokens
token_data = {
"sub": str(user.id),
@@ -171,61 +144,20 @@ async def login(
access_token = security.create_access_token(token_data)
refresh_token = security.create_refresh_token(token_data)
# Extraer información del dispositivo para rastreo
user_agent = request.headers.get("user-agent")
client_ip = request.client.host if request.client else None
# device_id: El frontend puede enviarlo en el futuro como header
device_id = request.headers.get("x-device-id") # Opcional
# device_name: Simplificado del user-agent (ej: "Chrome en Windows")
device_name = None
if user_agent:
# Parseo básico para obtener un nombre legible
if "Chrome" in user_agent:
device_name = "Chrome"
elif "Firefox" in user_agent:
device_name = "Firefox"
elif "Safari" in user_agent:
device_name = "Safari"
elif "Edge" in user_agent:
device_name = "Edge"
else:
device_name = "Unknown Browser"
# Agregar OS
if "Windows" in user_agent:
device_name += " en Windows"
elif "Macintosh" in user_agent or "Mac OS" in user_agent:
device_name += " en macOS"
elif "Linux" in user_agent:
device_name += " en Linux"
elif "Android" in user_agent:
device_name += " en Android"
elif "iPhone" in user_agent or "iPad" in user_agent:
device_name += " en iOS"
# Persistir refresh token en BD con tracking completo
await TokenService.create_refresh_token(
db=db,
user=user,
refresh_token=refresh_token,
device_id=device_id,
device_name=device_name,
user_agent=user_agent,
ip_address=client_ip
)
# 🔍 AUDITORÍA: Registrar login exitoso
await AuditService.log_login(
db=db,
user=user,
request=request,
success=True
)
# Commit de todos los cambios (last_login + refresh token + audit log)
await db.commit()
# 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",
@@ -260,9 +192,6 @@ async def refresh_token(
"""
Refresh access token using refresh token.
Verifica el JWT, valida en BD que no esté revocado/expirado,
actualiza estadísticas de uso y genera nuevo access token.
Args:
refresh_data: Refresh token data
db: Database session
@@ -275,26 +204,18 @@ async def refresh_token(
"""
logger.info("Token refresh attempt")
# 1. Verify refresh token JWT signature
# Verify refresh token
payload = security.verify_token(refresh_data.refresh_token)
if not payload or payload.get("type") != "refresh":
logger.warning("Token refresh failed - invalid JWT")
logger.warning("Token refresh failed - invalid token")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
# 2. Verificar que el token exista en BD y no esté revocado/expirado
# También actualiza last_used_at y usage_count automáticamente
db_token = await TokenService.verify_refresh_token(db, refresh_data.refresh_token)
if not db_token:
logger.warning("Token refresh failed - token not found, revoked or expired")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or revoked refresh token"
)
# TODO: Check if refresh token exists in database and is not revoked
# 3. Create new access token
# Create new access token
token_data = {
"sub": payload["sub"],
"email": payload["email"],
@@ -304,15 +225,7 @@ async def refresh_token(
access_token = security.create_access_token(token_data)
# 4. Commit actualización de estadísticas de uso
await db.commit()
logger.info(
"Token refresh successful",
user_id=payload["sub"],
token_id=str(db_token.id),
usage_count=db_token.usage_count
)
logger.info("Token refresh successful", user_id=payload["sub"])
return TokenResponse(
access_token=access_token,
@@ -326,17 +239,14 @@ async def logout(
db: AsyncSession = Depends(get_db)
):
"""
Logout user and revoke all refresh tokens.
Revoca todos los tokens del usuario (logout en todos los dispositivos)
y registra quién ejecutó la revocación para auditoría.
Logout user and revoke refresh token.
Args:
token: Access token
db: Database session
Returns:
Success message with count of revoked tokens
Success message
"""
logger.info("Logout attempt")
@@ -348,31 +258,30 @@ async def logout(
detail="Invalid token"
)
# Extraer user_id del token
user_id_str = payload.get("sub")
if not user_id_str:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload"
# 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))
user_id = uuid.UUID(user_id_str)
logger.info("Logout successful", user_id=payload["sub"])
# Revocar todos los tokens del usuario con auditoría de quién lo revocó
count = await TokenService.revoke_all_user_tokens(
db=db,
user_id=user_id,
revoked_by_user_id=user_id # El usuario se revoca a sí mismo
)
await db.commit()
logger.info("Logout successful", user_id=str(user_id), tokens_revoked=count)
return {
"message": "Successfully logged out from all devices",
"tokens_revoked": count
}
return {"message": "Successfully logged out"}
@router.get("/me")

View File

@@ -82,25 +82,17 @@ async def read_categories(
async def create_category(
category: CategoryCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user)
current_user: User = Depends(deps.get_current_user) # ✅ CORREGIDO: Type hint
):
"""
Crear nueva categoría en el tenant del usuario actual.
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden crear categorías.
✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario.
"""
# Verificar permisos
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No tienes permisos para crear categorías"
)
# Asignar tenant_id del usuario actual
# ✅ CORREGIDO: Asignar tenant_id del usuario actual
db_category = Category(
**category.model_dump(),
tenant_id=current_user.tenant_id
tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático
)
db.add(db_category)
@@ -146,16 +138,8 @@ async def update_category(
"""
Actualizar categoría del tenant.
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden actualizar categorías.
✅ Implementa multi-tenancy: solo permite actualizar categorías del propio tenant.
"""
# Verificar permisos
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No tienes permisos para actualizar categorías"
)
query = select(Category).where(
Category.id == category_id,
Category.tenant_id == current_user.tenant_id
@@ -188,16 +172,8 @@ async def delete_category(
"""
Desactivar categoría del tenant (soft delete).
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden desactivar categorías.
✅ Implementa multi-tenancy: solo permite desactivar categorías del propio tenant.
"""
# Verificar permisos
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No tienes permisos para desactivar categorías"
)
query = select(Category).where(
Category.id == category_id,
Category.tenant_id == current_user.tenant_id

View File

@@ -50,49 +50,11 @@ async def get_current_client_profile(
profile = result.scalar_one_or_none()
if not profile:
# Si no existe, devolver un perfil vacío con solo tenant_id
# No crear en base de datos hasta que el usuario guarde
return ClientProfileResponse(
id=None,
tenant_id=current_tenant.id,
business_name=None,
commercial_name=None,
client_code=None,
client_type=None,
rfc=None,
tax_id=None,
country=None,
state=None,
city=None,
address=None,
external_number=None,
internal_number=None,
postal_code=None,
neighborhood=None,
main_phone=None,
secondary_phone=None,
direct_phone=None,
phone_extension=None,
fax=None,
business_hours=None,
website=None,
main_email=None,
billing_email=None,
advertising_medium=None,
nationality=None,
logo_url=None,
company_representative=None,
legal_representative=None,
credit_limit=None,
payment_terms=None,
preferred_currency="MXN",
send_to_billing=False,
is_active_client=True,
is_prospect=False,
notes=None,
created_at=None,
updated_at=None
)
# Si no existe, crear uno vacío
profile = ClientProfile(tenant_id=current_tenant.id)
db.add(profile)
await db.commit()
await db.refresh(profile)
return profile

View File

@@ -70,25 +70,17 @@ async def read_systems(
async def create_system(
system: SystemCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user)
current_user: User = Depends(deps.get_current_user) # ✅ CORREGIDO: Type hint
):
"""
Crear nuevo sistema en el tenant del usuario actual.
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden crear sistemas.
✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario.
"""
# Verificar permisos
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No tienes permisos para crear sistemas"
)
# Asignar tenant_id del usuario actual
# ✅ CORREGIDO: Asignar tenant_id del usuario actual
db_system = System(
**system.model_dump(),
tenant_id=current_user.tenant_id
tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático
)
db.add(db_system)
@@ -134,16 +126,8 @@ async def update_system(
"""
Actualizar sistema del tenant.
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden actualizar sistemas.
✅ Implementa multi-tenancy: solo permite actualizar sistemas del propio tenant.
"""
# Verificar permisos
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No tienes permisos para actualizar sistemas"
)
query = select(System).where(
System.id == system_id,
System.tenant_id == current_user.tenant_id
@@ -176,16 +160,8 @@ async def delete_system(
"""
Desactivar sistema del tenant (soft delete).
**Permisos**: Solo ADMIN y SUPPORT_MANAGER pueden desactivar sistemas.
✅ Implementa multi-tenancy: solo permite desactivar sistemas del propio tenant.
"""
# Verificar permisos
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No tienes permisos para desactivar sistemas"
)
query = select(System).where(
System.id == system_id,
System.tenant_id == current_user.tenant_id

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()
@@ -143,6 +144,27 @@ async def create_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
}
)
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),
@@ -240,7 +262,7 @@ async def get_tickets(
"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, # ✅ CORREGIDO
"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,
@@ -501,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)
@@ -519,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),
@@ -787,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

@@ -23,13 +23,6 @@ api_router.include_router(
tags=["authentication"]
)
# Audit routes (antes de otros para logging)
api_router.include_router(
audit.router,
prefix="/audit",
tags=["audit"]
)
api_router.include_router(
tenants.router,
prefix="/tenants",
@@ -66,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,8 +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 # Nuevo modelo para auditoría
from app.models.refresh_token import RefreshToken # Modelo para refresh tokens
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
@@ -56,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,
@@ -163,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
}
@@ -174,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

@@ -1,7 +1,7 @@
"""
"""
Audit Log Model - ServiceManagerWeb
Modelo para bitácora de auditoría y compliance.
Modelo para bitácora de auditoría y compliance.
Registra todas las acciones importantes del sistema.
"""
@@ -21,15 +21,15 @@ if TYPE_CHECKING:
class AuditLog(Base):
"""
Bitácora de auditoría para tracking completo de acciones.
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)
- 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"
@@ -42,7 +42,7 @@ class AuditLog(Base):
index=True
)
# Usuario que ejecutó la acción (NULL = acción del sistema)
# 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"),
@@ -50,7 +50,7 @@ class AuditLog(Base):
index=True
)
# Acción realizada (ej: "user.login", "ticket.create", "ticket.assign")
# Acci├│n realizada (ej: "user.login", "ticket.create", "ticket.assign")
action: Mapped[str] = mapped_column(
String(100),
nullable=False,
@@ -87,14 +87,14 @@ class AuditLog(Base):
nullable=True
)
# Valores después del cambio (JSON)
# 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'
# 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,
@@ -113,14 +113,14 @@ class AuditLog(Base):
tenant: Mapped["Tenant"] = relationship("Tenant", foreign_keys=[tenant_id])
user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[user_id])
# Índices compuestos para queries comunes
# Í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
# Configuraci├│n del mapper: excluir updated_at porque audit logs son inmutables
__mapper_args__ = {
"exclude_properties": ["updated_at"]
}
@@ -130,19 +130,19 @@ class AuditLog(Base):
@property
def action_display(self) -> str:
"""Formato amigable de la acción."""
"""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ó'
'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

@@ -1,7 +1,7 @@
"""
"""
Refresh Token Model - ServiceManagerWeb
Modelo para persistencia de refresh tokens con revocación y tracking.
Modelo para persistencia de refresh tokens con revocaci├│n y tracking.
"""
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Index, Integer
@@ -19,16 +19,16 @@ if TYPE_CHECKING:
class RefreshToken(Base):
"""
Refresh Token persistente para gestión de sesiones.
Refresh Token persistente para gesti├│n de sesiones.
Almacena refresh tokens con información de dispositivo y permite
revocación para mejorar la seguridad.
Almacena refresh tokens con informaci├│n de dispositivo y permite
revocaci├│n para mejorar la seguridad.
Características:
Características:
- Token hasheado (no se guarda en texto plano)
- Device fingerprinting
- Revocación individual con tracking
- Auto-expiración
- Revocaci├│n individual con tracking
- Auto-expiraci├│n
- Tracking de IP y uso
"""
@@ -72,14 +72,14 @@ class RefreshToken(Base):
nullable=True
)
# Expiración del token
# Expiraci├│n del token
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
index=True
)
# Estado de revocación
# Estado de revocaci├│n
revoked: Mapped[bool] = mapped_column(
Boolean,
default=False,
@@ -125,11 +125,11 @@ class RefreshToken(Base):
server_default="NOW()"
)
# Relación con usuario
# 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
# Índices compuestos
__table_args__ = (
Index('idx_refresh_tokens_user_expires', 'user_id', 'expires_at'),
)
@@ -140,10 +140,10 @@ class RefreshToken(Base):
@property
def is_valid(self) -> bool:
"""
Verificar si el token es válido.
Verificar si el token es válido.
Un token es válido si:
- No está revocado
Un token es válido si:
- No está revocado
- No ha expirado
"""
return not self.revoked and self.expires_at > datetime.utcnow()
@@ -158,7 +158,7 @@ class RefreshToken(Base):
Marcar el token como revocado.
Args:
revoked_by: ID del usuario que revocó el token
revoked_by: ID del usuario que revoc├│ el token
"""
self.revoked = True
self.revoked_at = datetime.utcnow()

View File

@@ -78,12 +78,11 @@ class User(Base):
back_populates="assigned_to_user",
foreign_keys="Ticket.assigned_to"
)
# Refresh tokens: especificar user_id como FK (hay 2 FKs: user_id y revoked_by)
refresh_tokens: Mapped[List["RefreshToken"]] = relationship(
"RefreshToken",
back_populates="user",
cascade="all, delete-orphan",
foreign_keys="[RefreshToken.user_id]"
foreign_keys="RefreshToken.user_id",
cascade="all, delete-orphan"
)
# Unique constraint por tenant

View File

@@ -1,8 +1,8 @@
"""
"""
Audit Service - ServiceManagerWeb
Funciones helper para facilitar el registro de auditoría.
Simplifica el proceso de logging en toda la aplicación.
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
@@ -19,9 +19,9 @@ logger = structlog.get_logger(__name__)
class AuditService:
"""
Servicio centralizado para registro de auditoría.
Servicio centralizado para registro de auditoría.
Uso básico:
Uso básico:
await AuditService.log(
db=db,
tenant_id=tenant.id,
@@ -47,25 +47,25 @@ class AuditService:
request: Optional[Request] = None
) -> AuditLog:
"""
Registra una acción en la bitácora de auditoría.
Registra una acción en la bitácora de auditoría.
Args:
db: Sesión de base de datos
db: Sesi├│n de base de datos
tenant_id: ID del tenant
action: Acción realizada (formato: "recurso.verbo")
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)
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)
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
# Extraer información del request si está disponible
ip_address = None
user_agent = None
correlation_id = None
@@ -81,7 +81,7 @@ class AuditService:
# Correlation ID (si existe en el request state)
correlation_id = getattr(request.state, "correlation_id", None)
# Crear registro de auditoría
# Crear registro de auditoría
audit_log = AuditLog(
tenant_id=tenant_id,
user_id=user_id,
@@ -97,7 +97,7 @@ class AuditService:
)
db.add(audit_log)
await db.flush() # No commit, se hará con la transacción principal
await db.flush() # No commit, se hará con la transacción principal
# Log estructurado para debugging
logger.info(
@@ -122,8 +122,8 @@ class AuditService:
Registra un intento de login.
Args:
db: Sesión de base de datos
user: Usuario que intentó loguearse
db: Sesi├│n de base de datos
user: Usuario que intent├│ loguearse
request: Request de FastAPI
success: Si el login fue exitoso
@@ -154,8 +154,8 @@ class AuditService:
Registra un logout.
Args:
db: Sesión de base de datos
user: Usuario que cerró sesión
db: Sesi├│n de base de datos
user: Usuario que cerr├│ sesi├│n
request: Request de FastAPI
Returns:
@@ -182,12 +182,12 @@ class AuditService:
request: Optional[Request] = None
) -> AuditLog:
"""
Registra la creación de un recurso.
Registra la creaci├│n de un recurso.
Args:
db: Sesión de base de datos
db: Sesi├│n de base de datos
tenant_id: ID del tenant
user_id: ID del usuario que creó el recurso
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
@@ -219,12 +219,12 @@ class AuditService:
request: Optional[Request] = None
) -> AuditLog:
"""
Registra la actualización de un recurso.
Registra la actualizaci├│n de un recurso.
Args:
db: Sesión de base de datos
db: Sesi├│n de base de datos
tenant_id: ID del tenant
user_id: ID del usuario que actualizó
user_id: ID del usuario que actualiz├│
resource_type: Tipo de recurso
resource_id: ID del recurso
old_values: Valores anteriores
@@ -257,12 +257,12 @@ class AuditService:
request: Optional[Request] = None
) -> AuditLog:
"""
Registra la eliminación de un recurso.
Registra la eliminaci├│n de un recurso.
Args:
db: Sesión de base de datos
db: Sesi├│n de base de datos
tenant_id: ID del tenant
user_id: ID del usuario que eliminó
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

View File

@@ -1,7 +1,7 @@
"""
"""
Token Service - ServiceManagerWeb
Servicio para gestión de refresh tokens persistentes.
Servicio para gesti├│n de refresh tokens persistentes.
"""
from sqlalchemy.ext.asyncio import AsyncSession
@@ -21,9 +21,9 @@ settings = get_settings()
class TokenService:
"""
Servicio para gestión de refresh tokens.
Servicio para gesti├│n de refresh tokens.
Proporciona métodos para crear, validar, revocar y limpiar
Proporciona m├®todos para crear, validar, revocar y limpiar
refresh tokens persistentes.
NOTA: Los tokens se almacenan directamente en BD (no hash)
@@ -44,10 +44,10 @@ class TokenService:
Crear y persistir un refresh token.
Args:
db: Sesión de base de datos
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_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
@@ -55,7 +55,7 @@ class TokenService:
Returns:
RefreshToken creado
"""
# Calcular expiración
# Calcular expiraci├│n
expires_at = datetime.utcnow() + timedelta(
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
)
@@ -92,16 +92,16 @@ class TokenService:
refresh_token: str
) -> Optional[RefreshToken]:
"""
Verificar que el refresh token exista y sea válido.
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
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
RefreshToken si es válido, None si no existe o está revocado/expirado
"""
# Buscar token directamente en BD (sin hash)
query = select(RefreshToken).where(
@@ -114,7 +114,7 @@ class TokenService:
logger.warning("Refresh token not found in database")
return None
# Verificar si es válido (usa property is_valid del modelo)
# Verificar si es válido (usa property is_valid del modelo)
if not db_token.is_valid:
logger.warning(
"Invalid refresh token",
@@ -124,7 +124,7 @@ class TokenService:
)
return None
# Actualizar estadísticas de uso
# Actualizar estadísticas de uso
db_token.track_usage()
await db.flush()
@@ -142,15 +142,15 @@ class TokenService:
revoked_by_user_id: Optional[uuid.UUID] = None
) -> bool:
"""
Revocar un refresh token específico.
Revocar un refresh token específico.
Args:
db: Sesión de base de datos
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)
revoked_by_user_id: ID del usuario que revoca (para auditoría)
Returns:
True si se revocó, False si no se encontró
True si se revoc├│, False si no se encontr├│
"""
# Buscar token directamente (sin hash)
query = select(RefreshToken).where(
@@ -163,7 +163,7 @@ class TokenService:
logger.warning("Refresh token not found for revocation")
return False
# Revocar usando método del modelo
# Revocar usando m├®todo del modelo
db_token.revoke(revoked_by=revoked_by_user_id)
await db.flush()
@@ -183,15 +183,15 @@ class TokenService:
"""
Revocar todos los tokens activos de un usuario.
Útil para logout en todos los dispositivos.
Útil para logout en todos los dispositivos.
Args:
db: Sesión de base de datos
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)
revoked_by_user_id: ID del usuario que ejecuta la revocación (para auditoría)
Returns:
Número de tokens revocados
N├║mero de tokens revocados
"""
# Buscar todos los tokens activos del usuario
query = select(RefreshToken).where(
@@ -226,12 +226,12 @@ class TokenService:
Tarea de mantenimiento para limpiar tokens antiguos.
Args:
db: Sesión de base de datos
db: Sesi├│n de base de datos
Returns:
Número de tokens eliminados
N├║mero de tokens eliminados
"""
# Eliminar tokens expirados hace más de 7 días
# Eliminar tokens expirados hace más de 7 días
cutoff_date = datetime.utcnow() - timedelta(days=7)
query = delete(RefreshToken).where(
@@ -254,7 +254,7 @@ class TokenService:
Obtener todos los tokens activos de un usuario.
Args:
db: Sesión de base de datos
db: Sesi├│n de base de datos
user_id: ID del usuario
Returns:

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

@@ -1,11 +1,11 @@
"""add_audit_logs_table
"""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 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
@@ -23,8 +23,8 @@ 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.
La tabla fue creada por schema.sql, esta migraci├│n solo verifica
que exista y está disponible para usar.
"""
from sqlalchemy import inspect
@@ -33,10 +33,10 @@ def upgrade():
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")
print(" Tabla audit_logs encontrada (creada por schema.sql)")
print(" Modelo AuditLog registrado en Alembic")
# Verificar que tenga los índices necesarios
# Verificar que tenga los índices necesarios
existing_indexes = [idx['name'] for idx in inspector.get_indexes('audit_logs')]
missing_indexes = []
@@ -53,17 +53,17 @@ def upgrade():
missing_indexes.append(idx)
if missing_indexes:
print(f"⚠️ Índices faltantes: {', '.join(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")
print("Ô£à Todos los ├¡ndices necesarios est├ín presentes")
else:
print("⚠️ La tabla audit_logs NO existe")
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
# 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."
@@ -76,6 +76,6 @@ def downgrade():
Solo des-registrar de Alembic.
"""
print(" Tabla audit_logs NO será eliminada (creada por schema.sql)")
print(" Modelo AuditLog des-registrado 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 = "1.5.1"
version = "1.6.0"
description = "ServiceManagerWeb Backend - Mesa de Ayuda B2B"
authors = [
{name = "Aduanasoft", email = "dev@aduanasoft.com"}

View File

View File

@@ -1,109 +0,0 @@
"""
Script de verificación de control de acceso basado en roles
"""
import asyncio
import httpx
BASE_URL = "http://localhost:8000/api/v1"
# Credenciales de prueba
USERS = {
"admin": {"email": "admin@aduanasoft.com", "password": "Admin123!", "tenant_slug": "aduanasoft"},
"agent": {"email": "agente@aduanasoft.com", "password": "Agente123!", "tenant_slug": "aduanasoft"},
"client": {"email": "test_user@example.com", "password": "TestPassword123!", "tenant_slug": "aduanasoft"}
}
async def login(user_type: str):
"""Login y obtener token"""
async with httpx.AsyncClient() as client:
response = await client.post(
f"{BASE_URL}/auth/login",
json=USERS[user_type]
)
if response.status_code == 200:
data = response.json()
return data["access_token"], data["user"]
return None, None
async def test_endpoint(method: str, endpoint: str, token: str, tenant_id: str, data: dict = None):
"""Probar un endpoint"""
async with httpx.AsyncClient() as client:
headers = {
"Authorization": f"Bearer {token}",
"X-Tenant-ID": tenant_id
}
if method == "GET":
response = await client.get(f"{BASE_URL}{endpoint}", headers=headers)
elif method == "POST":
response = await client.post(f"{BASE_URL}{endpoint}", headers=headers, json=data)
elif method == "PUT":
response = await client.put(f"{BASE_URL}{endpoint}", headers=headers, json=data)
elif method == "DELETE":
response = await client.delete(f"{BASE_URL}{endpoint}", headers=headers)
return response.status_code
async def main():
print("=" * 80)
print("VERIFICACIÓN DE CONTROL DE ACCESO BASADO EN ROLES")
print("=" * 80)
# Login todos los usuarios
print("\n1. Autenticando usuarios...")
admin_token, admin_user = await login("admin")
agent_token, agent_user = await login("agent")
client_token, client_user = await login("client")
if not all([admin_token, agent_token, client_token]):
print("❌ Error en autenticación")
return
tenant_id = admin_user["tenant_id"]
print(f"✅ Todos autenticados - Tenant ID: {tenant_id}")
# Test 1: Listar tickets
print("\n2. Test GET /tickets (listar tickets)")
print(" - Admin:", "" if await test_endpoint("GET", "/tickets/", admin_token, tenant_id) == 200 else "")
print(" - Agent:", "" if await test_endpoint("GET", "/tickets/", agent_token, tenant_id) == 200 else "")
print(" - Client:", "" if await test_endpoint("GET", "/tickets/", client_token, tenant_id) == 200 else "")
# Test 2: Crear categoría (solo ADMIN/SUPPORT_MANAGER)
print("\n3. Test POST /categories/ (crear categoría)")
category_data = {"name": "Test Category", "description": "Test"}
admin_status = await test_endpoint("POST", "/categories/", admin_token, tenant_id, category_data)
agent_status = await test_endpoint("POST", "/categories/", agent_token, tenant_id, category_data)
client_status = await test_endpoint("POST", "/categories/", client_token, tenant_id, category_data)
print(f" - Admin: {'' if admin_status in [200, 201] else ''} (esperado: 201)")
print(f" - Agent: {'' if agent_status == 403 else ''} (esperado: 403)")
print(f" - Client: {'' if client_status == 403 else ''} (esperado: 403)")
# Test 3: Crear sistema (solo ADMIN/SUPPORT_MANAGER)
print("\n4. Test POST /systems/ (crear sistema)")
system_data = {"name": "Test System", "description": "Test"}
admin_status = await test_endpoint("POST", "/systems/", admin_token, tenant_id, system_data)
agent_status = await test_endpoint("POST", "/systems/", agent_token, tenant_id, system_data)
client_status = await test_endpoint("POST", "/systems/", client_token, tenant_id, system_data)
print(f" - Admin: {'' if admin_status in [200, 201] else ''} (esperado: 201)")
print(f" - Agent: {'' if agent_status == 403 else ''} (esperado: 403)")
print(f" - Client: {'' if client_status == 403 else ''} (esperado: 403)")
# Test 4: Ver tickets de otros usuarios
print("\n5. Test de visibilidad de tickets:")
print(" - Admin puede ver tickets de clientes: ✅ (implementado)")
print(" - Agent puede ver tickets de clientes: ✅ (implementado)")
print(" - Client solo ve sus propios tickets: ✅ (implementado)")
print("\n" + "=" * 80)
print("RESUMEN")
print("=" * 80)
print("✅ Control de acceso basado en roles implementado correctamente")
print("✅ Staff interno (ADMIN/AGENT) puede ver todos los tickets del tenant")
print("✅ Clientes solo ven sus propios tickets")
print("✅ Solo ADMIN/SUPPORT_MANAGER pueden crear/modificar categories/systems")
print("=" * 80)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -1,84 +0,0 @@
"""Script para verificar el acceso a tickets con diferentes usuarios"""
import asyncio
import httpx
import os
BASE_URL = "http://localhost:8000/api/v1"
TICKET_ID = "2bd79718-440d-4144-b660-c0c6051fcf73"
async def login(email: str, password: str, tenant_slug: str = "aduanasoft"):
"""Login y obtener token"""
async with httpx.AsyncClient() as client:
response = await client.post(
f"{BASE_URL}/auth/login",
json={
"email": email,
"password": password,
"tenant_slug": tenant_slug
}
)
if response.status_code == 200:
data = response.json()
return data["access_token"], data["user"]
else:
print(f"❌ Login failed for {email}: {response.text}")
return None, None
async def get_ticket(ticket_id: str, token: str, tenant_id: str):
"""Intentar obtener un ticket"""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{BASE_URL}/tickets/{ticket_id}",
headers={
"Authorization": f"Bearer {token}",
"X-Tenant-ID": tenant_id
}
)
return response.status_code, response.text
async def test_access():
print("=" * 60)
print("PRUEBA DE ACCESO A TICKETS")
print("=" * 60)
# Test con test_user (CLIENT_USER)
print("\n1. Probando con test_user (CLIENT_USER)...")
token, user = await login("test_user@example.com", "TestPassword123!")
if token and user:
print(f" ✅ Login exitoso - Role: {user['role']}, Tenant: {user['tenant_id']}")
status, response = await get_ticket(TICKET_ID, token, user['tenant_id'])
if status == 200:
print(f" ✅ Ticket obtenido correctamente")
else:
print(f" ❌ Error {status}: {response}")
# Test con admin
print("\n2. Probando con admin (ADMIN)...")
token, user = await login("admin@aduanasoft.com", "Admin123!")
if token and user:
print(f" ✅ Login exitoso - Role: {user['role']}, Tenant: {user['tenant_id']}")
status, response = await get_ticket(TICKET_ID, token, user['tenant_id'])
if status == 200:
print(f" ✅ Ticket obtenido correctamente")
else:
print(f" ❌ Error {status}: {response}")
# Test con agente
print("\n3. Probando con agente (AGENT)...")
token, user = await login("agente@aduanasoft.com", "Agente123!")
if token and user:
print(f" ✅ Login exitoso - Role: {user['role']}, Tenant: {user['tenant_id']}")
status, response = await get_ticket(TICKET_ID, token, user['tenant_id'])
if status == 200:
print(f" ✅ Ticket obtenido correctamente")
else:
print(f" ❌ Error {status}: {response}")
print("\n" + "=" * 60)
print("Nota: Este ticket fue creado por test_user@example.com")
print("Ahora todos los usuarios del mismo tenant deberían poder verlo")
print("según su rol (admins y agentes: todos, clientes: solo propios)")
print("=" * 60)
if __name__ == "__main__":
asyncio.run(test_access())

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": "1.5.1",
"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

@@ -3,9 +3,6 @@ import { auth } from './auth.js';
import { get } from 'svelte/store';
import type { Writable } from 'svelte/store';
// API Configuration
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// Types
export interface Category {
id: string;
@@ -33,7 +30,7 @@ const initialState: AppState = {
async function apiCall(endpoint: string, options: RequestInit = {}) {
const authState = get(auth);
const response = await fetch(`${API_URL}/v1${endpoint}`, {
const response = await fetch(`/api/v1${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',

View File

@@ -1,8 +1,5 @@
import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
// API Configuration
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
import { writable } from 'svelte/store';
// Types
export interface User {
@@ -52,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);
@@ -80,9 +77,9 @@ function createAuthStore() {
// Login
login: async (credentials: LoginRequest): Promise<void> => {
update(state => ({ ...state, isLoading: true }));
try {
const response = await fetch(`${API_URL}/v1/auth/login`, {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -96,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);
@@ -120,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

@@ -2,9 +2,6 @@ import type { Writable } from 'svelte/store';
import { get, writable } from 'svelte/store';
import { auth } from './auth';
// API Configuration
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// Types
interface FastAPIValidationError {
loc: (string | number)[];
@@ -87,7 +84,7 @@ async function apiCall(endpoint: string, options: RequestInit = {}) {
throw new Error('Not authenticated');
}
const response = await fetch(`${API_URL}/v1${endpoint}`, {
const response = await fetch(`/api/v1${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
@@ -262,7 +259,7 @@ function createTicketsStore() {
throw new Error('Not authenticated');
}
const response = await fetch(`${API_URL}/v1/tickets/${ticketId}/attachments`, {
const response = await fetch(`/api/v1/tickets/${ticketId}/attachments`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${authState.token}`,
@@ -341,7 +338,7 @@ function createTicketsStore() {
throw new Error('Not authenticated');
}
const response = await fetch(`${API_URL}/v1/tickets/${ticketId}/attachments/${attachmentId}/download`, {
const response = await fetch(`/api/v1/tickets/${ticketId}/attachments/${attachmentId}/download`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${authState.token}`,

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

@@ -4,9 +4,6 @@
import { toast } from '$lib/stores/toast.js';
import { onMount } from 'svelte';
// API Configuration
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
let currentPassword = '';
let newPassword = '';
let confirmPassword = '';
@@ -85,7 +82,7 @@
return;
}
const response = await fetch(`${API_URL}/v1/client-profile/`, {
const response = await fetch('/api/v1/client-profile/', {
headers: {
Authorization: `Bearer ${$auth.token}`,
'X-Tenant-ID': $auth.user.tenant_id
@@ -188,7 +185,7 @@
isUpdatingProfile = true;
try {
const response = await fetch(`${API_URL}/v1/auth/profile`, {
const response = await fetch('/api/v1/auth/profile', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
@@ -221,7 +218,7 @@
isChangingPassword = true;
try {
const response = await fetch(`${API_URL}/v1/auth/change-password`, {
const response = await fetch('/api/v1/auth/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -272,7 +269,7 @@
profileData.credit_limit = parseFloat(profileData.credit_limit);
}
const response = await fetch(`${API_URL}/v1/client-profile/`, {
const response = await fetch('/api/v1/client-profile/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@@ -8,7 +8,7 @@ export default defineConfig({
host: '0.0.0.0',
proxy: {
'/api': {
target: 'http://backend:8000',
target: 'http://servicemanager-backend:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}

View File

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

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { auth } from '$lib/stores/auth.js';
;
export let toggleSidebar: () => void;

View File

@@ -1,7 +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();
@@ -10,37 +12,72 @@
}
function handleKeydown(e) {
if (e.key === 'Escape') {
if (e.key === 'Escape' && open) {
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}/>
<div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
{#if open}
<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="mt-5 sm:mt-6 sm:flex sm:flex-row-reverse">
<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>
{/if}

View File

@@ -62,6 +62,11 @@
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'
}
);
}
@@ -178,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>

View File

@@ -1,9 +1,6 @@
import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
// API Configuration
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// Types
export interface InternalUser {
id: string;
@@ -98,7 +95,7 @@ function createAuthStore() {
update(state => ({ ...state, isLoading: true }));
try {
const response = await fetch(`${API_URL}/v1/auth/login`, {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -150,7 +147,7 @@ function createAuthStore() {
update (state => ({ ...state, isLoading: true }));
try {
const response = await fetch(`${API_URL}/v1/auth/refresh`, {
const response = await fetch('/api/v1/auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@@ -25,15 +25,11 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
const authState = get(auth);
const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : null);
const user = authState.user || (typeof window !== 'undefined' ? JSON.parse(localStorage.getItem('internal_auth_user') || 'null') : null);
const headers = new Headers(init.headers);
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
if (user && user.tenant_id) {
headers.set('X-Tenant-ID', user.tenant_id);
}
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
@@ -69,15 +65,11 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
async function downloadFile(endpoint: string, filename: string): Promise<void> {
const authState = get(auth);
const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : null);
const user = authState.user || (typeof window !== 'undefined' ? JSON.parse(localStorage.getItem('internal_auth_user') || 'null') : null);
const headers = new Headers();
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
if (user && user.tenant_id) {
headers.set('X-Tenant-ID', user.tenant_id);
}
const response = await fetch(`${API_BASE}${endpoint}`, {
method: 'GET',

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}

View File

@@ -145,8 +145,7 @@
</div>
</div>
{#if showModal}
<Modal title={editingCategory ? 'Editar Categoría' : 'Nueva Categoría'} on:close={() => showModal = false}>
<Modal open={showModal} title={editingCategory ? 'Editar Categoría' : 'Nueva Categoría'} on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
@@ -183,4 +182,3 @@
</div>
</form>
</Modal>
{/if}

View File

@@ -118,8 +118,7 @@
</div>
</div>
{#if showModal}
<Modal title={editingSystem ? 'Editar Sistema' : 'Nuevo Sistema'} on:close={() => showModal = false}>
<Modal open={showModal} title={editingSystem ? 'Editar Sistema' : 'Nuevo Sistema'} on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
@@ -146,4 +145,3 @@
</div>
</form>
</Modal>
{/if}

View File

@@ -126,8 +126,7 @@
</div>
</div>
{#if showModal}
<Modal title={editingTenant ? 'Editar Cliente' : 'Nuevo Cliente'} on:close={() => showModal = false}>
<Modal open={showModal} title={editingTenant ? 'Editar Cliente' : 'Nuevo Cliente'} on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
@@ -165,4 +164,3 @@
</div>
</form>
</Modal>
{/if}

View File

@@ -9,7 +9,6 @@
let categories = [];
let systems = [];
let users = [];
let tenants = []; // Nueva lista de tenants
let isLoading = false;
let showModal = false;
let showEditModal = false;
@@ -19,15 +18,6 @@
// Filtros
let filterStatus = '';
let filterPriority = '';
let filterTenant = ''; // Nuevo filtro por cliente/tenant
let filterCategory = ''; // Filtro por categoría
let filterAssignedTo = ''; // Filtro por asignado a
let searchText = ''; // Búsqueda por texto
let filterDateFrom = ''; // Fecha desde
let filterDateTo = ''; // Fecha hasta
// Estado de filtros
$: activeFiltersCount = [filterTenant, filterStatus, filterPriority, filterCategory, filterAssignedTo, searchText, filterDateFrom, filterDateTo].filter(f => f && f.trim()).length;
// Form para editar
let editFormData = {
@@ -60,34 +50,25 @@
{ value: 'URGENT', label: 'Urgente', color: 'red' }
];
// Ajustar la función loadData para usar el endpoint administrativo con filtros
// Ajustar la función loadData para asegurar que los filtros se envíen correctamente
async function loadData() {
isLoading = true;
try {
// Preparar parámetros filtrando valores vacíos
const ticketParams = {};
if (filterStatus) ticketParams.status_filter = filterStatus;
if (filterPriority) ticketParams.priority_filter = filterPriority;
if (filterTenant) ticketParams.tenant_id_filter = filterTenant;
if (filterCategory) ticketParams.category_filter = filterCategory;
if (filterAssignedTo) ticketParams.assigned_to_filter = filterAssignedTo;
if (searchText) ticketParams.search = searchText;
if (filterDateFrom) ticketParams.date_from = filterDateFrom;
if (filterDateTo) ticketParams.date_to = filterDateTo;
const [ticketsData, categoriesData, systemsData, usersData, tenantsData] = await Promise.all([
// Usar el nuevo endpoint administrativo
api.get('/tickets/admin/all', ticketParams),
const [ticketsData, categoriesData, systemsData, usersData] = await Promise.all([
api.get('/tickets/', {
params: {
status: filterStatus || undefined,
priority: filterPriority || undefined
}
}),
api.get('/categories/'),
api.get('/systems/'),
api.get('/users/'),
api.get('/tenants/') // Cargar lista de tenants
api.get('/users/')
]);
tickets = ticketsData;
categories = categoriesData;
systems = systemsData;
users = usersData;
tenants = tenantsData;
} catch (e) {
toast.error('Error cargando datos: ' + (e.message || 'Error desconocido'));
} finally {
@@ -99,28 +80,6 @@
function applyFilters() {
loadData();
}
// Limpiar todos los filtros
function clearFilters() {
filterStatus = '';
filterPriority = '';
filterTenant = '';
filterCategory = '';
filterAssignedTo = '';
searchText = '';
filterDateFrom = '';
filterDateTo = '';
applyFilters();
}
// Búsqueda en tiempo real (debounced)
let searchTimeout;
function handleSearchInput() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
applyFilters();
}, 500);
}
function openCreateModal() {
selectedTicket = null;
@@ -260,31 +219,12 @@
</script>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="sm:flex sm:items-center sm:justify-between">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-xl font-semibold text-gray-900">Tickets de Soporte</h1>
<p class="mt-2 text-sm text-gray-700">
Gestión de tickets del sistema de mesa de ayuda.
{#if activeFiltersCount > 0}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 ml-2">
{activeFiltersCount} filtro{activeFiltersCount !== 1 ? 's' : ''} activo{activeFiltersCount !== 1 ? 's' : ''}
</span>
{/if}
</p>
<p class="mt-2 text-sm text-gray-700">Gestión de tickets del sistema de mesa de ayuda.</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none space-x-3">
{#if activeFiltersCount > 0}
<button
type="button"
on:click={clearFilters}
class="inline-flex items-center justify-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50"
>
<svg class="w-4 h-4 mr-2" 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>
Limpiar filtros
</button>
{/if}
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<button
type="button"
on:click={openCreateModal}
@@ -295,139 +235,46 @@
</div>
</div>
<!-- Filtros Avanzados -->
<div class="mt-6 bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">Filtros</h3>
<!-- Primera fila - Búsqueda y Filtros principales -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-4 mb-4">
<!-- Búsqueda por texto -->
<div class="sm:col-span-2">
<label for="searchText" class="block text-sm font-medium text-gray-700 mb-1">Búsqueda</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
id="searchText"
bind:value={searchText}
on:input={handleSearchInput}
placeholder="Buscar en título o descripción..."
class="pl-10 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
/>
</div>
</div>
<!-- Cliente/Empresa -->
<div>
<label for="filterTenant" class="block text-sm font-medium text-gray-700 mb-1">Cliente/Empresa</label>
<select
id="filterTenant"
bind:value={filterTenant}
on:change={applyFilters}
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
>
<option value="">Todos los clientes</option>
{#each tenants as tenant}
<option value={tenant.id}>{tenant.name}</option>
{/each}
</select>
</div>
<!-- Estado -->
<div>
<label for="filterStatus" class="block text-sm font-medium text-gray-700 mb-1">Estado</label>
<select
id="filterStatus"
bind:value={filterStatus}
on:change={applyFilters}
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
>
<option value="">Todos</option>
{#each STATUSES as status}
<option value={status.value}>{status.label}</option>
{/each}
</select>
</div>
<!-- Filtros -->
<div class="mt-6 bg-white shadow sm:rounded-lg p-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label for="filterStatus" class="block text-sm font-medium text-gray-700">Estado</label>
<select
id="filterStatus"
bind:value={filterStatus}
on:change={applyFilters}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
>
<option value="">Todos</option>
{#each STATUSES as status}
<option value={status.value}>{status.label}</option>
{/each}
</select>
</div>
<!-- Segunda fila - Filtros secundarios -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-5">
<!-- Prioridad -->
<div>
<label for="filterPriority" class="block text-sm font-medium text-gray-700 mb-1">Prioridad</label>
<select
id="filterPriority"
bind:value={filterPriority}
on:change={applyFilters}
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
>
<option value="">Todas</option>
{#each PRIORITIES as priority}
<option value={priority.value}>{priority.label}</option>
{/each}
</select>
</div>
<!-- Categoría -->
<div>
<label for="filterCategory" class="block text-sm font-medium text-gray-700 mb-1">Categoría</label>
<select
id="filterCategory"
bind:value={filterCategory}
on:change={applyFilters}
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
>
<option value="">Todas</option>
{#each categories as category}
<option value={category.id}>{category.name}</option>
{/each}
</select>
</div>
<!-- Asignado a -->
<div>
<label for="filterAssignedTo" class="block text-sm font-medium text-gray-700 mb-1">Asignado a</label>
<select
id="filterAssignedTo"
bind:value={filterAssignedTo}
on:change={applyFilters}
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
>
<option value="">Todos</option>
{#each users.filter(u => u.role === 'AGENT' || u.role === 'SUPPORT_MANAGER' || u.role === 'ADMIN') as user}
<option value={user.id}>{user.first_name} {user.last_name}</option>
{/each}
</select>
</div>
<!-- Fecha desde -->
<div>
<label for="filterDateFrom" class="block text-sm font-medium text-gray-700 mb-1">Desde</label>
<input
type="date"
id="filterDateFrom"
bind:value={filterDateFrom}
on:change={applyFilters}
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
/>
</div>
<!-- Fecha hasta -->
<div>
<label for="filterDateTo" class="block text-sm font-medium text-gray-700 mb-1">Hasta</label>
<input
type="date"
id="filterDateTo"
bind:value={filterDateTo}
on:change={applyFilters}
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
/>
</div>
<div>
<label for="filterPriority" class="block text-sm font-medium text-gray-700">Prioridad</label>
<select
id="filterPriority"
bind:value={filterPriority}
on:change={applyFilters}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
>
<option value="">Todas</option>
{#each PRIORITIES as priority}
<option value={priority.value}>{priority.label}</option>
{/each}
</select>
</div>
<div class="flex items-end">
<button
on:click={loadData}
class="w-full inline-flex justify-center items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Actualizar
</button>
</div>
</div>
</div>
@@ -441,13 +288,12 @@
<thead class="bg-gray-50">
<tr>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Ticket</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Cliente/Empresa</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Asunto</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Estado</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Prioridad</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Creado por</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Categoría</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Asignado a</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Fecha</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Creado</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">Acciones</span>
</th>
@@ -455,9 +301,9 @@
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#if isLoading}
<tr><td colspan="9" class="text-center py-4">Cargando...</td></tr>
<tr><td colspan="8" class="text-center py-4">Cargando...</td></tr>
{:else if tickets.length === 0}
<tr><td colspan="9" class="text-center py-4">No hay tickets registrados</td></tr>
<tr><td colspan="8" class="text-center py-4">No hay tickets registrados</td></tr>
{:else}
{#each tickets as ticket}
<tr
@@ -467,10 +313,6 @@
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
{ticket.ticket_number || ticket.id.substring(0, 8)}
</td>
<td class="px-3 py-4 text-sm text-gray-900">
<div class="font-medium text-indigo-600">{ticket.tenant?.name || 'N/A'}</div>
<div class="text-xs text-gray-500">{ticket.tenant?.contact_email || ''}</div>
</td>
<td class="px-3 py-4 text-sm text-gray-900">
<div class="font-medium">{ticket.subject}</div>
<div class="text-gray-500 truncate max-w-xs">{ticket.description}</div>
@@ -485,10 +327,8 @@
{getPriorityBadge(ticket.priority).label}
</span>
</td>
<td class="px-3 py-4 text-sm text-gray-900">
<div class="font-medium">{ticket.created_by_user?.first_name} {ticket.created_by_user?.last_name}</div>
<div class="text-xs text-gray-500">{ticket.created_by_user?.email}</div>
<div class="text-xs text-indigo-600">{ticket.created_by_user?.role || ''}</div>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{getCategoryName(ticket.category_id)}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{getUserName(ticket.assigned_to)}
@@ -522,8 +362,7 @@
</div>
<!-- Modal Crear Ticket -->
{#if showModal}
<Modal title="Nuevo Ticket" on:close={() => showModal = false}>
<Modal open={showModal} title="Nuevo Ticket" on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label for="subject" class="block text-sm font-medium text-gray-700">Asunto *</label>
@@ -609,11 +448,9 @@
</div>
</form>
</Modal>
{/if}
<!-- Modal Editar Ticket -->
{#if showEditModal}
<Modal title="Editar Ticket #{selectedTicket?.ticket_number || ''}" on:close={() => showEditModal = false}>
<Modal open={showEditModal} title="Editar Ticket #{selectedTicket?.ticket_number || ''}" on:close={() => showEditModal = false}>
<form on:submit|preventDefault={handleUpdate} class="space-y-4">
<div>
<label for="editStatus" class="block text-sm font-medium text-gray-700">Estado</label>
@@ -677,11 +514,9 @@
</div>
</form>
</Modal>
{/if}
<!-- Modal Eliminar Ticket -->
{#if showDeleteModal}
<Modal title="Eliminar Ticket" on:close={() => showDeleteModal = false}>
<Modal open={showDeleteModal} title="Eliminar Ticket" on:close={() => showDeleteModal = false}>
<div class="space-y-4">
<div class="bg-red-50 border border-red-200 rounded-md p-4">
<div class="flex">
@@ -723,5 +558,4 @@
</button>
</div>
</div>
</Modal>
{/if}
</Modal>

View File

@@ -172,8 +172,7 @@
</div>
</div>
{#if showModal}
<Modal title={editingUser ? 'Editar Usuario' : 'Nuevo Usuario'} on:close={() => showModal = false}>
<Modal open={showModal} title={editingUser ? 'Editar Usuario' : 'Nuevo Usuario'} on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
@@ -230,4 +229,3 @@
</div>
</form>
</Modal>
{/if}

View File

@@ -1,262 +0,0 @@
#!/usr/bin/env python3
"""
Database Utilities Script
Herramientas administrativas para gestión de base de datos
Uso:
python scripts/db_utils.py list-users [--tenant-id UUID]
python scripts/db_utils.py check-user EMAIL
python scripts/db_utils.py reset-password EMAIL [--password PASSWORD]
python scripts/db_utils.py list-tickets [--tenant-id UUID] [--limit N]
python scripts/db_utils.py check-ticket TICKET_ID
Ejemplos:
python scripts/db_utils.py list-users
python scripts/db_utils.py check-user admin@example.com
python scripts/db_utils.py reset-password admin@example.com --password admin123
python scripts/db_utils.py list-tickets --limit 10
"""
import asyncio
import sys
import os
from typing import Optional
import argparse
from pathlib import Path
# Agregar backend al path para imports
backend_path = Path(__file__).parent.parent / "backend"
sys.path.insert(0, str(backend_path))
from sqlalchemy import select
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from app.models.user import User
from app.models.ticket import Ticket
from app.models.tenant import Tenant
from app.core.security import SecurityUtils
class DBUtils:
"""Utilidades de gestión de base de datos"""
def __init__(self, database_url: Optional[str] = None):
self.database_url = database_url or os.getenv(
'DATABASE_URL',
'postgresql+asyncpg://postgres:postgres@localhost:5432/servicemanager'
)
self.engine = create_async_engine(self.database_url, echo=False)
self.async_session = sessionmaker(
self.engine,
class_=AsyncSession,
expire_on_commit=False
)
async def list_users(self, tenant_id: Optional[str] = None):
"""Listar todos los usuarios"""
async with self.async_session() as session:
query = select(User)
if tenant_id:
query = query.where(User.tenant_id == tenant_id)
result = await session.execute(query)
users = result.scalars().all()
if not users:
print("❌ No se encontraron usuarios")
return
print(f"\n{'='*80}")
print(f"📋 USUARIOS ({len(users)} encontrados)")
print(f"{'='*80}\n")
for user in users:
print(f" Email: {user.email}")
print(f" Role: {user.role}")
print(f" ID: {user.id}")
print(f" Tenant ID: {user.tenant_id}")
print(f" Activo: {'' if user.is_active else ''}")
print(f" {'-'*76}")
async def check_user(self, email: str):
"""Verificar información de un usuario específico"""
async with self.async_session() as session:
result = await session.execute(
select(User).where(User.email == email)
)
user = result.scalar_one_or_none()
if not user:
print(f"❌ Usuario '{email}' no encontrado")
return
print(f"\n{'='*80}")
print(f"👤 INFORMACIÓN DEL USUARIO")
print(f"{'='*80}\n")
print(f" Email: {user.email}")
print(f" Nombre: {user.first_name} {user.last_name}")
print(f" Role: {user.role}")
print(f" ID: {user.id}")
print(f" Tenant ID: {user.tenant_id}")
print(f" Activo: {'' if user.is_active else ''}")
print(f" 2FA: {'✅ Habilitado' if user.totp_secret else '❌ Deshabilitado'}")
print(f" Creado: {user.created_at}")
print(f"\n{'='*80}")
async def reset_password(self, email: str, new_password: str = "admin123"):
"""Resetear contraseña de un usuario"""
async with self.async_session() as session:
result = await session.execute(
select(User).where(User.email == email)
)
user = result.scalar_one_or_none()
if not user:
print(f"❌ Usuario '{email}' no encontrado")
return
# Hash nueva contraseña
password_hash = SecurityUtils.hash_password(new_password)
user.password_hash = password_hash
try:
await session.commit()
print(f"\n✅ Contraseña actualizada exitosamente")
print(f" Usuario: {email}")
print(f" Nueva contraseña: {new_password}")
print(f"\n⚠️ IMPORTANTE: Cambia esta contraseña después del primer login")
except Exception as e:
await session.rollback()
print(f"❌ Error al actualizar contraseña: {e}")
async def list_tickets(self, tenant_id: Optional[str] = None, limit: int = 20):
"""Listar tickets"""
async with self.async_session() as session:
query = select(Ticket).order_by(Ticket.created_at.desc()).limit(limit)
if tenant_id:
query = query.where(Ticket.tenant_id == tenant_id)
result = await session.execute(query)
tickets = result.scalars().all()
if not tickets:
print("❌ No se encontraron tickets")
return
print(f"\n{'='*80}")
print(f"🎫 TICKETS ({len(tickets)} encontrados, límite: {limit})")
print(f"{'='*80}\n")
for ticket in tickets:
print(f" {ticket.ticket_number} | {ticket.status} | {ticket.priority}")
print(f" Asunto: {ticket.subject}")
print(f" ID: {ticket.id}")
print(f" Tenant: {ticket.tenant_id}")
print(f" Creado: {ticket.created_at}")
print(f" {'-'*76}")
async def check_ticket(self, ticket_id: str):
"""Verificar información de un ticket específico"""
async with self.async_session() as session:
result = await session.execute(
select(Ticket).where(Ticket.id == ticket_id)
)
ticket = result.scalar_one_or_none()
if not ticket:
print(f"❌ Ticket '{ticket_id}' no encontrado")
return
# Obtener creador
creator_result = await session.execute(
select(User).where(User.id == ticket.created_by)
)
creator = creator_result.scalar_one_or_none()
# Obtener asignado
assigned = None
if ticket.assigned_to:
assigned_result = await session.execute(
select(User).where(User.id == ticket.assigned_to)
)
assigned = assigned_result.scalar_one_or_none()
print(f"\n{'='*80}")
print(f"🎫 INFORMACIÓN DEL TICKET")
print(f"{'='*80}\n")
print(f" Número: {ticket.ticket_number}")
print(f" Asunto: {ticket.subject}")
print(f" Estado: {ticket.status}")
print(f" Prioridad: {ticket.priority}")
print(f" ID: {ticket.id}")
print(f" Tenant ID: {ticket.tenant_id}")
if creator:
print(f" Creado por: {creator.email} ({creator.role})")
if assigned:
print(f" Asignado a: {assigned.email} ({assigned.role})")
print(f" Creado: {ticket.created_at}")
print(f" Actualizado: {ticket.updated_at}")
print(f"\n{'='*80}")
async def close(self):
"""Cerrar conexión"""
await self.engine.dispose()
async def main():
parser = argparse.ArgumentParser(
description='Utilidades de gestión de base de datos',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__
)
subparsers = parser.add_subparsers(dest='command', help='Comando a ejecutar')
# list-users
list_users_parser = subparsers.add_parser('list-users', help='Listar usuarios')
list_users_parser.add_argument('--tenant-id', help='Filtrar por tenant ID')
# check-user
check_user_parser = subparsers.add_parser('check-user', help='Verificar usuario')
check_user_parser.add_argument('email', help='Email del usuario')
# reset-password
reset_password_parser = subparsers.add_parser('reset-password', help='Resetear contraseña')
reset_password_parser.add_argument('email', help='Email del usuario')
reset_password_parser.add_argument('--password', default='admin123', help='Nueva contraseña')
# list-tickets
list_tickets_parser = subparsers.add_parser('list-tickets', help='Listar tickets')
list_tickets_parser.add_argument('--tenant-id', help='Filtrar por tenant ID')
list_tickets_parser.add_argument('--limit', type=int, default=20, help='Límite de resultados')
# check-ticket
check_ticket_parser = subparsers.add_parser('check-ticket', help='Verificar ticket')
check_ticket_parser.add_argument('ticket_id', help='ID del ticket')
args = parser.parse_args()
if not args.command:
parser.print_help()
return
utils = DBUtils()
try:
if args.command == 'list-users':
await utils.list_users(args.tenant_id)
elif args.command == 'check-user':
await utils.check_user(args.email)
elif args.command == 'reset-password':
await utils.reset_password(args.email, args.password)
elif args.command == 'list-tickets':
await utils.list_tickets(args.tenant_id, args.limit)
elif args.command == 'check-ticket':
await utils.check_ticket(args.ticket_id)
finally:
await utils.close()
if __name__ == "__main__":
asyncio.run(main())