diff --git a/.gitignore b/.gitignore index 84aeec3..3ff236d 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index bc92463..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -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 diff --git a/README.md b/README.md index 6f39197..b040355 100644 --- a/README.md +++ b/README.md @@ -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 - -# Filtrar por tenant -python scripts/db_utils.py list-users --tenant-id -python scripts/db_utils.py list-tickets --tenant-id -``` - -**💡 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 diff --git a/RELEASE_NOTES_v1.5.1.md b/RELEASE_NOTES_v1.5.1.md deleted file mode 100644 index 5cd804b..0000000 --- a/RELEASE_NOTES_v1.5.1.md +++ /dev/null @@ -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 -- **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_ diff --git a/backend/app/api/schemas/audit.py b/backend/app/api/schemas/audit.py index 0b44deb..e69de29 100644 --- a/backend/app/api/schemas/audit.py +++ b/backend/app/api/schemas/audit.py @@ -1,109 +0,0 @@ -""" -Audit Schemas - ServiceManagerWeb - -Schemas Pydantic para endpoints de auditoría -""" - -from pydantic import BaseModel, Field, UUID4 -from typing import Optional, Dict, Any -from datetime import datetime - - -class AuditLogBase(BaseModel): - """Schema base para audit logs.""" - action: str = Field(..., description="Acción realizada (ej: ticket.create)") - resource_type: str = Field(..., description="Tipo de recurso (ticket, user, etc.)") - resource_id: Optional[UUID4] = Field(None, description="ID del recurso afectado") - extra_metadata: Optional[Dict[str, Any]] = Field(None, description="Metadata adicional", alias="metadata") - - -class AuditLogResponse(AuditLogBase): - """ - Schema de respuesta para audit logs. - - Incluye toda la información del log con datos del usuario. - """ - id: UUID4 - tenant_id: UUID4 - user_id: Optional[UUID4] - - # Información del usuario (si existe) - user_email: Optional[str] = None - user_name: Optional[str] = None - user_role: Optional[str] = None - - # Contexto de la acción - ip_address: Optional[str] - user_agent: Optional[str] - correlation_id: Optional[UUID4] - - # Cambios realizados - old_values: Optional[Dict[str, Any]] - new_values: Optional[Dict[str, Any]] - - # Timestamp - created_at: datetime - - # Display friendly - action_display: str = Field(description="Acción en formato amigable") - - class Config: - from_attributes = True - - -class AuditLogFilters(BaseModel): - """ - Filtros para consulta de audit logs. - - Permite filtrar por múltiples criterios. - """ - # Paginación - page: int = Field(default=1, ge=1, description="Número de página") - per_page: int = Field(default=50, ge=1, le=100, description="Elementos por página") - - # Filtros - user_id: Optional[UUID4] = Field(None, description="Filtrar por usuario") - action: Optional[str] = Field(None, description="Filtrar por acción específica") - resource_type: Optional[str] = Field(None, description="Filtrar por tipo de recurso") - resource_id: Optional[UUID4] = Field(None, description="Filtrar por ID de recurso") - - # Rango de fechas - date_from: Optional[datetime] = Field(None, description="Fecha inicio (ISO 8601)") - date_to: Optional[datetime] = Field(None, description="Fecha fin (ISO 8601)") - - # Búsqueda - search: Optional[str] = Field(None, description="Búsqueda en acciones o recursos") - - -class AuditLogStats(BaseModel): - """ - Estadísticas de auditoría. - - Resumen de actividad del sistema. - """ - total_actions: int = Field(description="Total de acciones registradas") - actions_today: int = Field(description="Acciones en las últimas 24 horas") - actions_this_week: int = Field(description="Acciones en los últimos 7 días") - - # Top acciones - top_actions: Dict[str, int] = Field(description="Acciones más frecuentes") - - # Top usuarios - top_users: Dict[str, int] = Field(description="Usuarios más activos") - - # Actividad por tipo de recurso - by_resource_type: Dict[str, int] = Field(description="Acciones por tipo de recurso") - - -class AuditLogListResponse(BaseModel): - """ - Respuesta paginada de audit logs. - """ - logs: list[AuditLogResponse] - total: int = Field(description="Total de registros") - page: int = Field(description="Página actual") - per_page: int = Field(description="Registros por página") - total_pages: int = Field(description="Total de páginas") - - class Config: - from_attributes = True diff --git a/backend/app/api/schemas/client_profile.py b/backend/app/api/schemas/client_profile.py index bd32390..c576e02 100644 --- a/backend/app/api/schemas/client_profile.py +++ b/backend/app/api/schemas/client_profile.py @@ -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 diff --git a/backend/app/api/v1/endpoints/audit.py b/backend/app/api/v1/endpoints/audit.py index 9a1f219..e69de29 100644 --- a/backend/app/api/v1/endpoints/audit.py +++ b/backend/app/api/v1/endpoints/audit.py @@ -1,338 +0,0 @@ -""" -Audit Endpoints - ServiceManagerWeb - -Endpoints para consulta de logs de auditoría. -Solo accesible por roles: ADMIN, SUPPORT_MANAGER, AUDITOR -""" - -from fastapi import APIRouter, Depends, HTTPException, status, Query -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, and_, or_, desc -from sqlalchemy.orm import selectinload -from typing import Optional, List -from datetime import datetime, timedelta -import uuid -import structlog - -from app.core.database import get_db -from app.api.deps import get_current_user, get_current_tenant -from app.models.user import User, UserRole -from app.models.tenant import Tenant -from app.models.audit import AuditLog -from app.api.schemas.audit import ( - AuditLogResponse, - AuditLogListResponse, - AuditLogFilters, - AuditLogStats -) - -router = APIRouter() -logger = structlog.get_logger(__name__) - - -def require_auditor_role(current_user: User = Depends(get_current_user)) -> User: - """ - Dependency que verifica que el usuario tenga rol de auditor. - - Solo ADMIN, SUPPORT_MANAGER y AUDITOR pueden ver logs de auditoría. - """ - allowed_roles = [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR] - - if current_user.role not in allowed_roles: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Solo usuarios con rol ADMIN, SUPPORT_MANAGER o AUDITOR pueden acceder a logs de auditoría" - ) - - return current_user - - -@router.get("/", response_model=AuditLogListResponse) -async def get_audit_logs( - # Paginación - page: int = Query(default=1, ge=1, description="Número de página"), - per_page: int = Query(default=50, ge=1, le=100, description="Registros por página"), - - # Filtros - user_id: Optional[uuid.UUID] = Query(None, description="Filtrar por usuario"), - action: Optional[str] = Query(None, description="Filtrar por acción"), - resource_type: Optional[str] = Query(None, description="Filtrar por tipo de recurso"), - resource_id: Optional[uuid.UUID] = Query(None, description="Filtrar por ID de recurso"), - date_from: Optional[datetime] = Query(None, description="Fecha desde"), - date_to: Optional[datetime] = Query(None, description="Fecha hasta"), - search: Optional[str] = Query(None, description="Búsqueda en acción o email"), - - # Dependencies - current_user: User = Depends(require_auditor_role), - current_tenant: Tenant = Depends(get_current_tenant), - db: AsyncSession = Depends(get_db) -): - """ - Obtener logs de auditoría con filtros y paginación. - - **Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR - - **Filtros disponibles**: - - `user_id`: Acciones de un usuario específico - - `action`: Tipo de acción (ej: "ticket.create") - - `resource_type`: Tipo de recurso (ej: "ticket") - - `resource_id`: ID de recurso específico - - `date_from`, `date_to`: Rango de fechas - - `search`: Búsqueda en acciones - - **Retorna**: Lista paginada de audit logs - """ - logger.info( - "Fetching audit logs", - user_id=str(current_user.id), - tenant_id=str(current_tenant.id), - filters={ - "user_id": str(user_id) if user_id else None, - "action": action, - "resource_type": resource_type, - "page": page - } - ) - - # Query base - solo logs del tenant actual - # Usar selectinload para cargar la relación user (eager loading para async) - query = ( - select(AuditLog) - .where(AuditLog.tenant_id == current_tenant.id) - .options(selectinload(AuditLog.user)) - ) - - # Aplicar filtros - if user_id: - query = query.where(AuditLog.user_id == user_id) - - if action: - query = query.where(AuditLog.action == action) - - if resource_type: - query = query.where(AuditLog.resource_type == resource_type) - - if resource_id: - query = query.where(AuditLog.resource_id == resource_id) - - if date_from: - query = query.where(AuditLog.created_at >= date_from) - - if date_to: - # Agregar 1 día para incluir todo el día - date_to_end = date_to + timedelta(days=1) - query = query.where(AuditLog.created_at < date_to_end) - - if search: - # Búsqueda en action - search_filter = AuditLog.action.ilike(f"%{search}%") - query = query.where(search_filter) - - # Ordenar por fecha descendente (más recientes primero) - query = query.order_by(desc(AuditLog.created_at)) - - # Contar total antes de paginar - count_query = select(func.count()).select_from(query.subquery()) - total_result = await db.execute(count_query) - total = total_result.scalar() or 0 - - # Aplicar paginación - offset = (page - 1) * per_page - query = query.offset(offset).limit(per_page) - - # Ejecutar query - result = await db.execute(query) - logs = result.scalars().all() - - # Calcular total de páginas - total_pages = (total + per_page - 1) // per_page - - # Convertir a response schema (agregar info del usuario) - logs_response = [] - for log in logs: - log_dict = { - "id": log.id, - "tenant_id": log.tenant_id, - "user_id": log.user_id, - "action": log.action, - "resource_type": log.resource_type, - "resource_id": log.resource_id, - "ip_address": str(log.ip_address) if log.ip_address else None, - "user_agent": log.user_agent, - "correlation_id": log.correlation_id, - "old_values": log.old_values, - "new_values": log.new_values, - "metadata": log.extra_metadata, - "created_at": log.created_at, - "action_display": log.action_display, - "user_email": None, - "user_name": None - } - - # Agregar info del usuario si existe - if log.user: - log_dict["user_email"] = log.user.email - log_dict["user_name"] = log.user.full_name - log_dict["user_role"] = log.user.role.value if hasattr(log.user.role, 'value') else str(log.user.role) - - logs_response.append(AuditLogResponse(**log_dict)) - - return AuditLogListResponse( - logs=logs_response, - total=total, - page=page, - per_page=per_page, - total_pages=total_pages - ) - - -@router.get("/stats", response_model=AuditLogStats) -async def get_audit_stats( - current_user: User = Depends(require_auditor_role), - current_tenant: Tenant = Depends(get_current_tenant), - db: AsyncSession = Depends(get_db) -): - """ - Obtener estadísticas de auditoría del tenant. - - **Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR - - **Retorna**: Estadísticas de actividad - """ - logger.info( - "Fetching audit stats", - user_id=str(current_user.id), - tenant_id=str(current_tenant.id) - ) - - now = datetime.utcnow() - - # Total de acciones - total_query = select(func.count()).select_from(AuditLog).where( - AuditLog.tenant_id == current_tenant.id - ) - total_result = await db.execute(total_query) - total_actions = total_result.scalar() or 0 - - # Acciones hoy (últimas 24 horas) - today_start = now - timedelta(days=1) - today_query = select(func.count()).select_from(AuditLog).where( - and_( - AuditLog.tenant_id == current_tenant.id, - AuditLog.created_at >= today_start - ) - ) - today_result = await db.execute(today_query) - actions_today = today_result.scalar() or 0 - - # Acciones esta semana (últimos 7 días) - week_start = now - timedelta(days=7) - week_query = select(func.count()).select_from(AuditLog).where( - and_( - AuditLog.tenant_id == current_tenant.id, - AuditLog.created_at >= week_start - ) - ) - week_result = await db.execute(week_query) - actions_this_week = week_result.scalar() or 0 - - # Top 5 acciones más frecuentes - top_actions_query = select( - AuditLog.action, - func.count(AuditLog.id).label('count') - ).where( - AuditLog.tenant_id == current_tenant.id - ).group_by( - AuditLog.action - ).order_by( - desc('count') - ).limit(5) - - top_actions_result = await db.execute(top_actions_query) - top_actions = {row.action: row.count for row in top_actions_result} - - # Acciones por tipo de recurso - by_resource_query = select( - AuditLog.resource_type, - func.count(AuditLog.id).label('count') - ).where( - AuditLog.tenant_id == current_tenant.id - ).group_by( - AuditLog.resource_type - ).order_by( - desc('count') - ) - - by_resource_result = await db.execute(by_resource_query) - by_resource_type = {row.resource_type: row.count for row in by_resource_result} - - # Top usuarios (necesitamos hacer join - simplificado por ahora) - # En producción podrías hacer un join con users para obtener nombres - top_users = {} # Placeholder - implementar con join si es necesario - - return AuditLogStats( - total_actions=total_actions, - actions_today=actions_today, - actions_this_week=actions_this_week, - top_actions=top_actions, - top_users=top_users, - by_resource_type=by_resource_type - ) - - -@router.get("/{log_id}", response_model=AuditLogResponse) -async def get_audit_log_detail( - log_id: uuid.UUID, - current_user: User = Depends(require_auditor_role), - current_tenant: Tenant = Depends(get_current_tenant), - db: AsyncSession = Depends(get_db) -): - """ - Obtener detalle de un audit log específico. - - **Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR - - **Retorna**: Detalle completo del audit log - """ - # Buscar el log - query = select(AuditLog).where( - and_( - AuditLog.id == log_id, - AuditLog.tenant_id == current_tenant.id - ) - ) - - result = await db.execute(query) - log = result.scalar_one_or_none() - - if not log: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Audit log {log_id} no encontrado" - ) - - # Convertir a response - log_dict = { - "id": log.id, - "tenant_id": log.tenant_id, - "user_id": log.user_id, - "action": log.action, - "resource_type": log.resource_type, - "resource_id": log.resource_id, - "ip_address": str(log.ip_address) if log.ip_address else None, - "user_agent": log.user_agent, - "correlation_id": log.correlation_id, - "old_values": log.old_values, - "new_values": log.new_values, - "metadata": log.extra_metadata, - "created_at": log.created_at, - "action_display": log.action_display, - "user_email": None, - "user_name": None - } - - if log.user: - log_dict["user_email"] = log.user.email - log_dict["user_name"] = log.user.full_name - - return AuditLogResponse(**log_dict) diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py index 9ca2f35..97fe54b 100644 --- a/backend/app/api/v1/endpoints/auth.py +++ b/backend/app/api/v1/endpoints/auth.py @@ -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,6 @@ 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 router = APIRouter() logger = structlog.get_logger(__name__) @@ -70,17 +67,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: @@ -106,24 +99,6 @@ async def login( "Login failed - invalid credentials", 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 - raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Credenciales inválidas" @@ -135,31 +110,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,62 +126,6 @@ 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() - logger.info( "Login successful", email=login_data.email, @@ -260,9 +159,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 +171,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 +192,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 +206,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 +225,11 @@ 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 - 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") diff --git a/backend/app/api/v1/endpoints/categories.py b/backend/app/api/v1/endpoints/categories.py index b3058e4..17eeaa7 100644 --- a/backend/app/api/v1/endpoints/categories.py +++ b/backend/app/api/v1/endpoints/categories.py @@ -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 diff --git a/backend/app/api/v1/endpoints/client_profile.py b/backend/app/api/v1/endpoints/client_profile.py index b6ff9d9..2fbcc80 100644 --- a/backend/app/api/v1/endpoints/client_profile.py +++ b/backend/app/api/v1/endpoints/client_profile.py @@ -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 diff --git a/backend/app/api/v1/endpoints/systems.py b/backend/app/api/v1/endpoints/systems.py index 21b5550..bfc8e85 100644 --- a/backend/app/api/v1/endpoints/systems.py +++ b/backend/app/api/v1/endpoints/systems.py @@ -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 diff --git a/backend/app/api/v1/endpoints/tickets.py b/backend/app/api/v1/endpoints/tickets.py index db8f5f7..ca2d1bc 100644 --- a/backend/app/api/v1/endpoints/tickets.py +++ b/backend/app/api/v1/endpoints/tickets.py @@ -78,188 +78,85 @@ async def create_ticket( """ Crear un nuevo ticket """ - # Retry logic para evitar race conditions en generación de ticket_number - max_retries = 3 - last_error = None - - for attempt in range(max_retries): - try: - # Generar número de ticket único basado en el máximo existente - result = await db.execute( - select(Ticket.ticket_number) - .where(Ticket.tenant_id == current_user.tenant_id) - .order_by(Ticket.ticket_number.desc()) - .limit(1) - ) - last_ticket_number = result.scalar_one_or_none() - - if last_ticket_number: - # Extraer el número del formato TK-XXXXXX - last_number = int(last_ticket_number.split('-')[1]) - next_number = last_number + 1 - else: - next_number = 1 - - ticket_number = f"TK-{next_number:06d}" - - # Convertir IDs de string a UUID si son proporcionados - category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None - system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None - - # Validar categoría - if category_uuid: - category = await db.get(Category, category_uuid) - if not category: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"La categoría con ID {ticket.category_id} no existe." - ) + try: + # Generar número de ticket único + result = await db.execute( + select(func.count(Ticket.id)).where(Ticket.tenant_id == current_user.tenant_id) + ) + count = result.scalar() or 0 + ticket_number = f"TK-{count + 1:06d}" + + # Generar número de ticket único + result = await db.execute( + select(func.count(Ticket.id)).where(Ticket.tenant_id == current_user.tenant_id) + ) + count = result.scalar() or 0 + ticket_number = f"TK-{count + 1:06d}" + + # Convertir IDs de string a UUID si son proporcionados + category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None + system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None # ✅ CORREGIDO + + # ✅ CORREGIDO: Validar en la tabla correcta con el nombre correcto del modelo + if category_uuid: + category = await db.get(Category, category_uuid) # ✅ Category, no TicketCategory + if not category: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"La categoría con ID {ticket.category_id} no existe." + ) - # Validar sistema - if system_uuid: - system = await db.get(System, system_uuid) - if not system: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"El sistema con ID {ticket.affected_system_id} no existe." - ) - - db_ticket = Ticket( - id=uuid.uuid4(), - tenant_id=current_user.tenant_id, - ticket_number=ticket_number, - subject=ticket.subject, - description=ticket.description, - category_id=category_uuid, - affected_system_id=system_uuid, - priority=TicketPriority[ticket.priority.upper()], - created_by=current_user.id, - status=TicketStatus.NEW, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow() - ) - - db.add(db_ticket) - await db.commit() - await db.refresh(db_ticket) - - # ✅ Éxito - retornar ticket creado - return { - "id": str(db_ticket.id), - "ticket_number": db_ticket.ticket_number, - "subject": db_ticket.subject, - "title": db_ticket.subject, - "description": db_ticket.description, - "status": db_ticket.status.value, - "priority": db_ticket.priority.value, - "category_id": str(db_ticket.category_id) if db_ticket.category_id else None, - "affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None, - "created_by": str(db_ticket.created_by), - "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None, - "created_at": db_ticket.created_at, - "updated_at": db_ticket.updated_at - } - - except ValueError as e: - await db.rollback() - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid UUID format: {str(e)}" - ) - except HTTPException: - # Re-lanzar HTTPExceptions directamente - await db.rollback() - raise - except Exception as e: - await db.rollback() - last_error = e - - # Si es un error de llave duplicada, reintentar - if "duplicate key" in str(e).lower() and "ticket_number" in str(e).lower(): - if attempt < max_retries - 1: - continue # Reintentar - - # Para cualquier otro error, fallar inmediatamente - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Error creating ticket: {str(e)}" - ) - - # Si llegamos aquí después de todos los reintentos - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"No se pudo crear el ticket después de {max_retries} intentos: {str(last_error)}" - ) - - -@router.get("/", response_model=List[TicketResponse]) -async def get_tickets( - skip: int = 0, - limit: int = 100, - status_filter: Optional[str] = None, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - Obtener tickets - Roles ADMIN/SUPPORT_MANAGER/AGENT: Ven todos los tickets del tenant - Roles CLIENT_USER/CLIENT_ADMIN: Solo ven sus propios tickets - """ - # Construir query base filtrado por tenant - query = select(Ticket).where( - Ticket.tenant_id == current_user.tenant_id - ) - - # Si es cliente, solo puede ver sus propios tickets - if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]: - query = query.where(Ticket.created_by == current_user.id) - - if status_filter: - try: - status_enum = TicketStatus[status_filter.upper()] - query = query.where(Ticket.status == status_enum) - except KeyError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid status: {status_filter}" - ) - - query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit) - - result = await db.execute(query) - tickets = result.scalars().all() - - # ✅ CORREGIDO: Usar affected_system_id - return [ - { - "id": str(t.id), - "ticket_number": t.ticket_number, - "subject": t.subject, - "title": t.subject, - "description": t.description, - "status": t.status.value, - "priority": t.priority.value, - "category_id": str(t.category_id) if t.category_id else None, - "affected_system_id": str(t.affected_system_id) if t.affected_system_id else None, # ✅ CORREGIDO - "created_by": str(t.created_by), - "assigned_to": str(t.assigned_to) if t.assigned_to else None, - "created_at": t.created_at, - "updated_at": t.updated_at + # Validar si el system_id existe en la tabla affected_systems + if system_uuid: + system = await db.get(System, system_uuid) + if not system: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"El sistema con ID {ticket.affected_system_id} no existe." + ) + + db_ticket = Ticket( + id=uuid.uuid4(), + tenant_id=current_user.tenant_id, + ticket_number=ticket_number, + subject=ticket.subject, + description=ticket.description, + category_id=category_uuid, + affected_system_id=system_uuid, # ✅ CORREGIDO: Nombre correcto del campo + priority=TicketPriority[ticket.priority.upper()], + created_by=current_user.id, + status=TicketStatus.NEW, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow() + ) + + db.add(db_ticket) + await db.commit() + await db.refresh(db_ticket) + + # ✅ CORREGIDO: Usar affected_system_id en respuesta + return { + "id": str(db_ticket.id), + "ticket_number": db_ticket.ticket_number, + "subject": db_ticket.subject, + "title": db_ticket.subject, + "description": db_ticket.description, + "status": db_ticket.status.value, + "priority": db_ticket.priority.value, + "category_id": str(db_ticket.category_id) if db_ticket.category_id else None, + "affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None, # ✅ CORREGIDO + "created_by": str(db_ticket.created_by), + "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None, + "created_at": db_ticket.created_at, + "updated_at": db_ticket.updated_at } - for t in tickets - ] - - -@router.get("/admin/all", response_model=List[dict]) -async def get_all_tickets_admin( - skip: int = 0, - limit: int = 100, - status_filter: Optional[str] = None, - priority_filter: Optional[str] = None, - tenant_id_filter: Optional[str] = None, - category_filter: Optional[str] = None, - assigned_to_filter: Optional[str] = None, - search: Optional[str] = None, + + except ValueError as e: + await db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid UUID format: {str(e)}" + ) date_from: Optional[str] = None, date_to: Optional[str] = None, db: AsyncSession = Depends(get_db), @@ -414,8 +311,6 @@ async def get_ticket( ): """ Obtener un ticket específico - Roles ADMIN/SUPPORT_MANAGER/AGENT: Pueden ver todos los tickets del tenant - Roles CLIENT_USER/CLIENT_ADMIN: Solo pueden ver sus propios tickets """ try: ticket_uuid = uuid.UUID(ticket_id) @@ -425,16 +320,12 @@ async def get_ticket( detail="Invalid ticket ID format" ) - # Construir query basado en el rol del usuario query = select(Ticket).where( Ticket.id == ticket_uuid, - Ticket.tenant_id == current_user.tenant_id + Ticket.tenant_id == current_user.tenant_id, + Ticket.created_by == current_user.id ) - # Si es cliente, solo puede ver sus propios tickets - if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]: - query = query.where(Ticket.created_by == current_user.id) - result = await db.execute(query) ticket = result.scalars().first() @@ -471,8 +362,6 @@ async def update_ticket( ): """ Actualizar un ticket - Roles ADMIN/SUPPORT_MANAGER/AGENT: Pueden actualizar cualquier ticket del tenant - Roles CLIENT_USER/CLIENT_ADMIN: Solo pueden actualizar sus propios tickets """ try: ticket_uuid = uuid.UUID(ticket_id) @@ -482,16 +371,12 @@ async def update_ticket( detail="Invalid ticket ID format" ) - # Construir query basado en el rol del usuario query = select(Ticket).where( Ticket.id == ticket_uuid, - Ticket.tenant_id == current_user.tenant_id + Ticket.tenant_id == current_user.tenant_id, + Ticket.created_by == current_user.id ) - # Si es cliente, solo puede actualizar sus propios tickets - if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]: - query = query.where(Ticket.created_by == current_user.id) - result = await db.execute(query) db_ticket = result.scalars().first() @@ -553,8 +438,6 @@ async def close_ticket( ): """ Cerrar un ticket - Roles ADMIN/SUPPORT_MANAGER/AGENT: Pueden cerrar cualquier ticket del tenant - Roles CLIENT_USER/CLIENT_ADMIN: Solo pueden cerrar sus propios tickets """ try: ticket_uuid = uuid.UUID(ticket_id) @@ -564,16 +447,12 @@ async def close_ticket( detail="Invalid ticket ID format" ) - # Construir query basado en el rol del usuario query = select(Ticket).where( Ticket.id == ticket_uuid, - Ticket.tenant_id == current_user.tenant_id + Ticket.tenant_id == current_user.tenant_id, + Ticket.created_by == current_user.id ) - # Si es cliente, solo puede cerrar sus propios tickets - if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]: - query = query.where(Ticket.created_by == current_user.id) - result = await db.execute(query) db_ticket = result.scalars().first() diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index b3f0b25..5edd7fa 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -6,7 +6,7 @@ Router principal para la API v1 from fastapi import APIRouter -from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit +from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile api_router = APIRouter() @@ -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", diff --git a/backend/app/main.py b/backend/app/main.py index 4dc04c7..a1eb6cf 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -23,8 +23,6 @@ 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.core.logging import setup_logging from app.api.v1.router import api_router diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 4d14d97..f4fb709 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -8,8 +8,6 @@ from .system import System from .category import Category from .client_profile import ClientProfile from .attachment import TicketAttachment -from .audit import AuditLog -from .refresh_token import RefreshToken __all__ = [ "User", @@ -19,7 +17,5 @@ __all__ = [ "System", "Category", "ClientProfile", - "TicketAttachment", - "AuditLog", - "RefreshToken" + "TicketAttachment" ] \ No newline at end of file diff --git a/backend/app/models/audit.py b/backend/app/models/audit.py index 82bc99a..e69de29 100644 --- a/backend/app/models/audit.py +++ b/backend/app/models/audit.py @@ -1,148 +0,0 @@ -""" -Audit Log Model - ServiceManagerWeb - -Modelo para bitácora de auditoría y compliance. -Registra todas las acciones importantes del sistema. -""" - -from sqlalchemy import String, Text, DateTime, ForeignKey, Index -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID, INET, JSONB -from typing import Optional, Dict, Any, TYPE_CHECKING -import uuid -from datetime import datetime - -from app.core.database import Base - -if TYPE_CHECKING: - from app.models.tenant import Tenant - from app.models.user import User - - -class AuditLog(Base): - """ - Bitácora de auditoría para tracking completo de acciones. - - Registra: - - Quién hizo la acción (user_id) - - Qué hizo (action) - - Sobre qué recurso (resource_type + resource_id) - - Cuándo lo hizo (created_at) - - Desde dónde (ip_address, user_agent) - - Qué cambió (old_values, new_values) - """ - - __tablename__ = "audit_logs" - - # Multi-tenancy - tenant_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), - ForeignKey("tenants.id", ondelete="CASCADE"), - nullable=False, - index=True - ) - - # Usuario que ejecutó la acción (NULL = acción del sistema) - user_id: Mapped[Optional[uuid.UUID]] = mapped_column( - UUID(as_uuid=True), - ForeignKey("users.id", ondelete="SET NULL"), - nullable=True, - index=True - ) - - # Acción realizada (ej: "user.login", "ticket.create", "ticket.assign") - action: Mapped[str] = mapped_column( - String(100), - nullable=False, - index=True - ) - - # Tipo de recurso afectado (user, ticket, comment, category, etc.) - resource_type: Mapped[str] = mapped_column( - String(50), - nullable=False, - index=True - ) - - # ID del recurso afectado - resource_id: Mapped[Optional[uuid.UUID]] = mapped_column( - UUID(as_uuid=True), - nullable=True - ) - - # Contexto de la request - ip_address: Mapped[Optional[str]] = mapped_column(INET, nullable=True) - user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True) - - # Correlation ID para rastrear requests relacionadas - correlation_id: Mapped[Optional[uuid.UUID]] = mapped_column( - UUID(as_uuid=True), - nullable=True, - index=True - ) - - # Valores antes del cambio (JSON) - old_values: Mapped[Optional[Dict[str, Any]]] = mapped_column( - JSONB, - nullable=True - ) - - # Valores después del cambio (JSON) - new_values: Mapped[Optional[Dict[str, Any]]] = mapped_column( - JSONB, - nullable=True - ) - - # Metadata adicional (cualquier info relevante) - # Nota: 'metadata' está reservado en SQLAlchemy, usamos 'extra_metadata' - extra_metadata: Mapped[Optional[Dict[str, Any]]] = mapped_column( - 'metadata', # Nombre real de la columna en BD - JSONB, - nullable=True - ) - - # Timestamp - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - default=datetime.utcnow, - nullable=False, - index=True - ) - - # Relaciones - tenant: Mapped["Tenant"] = relationship("Tenant", foreign_keys=[tenant_id]) - user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[user_id]) - - # Índices compuestos para queries comunes - __table_args__ = ( - Index('idx_audit_logs_tenant_action', 'tenant_id', 'action'), - Index('idx_audit_logs_resource', 'resource_type', 'resource_id'), - Index('idx_audit_logs_user_created', 'user_id', 'created_at'), - ) - - # Configuración del mapper: excluir updated_at porque audit logs son inmutables - __mapper_args__ = { - "exclude_properties": ["updated_at"] - } - - def __repr__(self) -> str: - return f"" - - @property - def action_display(self) -> str: - """Formato amigable de la acción.""" - parts = self.action.split('.') - if len(parts) == 2: - resource, verb = parts - verb_map = { - 'create': 'creó', - 'update': 'actualizó', - 'delete': 'eliminó', - 'login': 'inició sesión', - 'logout': 'cerró sesión', - 'assign': 'asignó', - 'close': 'cerró', - 'reopen': 'reabrió' - } - return f"{verb_map.get(verb, verb)} {resource}" - return self.action diff --git a/backend/app/models/refresh_token.py b/backend/app/models/refresh_token.py index 277f982..e69de29 100644 --- a/backend/app/models/refresh_token.py +++ b/backend/app/models/refresh_token.py @@ -1,171 +0,0 @@ -""" -Refresh Token Model - ServiceManagerWeb - -Modelo para persistencia de refresh tokens con revocación y tracking. -""" - -from sqlalchemy import String, Boolean, DateTime, ForeignKey, Index, Integer -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID -from typing import Optional, TYPE_CHECKING -import uuid -from datetime import datetime - -from app.core.database import Base - -if TYPE_CHECKING: - from app.models.user import User - - -class RefreshToken(Base): - """ - Refresh Token persistente para gestión de sesiones. - - Almacena refresh tokens con información de dispositivo y permite - revocación para mejorar la seguridad. - - Características: - - Token hasheado (no se guarda en texto plano) - - Device fingerprinting - - Revocación individual con tracking - - Auto-expiración - - Tracking de IP y uso - """ - - __tablename__ = "refresh_tokens" - - # User relationship - user_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), - ForeignKey("users.id", ondelete="CASCADE"), - nullable=False, - index=True - ) - - # Token JWT (almacenado directamente - firmado y verificable) - # VARCHAR(500) para acomodar JWTs con payload extenso - token: Mapped[str] = mapped_column( - String(500), - nullable=False, - unique=True - ) - - # Device information - device_id: Mapped[Optional[str]] = mapped_column( - String(100), - nullable=True - ) - - device_name: Mapped[Optional[str]] = mapped_column( - String(200), - nullable=True - ) - - user_agent: Mapped[Optional[str]] = mapped_column( - String(500), - nullable=True - ) - - # IP address del cliente (varchar(45) para IPv6) - ip_address: Mapped[Optional[str]] = mapped_column( - String(45), - nullable=True - ) - - # Expiración del token - expires_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - nullable=False, - index=True - ) - - # Estado de revocación - revoked: Mapped[bool] = mapped_column( - Boolean, - default=False, - nullable=False - ) - - revoked_at: Mapped[Optional[datetime]] = mapped_column( - DateTime(timezone=True), - nullable=True - ) - - revoked_by: Mapped[Optional[uuid.UUID]] = mapped_column( - UUID(as_uuid=True), - ForeignKey("users.id", ondelete="SET NULL"), - nullable=True - ) - - # Tracking de uso - last_used_at: Mapped[Optional[datetime]] = mapped_column( - DateTime(timezone=True), - nullable=True - ) - - usage_count: Mapped[int] = mapped_column( - Integer, - default=0, - nullable=False - ) - - # Timestamps - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - default=datetime.utcnow, - nullable=False, - server_default="NOW()" - ) - - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - default=datetime.utcnow, - onupdate=datetime.utcnow, - nullable=False, - server_default="NOW()" - ) - - # Relación con usuario - user: Mapped["User"] = relationship("User", foreign_keys=[user_id], back_populates="refresh_tokens") - revoker: Mapped[Optional["User"]] = relationship("User", foreign_keys=[revoked_by]) - - # Índices compuestos - __table_args__ = ( - Index('idx_refresh_tokens_user_expires', 'user_id', 'expires_at'), - ) - - def __repr__(self) -> str: - return f"" - - @property - def is_valid(self) -> bool: - """ - Verificar si el token es válido. - - Un token es válido si: - - No está revocado - - No ha expirado - """ - return not self.revoked and self.expires_at > datetime.utcnow() - - @property - def is_expired(self) -> bool: - """Verificar si el token ha expirado.""" - return datetime.utcnow() >= self.expires_at - - def revoke(self, revoked_by: Optional[uuid.UUID] = None) -> None: - """ - Marcar el token como revocado. - - Args: - revoked_by: ID del usuario que revocó el token - """ - self.revoked = True - self.revoked_at = datetime.utcnow() - if revoked_by: - self.revoked_by = revoked_by - - def track_usage(self) -> None: - """Registrar uso del token.""" - self.last_used_at = datetime.utcnow() - self.usage_count += 1 diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 403e33f..3d97dd0 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -78,13 +78,6 @@ 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]" - ) # Unique constraint por tenant __table_args__ = ( diff --git a/backend/app/services/audit_service.py b/backend/app/services/audit_service.py index 5a77019..e69de29 100644 --- a/backend/app/services/audit_service.py +++ b/backend/app/services/audit_service.py @@ -1,311 +0,0 @@ -""" -Audit Service - ServiceManagerWeb - -Funciones helper para facilitar el registro de auditoría. -Simplifica el proceso de logging en toda la aplicación. -""" - -from typing import Optional, Dict, Any -from sqlalchemy.ext.asyncio import AsyncSession -from fastapi import Request -import uuid -import structlog - -from app.models.audit import AuditLog -from app.models.user import User - -logger = structlog.get_logger(__name__) - - -class AuditService: - """ - Servicio centralizado para registro de auditoría. - - Uso básico: - await AuditService.log( - db=db, - tenant_id=tenant.id, - user_id=current_user.id, - action="ticket.create", - resource_type="ticket", - resource_id=new_ticket.id, - new_values={"subject": "...", "status": "NEW"} - ) - """ - - @staticmethod - async def log( - db: AsyncSession, - tenant_id: uuid.UUID, - action: str, - resource_type: str, - resource_id: Optional[uuid.UUID] = None, - user_id: Optional[uuid.UUID] = None, - old_values: Optional[Dict[str, Any]] = None, - new_values: Optional[Dict[str, Any]] = None, - metadata: Optional[Dict[str, Any]] = None, - request: Optional[Request] = None - ) -> AuditLog: - """ - Registra una acción en la bitácora de auditoría. - - Args: - db: Sesión de base de datos - tenant_id: ID del tenant - action: Acción realizada (formato: "recurso.verbo") - Ejemplos: "user.login", "ticket.create", "ticket.assign" - resource_type: Tipo de recurso ("user", "ticket", "comment", etc.) - resource_id: ID del recurso afectado (opcional) - user_id: ID del usuario que ejecutó la acción (opcional = sistema) - old_values: Valores antes del cambio (opcional) - new_values: Valores después del cambio (opcional) - metadata: Información adicional (opcional) - request: Request de FastAPI para extraer IP y user agent (opcional) - - Returns: - AuditLog creado - """ - # Extraer información del request si está disponible - ip_address = None - user_agent = None - correlation_id = None - - if request: - # IP del cliente - if request.client: - ip_address = request.client.host - - # User agent - user_agent = request.headers.get("user-agent") - - # Correlation ID (si existe en el request state) - correlation_id = getattr(request.state, "correlation_id", None) - - # Crear registro de auditoría - audit_log = AuditLog( - tenant_id=tenant_id, - user_id=user_id, - action=action, - resource_type=resource_type, - resource_id=resource_id, - ip_address=ip_address, - user_agent=user_agent, - correlation_id=correlation_id, - old_values=old_values, - new_values=new_values, - extra_metadata=metadata # Mapeo metadata -> extra_metadata - ) - - db.add(audit_log) - await db.flush() # No commit, se hará con la transacción principal - - # Log estructurado para debugging - logger.info( - "Audit log created", - action=action, - resource_type=resource_type, - resource_id=str(resource_id) if resource_id else None, - user_id=str(user_id) if user_id else "system", - tenant_id=str(tenant_id) - ) - - return audit_log - - @staticmethod - async def log_login( - db: AsyncSession, - user: User, - request: Request, - success: bool = True - ) -> AuditLog: - """ - Registra un intento de login. - - Args: - db: Sesión de base de datos - user: Usuario que intentó loguearse - request: Request de FastAPI - success: Si el login fue exitoso - - Returns: - AuditLog creado - """ - return await AuditService.log( - db=db, - tenant_id=user.tenant_id, - user_id=user.id if success else None, - action="user.login" if success else "user.login_failed", - resource_type="user", - resource_id=user.id, - metadata={ - "success": success, - "email": user.email - }, - request=request - ) - - @staticmethod - async def log_logout( - db: AsyncSession, - user: User, - request: Request - ) -> AuditLog: - """ - Registra un logout. - - Args: - db: Sesión de base de datos - user: Usuario que cerró sesión - request: Request de FastAPI - - Returns: - AuditLog creado - """ - return await AuditService.log( - db=db, - tenant_id=user.tenant_id, - user_id=user.id, - action="user.logout", - resource_type="user", - resource_id=user.id, - request=request - ) - - @staticmethod - async def log_create( - db: AsyncSession, - tenant_id: uuid.UUID, - user_id: uuid.UUID, - resource_type: str, - resource_id: uuid.UUID, - new_values: Dict[str, Any], - request: Optional[Request] = None - ) -> AuditLog: - """ - Registra la creación de un recurso. - - Args: - db: Sesión de base de datos - tenant_id: ID del tenant - user_id: ID del usuario que creó el recurso - resource_type: Tipo de recurso ("ticket", "user", etc.) - resource_id: ID del recurso creado - new_values: Valores del nuevo recurso - request: Request de FastAPI (opcional) - - Returns: - AuditLog creado - """ - return await AuditService.log( - db=db, - tenant_id=tenant_id, - user_id=user_id, - action=f"{resource_type}.create", - resource_type=resource_type, - resource_id=resource_id, - new_values=new_values, - request=request - ) - - @staticmethod - async def log_update( - db: AsyncSession, - tenant_id: uuid.UUID, - user_id: uuid.UUID, - resource_type: str, - resource_id: uuid.UUID, - old_values: Dict[str, Any], - new_values: Dict[str, Any], - request: Optional[Request] = None - ) -> AuditLog: - """ - Registra la actualización de un recurso. - - Args: - db: Sesión de base de datos - tenant_id: ID del tenant - user_id: ID del usuario que actualizó - resource_type: Tipo de recurso - resource_id: ID del recurso - old_values: Valores anteriores - new_values: Valores nuevos - request: Request de FastAPI (opcional) - - Returns: - AuditLog creado - """ - return await AuditService.log( - db=db, - tenant_id=tenant_id, - user_id=user_id, - action=f"{resource_type}.update", - resource_type=resource_type, - resource_id=resource_id, - old_values=old_values, - new_values=new_values, - request=request - ) - - @staticmethod - async def log_delete( - db: AsyncSession, - tenant_id: uuid.UUID, - user_id: uuid.UUID, - resource_type: str, - resource_id: uuid.UUID, - old_values: Dict[str, Any], - request: Optional[Request] = None - ) -> AuditLog: - """ - Registra la eliminación de un recurso. - - Args: - db: Sesión de base de datos - tenant_id: ID del tenant - user_id: ID del usuario que eliminó - resource_type: Tipo de recurso - resource_id: ID del recurso eliminado - old_values: Valores del recurso antes de eliminar - request: Request de FastAPI (opcional) - - Returns: - AuditLog creado - """ - return await AuditService.log( - db=db, - tenant_id=tenant_id, - user_id=user_id, - action=f"{resource_type}.delete", - resource_type=resource_type, - resource_id=resource_id, - old_values=old_values, - request=request - ) - - @staticmethod - def sanitize_values(values: Dict[str, Any]) -> Dict[str, Any]: - """ - Sanitiza valores sensibles antes de guardarlos en audit log. - - Remueve campos como passwords, tokens, etc. - - Args: - values: Diccionario de valores - - Returns: - Diccionario sanitizado - """ - sensitive_fields = { - 'password', - 'password_hash', - 'totp_secret', - 'backup_codes', - 'token', - 'access_token', - 'refresh_token' - } - - return { - key: '***REDACTED***' if key in sensitive_fields else value - for key, value in values.items() - } diff --git a/backend/app/services/token_service.py b/backend/app/services/token_service.py index e11336c..e69de29 100644 --- a/backend/app/services/token_service.py +++ b/backend/app/services/token_service.py @@ -1,270 +0,0 @@ -""" -Token Service - ServiceManagerWeb - -Servicio para gestión de refresh tokens persistentes. -""" - -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, delete -from datetime import datetime, timedelta -from typing import Optional -import uuid -import structlog - -from app.models.refresh_token import RefreshToken -from app.models.user import User -from app.core.config import get_settings - -logger = structlog.get_logger(__name__) -settings = get_settings() - - -class TokenService: - """ - Servicio para gestión de refresh tokens. - - Proporciona métodos para crear, validar, revocar y limpiar - refresh tokens persistentes. - - NOTA: Los tokens se almacenan directamente en BD (no hash) - ya que los JWTs son firmados y verificables. - """ - - @staticmethod - async def create_refresh_token( - db: AsyncSession, - user: User, - refresh_token: str, - device_id: Optional[str] = None, - device_name: Optional[str] = None, - user_agent: Optional[str] = None, - ip_address: Optional[str] = None - ) -> RefreshToken: - """ - Crear y persistir un refresh token. - - Args: - db: Sesión de base de datos - user: Usuario propietario del token - refresh_token: Token JWT generado (se almacena directamente) - device_id: ID único del dispositivo (UUID generado por cliente) - device_name: Nombre del dispositivo (ej: "Chrome en Windows") - user_agent: User agent completo del navegador - ip_address: IP del cliente - - Returns: - RefreshToken creado - """ - # Calcular expiración - expires_at = datetime.utcnow() + timedelta( - days=settings.REFRESH_TOKEN_EXPIRE_DAYS - ) - - # Crear registro - almacena JWT directamente (columna UNIQUE) - db_token = RefreshToken( - user_id=user.id, - token=refresh_token, # JWT almacenado directamente - device_id=device_id, - device_name=device_name, - user_agent=user_agent, - ip_address=ip_address, - expires_at=expires_at, - revoked=False, - usage_count=0 - ) - - db.add(db_token) - await db.flush() - - logger.info( - "Refresh token created", - user_id=str(user.id), - token_id=str(db_token.id), - device_name=device_name, - expires_at=expires_at.isoformat() - ) - - return db_token - - @staticmethod - async def verify_refresh_token( - db: AsyncSession, - refresh_token: str - ) -> Optional[RefreshToken]: - """ - Verificar que el refresh token exista y sea válido. - - Busca el JWT directamente en la BD y verifica su estado. - - Args: - db: Sesión de base de datos - refresh_token: Token JWT a verificar - - Returns: - RefreshToken si es válido, None si no existe o está revocado/expirado - """ - # Buscar token directamente en BD (sin hash) - query = select(RefreshToken).where( - RefreshToken.token == refresh_token - ) - result = await db.execute(query) - db_token = result.scalar_one_or_none() - - if not db_token: - logger.warning("Refresh token not found in database") - return None - - # Verificar si es válido (usa property is_valid del modelo) - if not db_token.is_valid: - logger.warning( - "Invalid refresh token", - token_id=str(db_token.id), - revoked=db_token.revoked, - expired=db_token.is_expired - ) - return None - - # Actualizar estadísticas de uso - db_token.track_usage() - await db.flush() - - logger.info( - "Refresh token verified and usage tracked", - token_id=str(db_token.id), - usage_count=db_token.usage_count - ) - return db_token - - @staticmethod - async def revoke_token( - db: AsyncSession, - refresh_token: str, - revoked_by_user_id: Optional[uuid.UUID] = None - ) -> bool: - """ - Revocar un refresh token específico. - - Args: - db: Sesión de base de datos - refresh_token: Token JWT a revocar - revoked_by_user_id: ID del usuario que revoca (para auditoría) - - Returns: - True si se revocó, False si no se encontró - """ - # Buscar token directamente (sin hash) - query = select(RefreshToken).where( - RefreshToken.token == refresh_token - ) - result = await db.execute(query) - db_token = result.scalar_one_or_none() - - if not db_token: - logger.warning("Refresh token not found for revocation") - return False - - # Revocar usando método del modelo - db_token.revoke(revoked_by=revoked_by_user_id) - await db.flush() - - logger.info( - "Refresh token revoked", - token_id=str(db_token.id), - revoked_by=str(revoked_by_user_id) if revoked_by_user_id else None - ) - return True - - @staticmethod - async def revoke_all_user_tokens( - db: AsyncSession, - user_id: uuid.UUID, - revoked_by_user_id: Optional[uuid.UUID] = None - ) -> int: - """ - Revocar todos los tokens activos de un usuario. - - Útil para logout en todos los dispositivos. - - Args: - db: Sesión de base de datos - user_id: ID del usuario - revoked_by_user_id: ID del usuario que ejecuta la revocación (para auditoría) - - Returns: - Número de tokens revocados - """ - # Buscar todos los tokens activos del usuario - query = select(RefreshToken).where( - RefreshToken.user_id == user_id, - RefreshToken.revoked == False - ) - result = await db.execute(query) - tokens = result.scalars().all() - - count = 0 - for token in tokens: - token.revoke(revoked_by=revoked_by_user_id) - count += 1 - - await db.flush() - - logger.info( - "All user tokens revoked", - user_id=str(user_id), - count=count, - revoked_by=str(revoked_by_user_id) if revoked_by_user_id else None - ) - return count - - @staticmethod - async def cleanup_expired_tokens( - db: AsyncSession - ) -> int: - """ - Eliminar tokens expirados de la base de datos. - - Tarea de mantenimiento para limpiar tokens antiguos. - - Args: - db: Sesión de base de datos - - Returns: - Número de tokens eliminados - """ - # Eliminar tokens expirados hace más de 7 días - cutoff_date = datetime.utcnow() - timedelta(days=7) - - query = delete(RefreshToken).where( - RefreshToken.expires_at < cutoff_date - ) - result = await db.execute(query) - await db.flush() - - deleted_count = result.rowcount - - logger.info("Expired tokens cleaned up", count=deleted_count) - return deleted_count - - @staticmethod - async def get_user_tokens( - db: AsyncSession, - user_id: uuid.UUID - ) -> list[RefreshToken]: - """ - Obtener todos los tokens activos de un usuario. - - Args: - db: Sesión de base de datos - user_id: ID del usuario - - Returns: - Lista de RefreshTokens activos - """ - query = select(RefreshToken).where( - RefreshToken.user_id == user_id, - RefreshToken.revoked == False, - RefreshToken.expires_at > datetime.utcnow() - ).order_by(RefreshToken.created_at.desc()) - - result = await db.execute(query) - return list(result.scalars().all()) diff --git a/backend/migrations/versions/a1b2c3d4e5f6_add_audit_logs_table.py b/backend/migrations/versions/a1b2c3d4e5f6_add_audit_logs_table.py index 6ca1751..e69de29 100644 --- a/backend/migrations/versions/a1b2c3d4e5f6_add_audit_logs_table.py +++ b/backend/migrations/versions/a1b2c3d4e5f6_add_audit_logs_table.py @@ -1,81 +0,0 @@ -"""add_audit_logs_table - -Revision ID: a1b2c3d4e5f6 -Revises: 13362e8c493a -Create Date: 2026-02-12 10:00:00.000000 - -Registra el modelo AuditLog en Alembic. -La tabla audit_logs ya existe en schema.sql, esta migración solo -la registra en el control de versiones de Alembic. -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = 'a1b2c3d4e5f6' -down_revision = '13362e8c493a' -branch_labels = None -depends_on = None - - -def upgrade(): - """ - Verificar que audit_logs existe y registrarla en Alembic. - - La tabla fue creada por schema.sql, esta migración solo verifica - que exista y está disponible para usar. - """ - from sqlalchemy import inspect - - bind = op.get_bind() - inspector = inspect(bind) - tables = inspector.get_table_names() - - if 'audit_logs' in tables: - print("✅ Tabla audit_logs encontrada (creada por schema.sql)") - print("✅ Modelo AuditLog registrado en Alembic") - - # Verificar que tenga los índices necesarios - existing_indexes = [idx['name'] for idx in inspector.get_indexes('audit_logs')] - missing_indexes = [] - - required_indexes = [ - 'idx_audit_logs_tenant_id', - 'idx_audit_logs_user_id', - 'idx_audit_logs_action', - 'idx_audit_logs_correlation_id', - 'idx_audit_logs_created_at' - ] - - for idx in required_indexes: - if idx not in existing_indexes: - missing_indexes.append(idx) - - if missing_indexes: - print(f"⚠️ Índices faltantes: {', '.join(missing_indexes)}") - print(" (Esto es normal si usaste schema.sql completo)") - else: - print("✅ Todos los índices necesarios están presentes") - - else: - print("⚠️ La tabla audit_logs NO existe") - print(" Ejecuta: docker-compose exec -T postgres psql -U postgres -d servicemanager < db/schema.sql") - print(" O crea la tabla manualmente desde schema.sql") - - # No crear la tabla aquí - debe venir de schema.sql para mantener consistencia - raise Exception( - "La tabla audit_logs no existe. " - "Por favor ejecuta el schema.sql completo primero." - ) - - -def downgrade(): - """ - No eliminar la tabla - fue creada por schema.sql. - - Solo des-registrar de Alembic. - """ - print("ℹ️ Tabla audit_logs NO será eliminada (creada por schema.sql)") - print("✅ Modelo AuditLog des-registrado de Alembic") - diff --git a/backend/pyproject.toml b/backend/pyproject.toml index a8ea720..d6386bb 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "servicemanager-backend" -version = "1.5.1" +version = "0.1.0" description = "ServiceManagerWeb Backend - Mesa de Ayuda B2B" authors = [ {name = "Aduanasoft", email = "dev@aduanasoft.com"} diff --git a/backend/set_test_password.py b/backend/set_test_password.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/test_rbac.py b/backend/test_rbac.py deleted file mode 100644 index 0155bad..0000000 --- a/backend/test_rbac.py +++ /dev/null @@ -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()) diff --git a/backend/test_ticket_access.py b/backend/test_ticket_access.py deleted file mode 100644 index 00bc78d..0000000 --- a/backend/test_ticket_access.py +++ /dev/null @@ -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()) diff --git a/frontend-client/package.json b/frontend-client/package.json index 267f127..51f0ca9 100644 --- a/frontend-client/package.json +++ b/frontend-client/package.json @@ -1,6 +1,6 @@ { "name": "@servicemanager/client-frontend", - "version": "1.5.1", + "version": "0.1.0", "private": true, "type": "module", "scripts": { diff --git a/frontend-client/src/lib/stores/app.ts b/frontend-client/src/lib/stores/app.ts index b9282a3..300ea5f 100644 --- a/frontend-client/src/lib/stores/app.ts +++ b/frontend-client/src/lib/stores/app.ts @@ -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', diff --git a/frontend-client/src/lib/stores/auth.ts b/frontend-client/src/lib/stores/auth.ts index cefcc37..0823712 100644 --- a/frontend-client/src/lib/stores/auth.ts +++ b/frontend-client/src/lib/stores/auth.ts @@ -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 User { id: string; @@ -82,7 +79,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', diff --git a/frontend-client/src/lib/stores/tickets.ts b/frontend-client/src/lib/stores/tickets.ts index 293b995..0ced967 100644 --- a/frontend-client/src/lib/stores/tickets.ts +++ b/frontend-client/src/lib/stores/tickets.ts @@ -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}`, diff --git a/frontend-client/src/routes/profile/+page.svelte b/frontend-client/src/routes/profile/+page.svelte index 619e563..484fa47 100644 --- a/frontend-client/src/routes/profile/+page.svelte +++ b/frontend-client/src/routes/profile/+page.svelte @@ -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', diff --git a/frontend-client/vite.config.js b/frontend-client/vite.config.js index 5fa2966..152d435 100644 --- a/frontend-client/vite.config.js +++ b/frontend-client/vite.config.js @@ -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/, '') } diff --git a/frontend-internal/package.json b/frontend-internal/package.json index f1796a1..d5fbd92 100644 --- a/frontend-internal/package.json +++ b/frontend-internal/package.json @@ -1,6 +1,6 @@ { "name": "@servicemanager/internal-frontend", - "version": "1.5.1", + "version": "0.1.0", "private": true, "type": "module", "scripts": { diff --git a/frontend-internal/src/lib/components/Header.svelte b/frontend-internal/src/lib/components/Header.svelte index c72343a..3a66a6a 100644 --- a/frontend-internal/src/lib/components/Header.svelte +++ b/frontend-internal/src/lib/components/Header.svelte @@ -1,5 +1,6 @@ diff --git a/frontend-internal/src/lib/stores/auth.ts b/frontend-internal/src/lib/stores/auth.ts index b5a0832..4d3a0ee 100644 --- a/frontend-internal/src/lib/stores/auth.ts +++ b/frontend-internal/src/lib/stores/auth.ts @@ -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', diff --git a/frontend-internal/src/lib/utils/api.ts b/frontend-internal/src/lib/utils/api.ts index 849719b..509a0ac 100644 --- a/frontend-internal/src/lib/utils/api.ts +++ b/frontend-internal/src/lib/utils/api.ts @@ -25,15 +25,11 @@ async function request(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(endpoint: string, options: RequestOptions = {}): Promi async function downloadFile(endpoint: string, filename: string): Promise { 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', diff --git a/frontend-internal/src/routes/audit/+page.svelte b/frontend-internal/src/routes/audit/+page.svelte index 86249ce..e69de29 100644 --- a/frontend-internal/src/routes/audit/+page.svelte +++ b/frontend-internal/src/routes/audit/+page.svelte @@ -1,704 +0,0 @@ - - -
- -
-
-

Auditoría

-

- Registro completo de todas las acciones realizadas en el sistema -

-
-
- - - {#if stats} -
-
-
-
-
- - - -
-
-
-
Total de Acciones
-
{stats.total_actions.toLocaleString()}
-
-
-
-
-
- -
-
-
-
- - - -
-
-
-
Hoy
-
{stats.actions_today}
-
-
-
-
-
- -
-
-
-
- - - -
-
-
-
Esta Semana
-
{stats.actions_this_week}
-
-
-
-
-
- -
-
-
-
- - - -
-
-
-
Acción más Común
-
- {#if stats.top_actions && Object.keys(stats.top_actions).length > 0} - {Object.keys(stats.top_actions)[0]} - {:else} - N/A - {/if} -
-
-
-
-
-
-
- {/if} - - -
-
-

Filtros

- {#if activeFiltersCount > 0} - - {/if} -
- -
- -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
-
-
- - -
-
-

- Logs de Auditoría - ({totalLogs} registros) -

-
- - {#if isLoading} -
-
-
- {:else if logs.length === 0} -
- - - -

No hay logs

-

No se encontraron registros con los filtros aplicados.

-
- {:else} -
- - - - - - - - - - - - - {#each logs as log (log.id)} - - - - - - - - - {/each} - -
- Fecha/Hora - - Usuario - - Acción - - Recurso - - IP - - Acciones -
- {formatDate(log.created_at)} - - {#if log.user_email} -
-
{log.user_name || 'N/A'}
-
{log.user_email}
- {#if log.user_role} -
- {log.user_role === 'ADMIN' ? 'Administrador' : - log.user_role === 'SUPPORT_MANAGER' ? 'Gerente de Soporte' : - log.user_role === 'AGENT' ? 'Agente' : - log.user_role === 'AUDITOR' ? 'Auditor' : - log.user_role === 'CLIENT_ADMIN' ? 'Admin de Cliente' : - log.user_role === 'CLIENT_USER' ? 'Usuario de Cliente' : - log.user_role} -
- {/if} -
- {:else} - Sistema - {/if} -
- - {formatActionWithRole(log)} - - -
-
{log.resource_type}
- {#if log.resource_id} -
{log.resource_id}
- {/if} -
-
- {log.ip_address || 'N/A'} - - -
-
- - - {#if totalPages > 1} -
-
- - -
- -
- {/if} - {/if} -
-
- - -{#if showDetailModal && selectedLog} - showDetailModal = false}> -
- -
-

Información General

-
-
-
ID:
-
{selectedLog.id}
-
-
-
Fecha:
-
{formatDate(selectedLog.created_at)}
-
-
-
Usuario:
-
{selectedLog.user_name || 'Sistema'}
-
-
-
Email:
-
{selectedLog.user_email || 'N/A'}
-
- {#if selectedLog.user_role} -
-
Rol:
-
- {selectedLog.user_role === 'ADMIN' ? 'Administrador' : - selectedLog.user_role === 'SUPPORT_MANAGER' ? 'Gerente de Soporte' : - selectedLog.user_role === 'AGENT' ? 'Agente' : - selectedLog.user_role === 'AUDITOR' ? 'Auditor' : - selectedLog.user_role === 'CLIENT_ADMIN' ? 'Admin de Cliente' : - selectedLog.user_role === 'CLIENT_USER' ? 'Usuario de Cliente' : - selectedLog.user_role} -
-
- {/if} -
-
IP:
-
{selectedLog.ip_address || 'N/A'}
-
-
-
Correlation ID:
-
{selectedLog.correlation_id || 'N/A'}
-
-
-
- - -
-

Acción

-
- - {selectedLog.action} - -

{formatActionWithRole(selectedLog)}

-
-
- - -
-

Recurso Afectado

-
-
Tipo: {selectedLog.resource_type}
- {#if selectedLog.resource_id} -
ID: {selectedLog.resource_id}
- {/if} -
-
- - - {#if selectedLog.user_agent} -
-

User Agent

-
- {selectedLog.user_agent} -
-
- {/if} - - - {#if selectedLog.old_values} -
-

Valores Anteriores

-
{JSON.stringify(selectedLog.old_values, null, 2)}
-
- {/if} - - - {#if selectedLog.new_values} -
-

Valores Nuevos

-
{JSON.stringify(selectedLog.new_values, null, 2)}
-
- {/if} - - - {#if selectedLog.metadata} -
-

Metadata Adicional

-
{JSON.stringify(selectedLog.metadata, null, 2)}
-
- {/if} -
- -
- -
-
-{/if} diff --git a/frontend-internal/src/routes/categories/+page.svelte b/frontend-internal/src/routes/categories/+page.svelte index 2152086..7d840e1 100644 --- a/frontend-internal/src/routes/categories/+page.svelte +++ b/frontend-internal/src/routes/categories/+page.svelte @@ -145,8 +145,7 @@ -{#if showModal} - showModal = false}> + showModal = false}>
@@ -183,4 +182,3 @@
-{/if} diff --git a/frontend-internal/src/routes/systems/+page.svelte b/frontend-internal/src/routes/systems/+page.svelte index a35407c..6354d64 100644 --- a/frontend-internal/src/routes/systems/+page.svelte +++ b/frontend-internal/src/routes/systems/+page.svelte @@ -118,8 +118,7 @@ -{#if showModal} - showModal = false}> + showModal = false}>
@@ -146,4 +145,3 @@
-{/if} diff --git a/frontend-internal/src/routes/tenants/+page.svelte b/frontend-internal/src/routes/tenants/+page.svelte index f24390b..8185b30 100644 --- a/frontend-internal/src/routes/tenants/+page.svelte +++ b/frontend-internal/src/routes/tenants/+page.svelte @@ -126,8 +126,7 @@ -{#if showModal} - showModal = false}> + showModal = false}>
@@ -165,4 +164,3 @@
-{/if} diff --git a/frontend-internal/src/routes/tickets/+page.svelte b/frontend-internal/src/routes/tickets/+page.svelte index 13965af..af116ca 100644 --- a/frontend-internal/src/routes/tickets/+page.svelte +++ b/frontend-internal/src/routes/tickets/+page.svelte @@ -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 @@
-
+

Tickets de Soporte

-

- Gestión de tickets del sistema de mesa de ayuda. - {#if activeFiltersCount > 0} - - {activeFiltersCount} filtro{activeFiltersCount !== 1 ? 's' : ''} activo{activeFiltersCount !== 1 ? 's' : ''} - - {/if} -

+

Gestión de tickets del sistema de mesa de ayuda.

-
- {#if activeFiltersCount > 0} - - {/if} +
- -
-
-

Filtros

- - -
- -
- -
-
- - - -
- -
-
- - -
- - -
- - -
- - -
+ +
+
+
+ +
- -
- -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
+
+ + +
+ +
+
@@ -441,13 +288,12 @@ Ticket - Cliente/Empresa Asunto Estado Prioridad - Creado por + Categoría Asignado a - Fecha + Creado Acciones @@ -455,9 +301,9 @@ {#if isLoading} - Cargando... + Cargando... {:else if tickets.length === 0} - No hay tickets registrados + No hay tickets registrados {:else} {#each tickets as ticket} {ticket.ticket_number || ticket.id.substring(0, 8)} - -
{ticket.tenant?.name || 'N/A'}
-
{ticket.tenant?.contact_email || ''}
-
{ticket.subject}
{ticket.description}
@@ -485,10 +327,8 @@ {getPriorityBadge(ticket.priority).label} - -
{ticket.created_by_user?.first_name} {ticket.created_by_user?.last_name}
-
{ticket.created_by_user?.email}
-
{ticket.created_by_user?.role || ''}
+ + {getCategoryName(ticket.category_id)} {getUserName(ticket.assigned_to)} @@ -522,8 +362,7 @@
-{#if showModal} - showModal = false}> + showModal = false}>
@@ -609,11 +448,9 @@
-{/if} -{#if showEditModal} - showEditModal = false}> + showEditModal = false}>
@@ -677,11 +514,9 @@
-{/if} -{#if showDeleteModal} - showDeleteModal = false}> + showDeleteModal = false}>
@@ -723,5 +558,4 @@
- -{/if} \ No newline at end of file + \ No newline at end of file diff --git a/frontend-internal/src/routes/users/+page.svelte b/frontend-internal/src/routes/users/+page.svelte index 71f8cb1..aec2681 100644 --- a/frontend-internal/src/routes/users/+page.svelte +++ b/frontend-internal/src/routes/users/+page.svelte @@ -172,8 +172,7 @@
-{#if showModal} - showModal = false}> + showModal = false}>
@@ -230,4 +229,3 @@
-{/if} diff --git a/scripts/db_utils.py b/scripts/db_utils.py deleted file mode 100644 index 1ad21fa..0000000 --- a/scripts/db_utils.py +++ /dev/null @@ -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())