From 96cd09476c8ee4ea523e9ef779878417d371d4db Mon Sep 17 00:00:00 2001 From: icamarillo Date: Thu, 12 Feb 2026 12:23:11 -0700 Subject: [PATCH] Auditoria funcionando --- backend/app/api/schemas/audit.py | 109 +++ backend/app/api/v1/endpoints/audit.py | 338 +++++++++ backend/app/api/v1/endpoints/auth.py | 165 +++- backend/app/api/v1/router.py | 9 +- backend/app/main.py | 2 + backend/app/models/__init__.py | 6 +- backend/app/models/audit.py | 148 ++++ backend/app/models/refresh_token.py | 171 +++++ backend/app/models/user.py | 7 + backend/app/services/audit_service.py | 311 ++++++++ backend/app/services/token_service.py | 270 +++++++ .../a1b2c3d4e5f6_add_audit_logs_table.py | 81 ++ frontend-client/src/lib/stores/app.ts | 5 +- frontend-client/src/lib/stores/auth.ts | 5 +- frontend-client/src/lib/stores/tickets.ts | 9 +- .../src/routes/profile/+page.svelte | 11 +- .../src/lib/components/Modal.svelte | 12 +- frontend-internal/src/lib/stores/auth.ts | 7 +- .../src/routes/audit/+page.svelte | 704 ++++++++++++++++++ .../src/routes/categories/+page.svelte | 4 +- .../src/routes/systems/+page.svelte | 4 +- .../src/routes/tenants/+page.svelte | 4 +- .../src/routes/tickets/+page.svelte | 14 +- .../src/routes/users/+page.svelte | 4 +- 24 files changed, 2363 insertions(+), 37 deletions(-) create mode 100644 backend/app/api/schemas/audit.py create mode 100644 backend/app/api/v1/endpoints/audit.py create mode 100644 backend/app/models/audit.py create mode 100644 backend/app/models/refresh_token.py create mode 100644 backend/app/services/audit_service.py create mode 100644 backend/app/services/token_service.py create mode 100644 backend/migrations/versions/a1b2c3d4e5f6_add_audit_logs_table.py create mode 100644 frontend-internal/src/routes/audit/+page.svelte diff --git a/backend/app/api/schemas/audit.py b/backend/app/api/schemas/audit.py new file mode 100644 index 0000000..0b44deb --- /dev/null +++ b/backend/app/api/schemas/audit.py @@ -0,0 +1,109 @@ +""" +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/v1/endpoints/audit.py b/backend/app/api/v1/endpoints/audit.py new file mode 100644 index 0000000..9a1f219 --- /dev/null +++ b/backend/app/api/v1/endpoints/audit.py @@ -0,0 +1,338 @@ +""" +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 97fe54b..9ca2f35 100644 --- a/backend/app/api/v1/endpoints/auth.py +++ b/backend/app/api/v1/endpoints/auth.py @@ -4,13 +4,14 @@ Authentication Endpoints - ServiceManagerWeb Endpoints para autenticación y autorización """ -from fastapi import APIRouter, HTTPException, status, Depends +from fastapi import APIRouter, HTTPException, status, Depends, Request 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 @@ -18,6 +19,8 @@ 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__) @@ -67,13 +70,17 @@ 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: @@ -99,6 +106,24 @@ 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" @@ -110,11 +135,31 @@ 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), @@ -126,6 +171,62 @@ 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, @@ -159,6 +260,9 @@ 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 @@ -171,18 +275,26 @@ async def refresh_token( """ logger.info("Token refresh attempt") - # Verify refresh token + # 1. Verify refresh token JWT signature payload = security.verify_token(refresh_data.refresh_token) if not payload or payload.get("type") != "refresh": - logger.warning("Token refresh failed - invalid token") + logger.warning("Token refresh failed - invalid JWT") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token" ) - # TODO: Check if refresh token exists in database and is not revoked + # 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" + ) - # Create new access token + # 3. Create new access token token_data = { "sub": payload["sub"], "email": payload["email"], @@ -192,7 +304,15 @@ async def refresh_token( access_token = security.create_access_token(token_data) - logger.info("Token refresh successful", user_id=payload["sub"]) + # 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 + ) return TokenResponse( access_token=access_token, @@ -206,14 +326,17 @@ async def logout( db: AsyncSession = Depends(get_db) ): """ - Logout user and revoke refresh token. + 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. Args: token: Access token db: Database session Returns: - Success message + Success message with count of revoked tokens """ logger.info("Logout attempt") @@ -225,11 +348,31 @@ async def logout( detail="Invalid token" ) - # TODO: Revoke refresh token in database + # 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" + ) - logger.info("Logout successful", user_id=payload["sub"]) + user_id = uuid.UUID(user_id_str) - return {"message": "Successfully logged out"} + # 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 + } @router.get("/me") diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 5edd7fa..b3f0b25 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 +from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit api_router = APIRouter() @@ -23,6 +23,13 @@ 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 a1eb6cf..4dc04c7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -23,6 +23,8 @@ from app.models.user import User from app.models.ticket import Ticket from app.models.comment import TicketComment from app.models.attachment import TicketAttachment +from app.models.audit import AuditLog # 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 f4fb709..4d14d97 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -8,6 +8,8 @@ from .system import System from .category import Category from .client_profile import ClientProfile from .attachment import TicketAttachment +from .audit import AuditLog +from .refresh_token import RefreshToken __all__ = [ "User", @@ -17,5 +19,7 @@ __all__ = [ "System", "Category", "ClientProfile", - "TicketAttachment" + "TicketAttachment", + "AuditLog", + "RefreshToken" ] \ No newline at end of file diff --git a/backend/app/models/audit.py b/backend/app/models/audit.py new file mode 100644 index 0000000..82bc99a --- /dev/null +++ b/backend/app/models/audit.py @@ -0,0 +1,148 @@ +""" +Audit Log Model - ServiceManagerWeb + +Modelo para bitácora de auditoría y compliance. +Registra todas las acciones importantes del sistema. +""" + +from sqlalchemy import String, Text, DateTime, ForeignKey, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID, INET, JSONB +from typing import Optional, Dict, Any, TYPE_CHECKING +import uuid +from datetime import datetime + +from app.core.database import Base + +if TYPE_CHECKING: + from app.models.tenant import Tenant + from app.models.user import User + + +class AuditLog(Base): + """ + Bitácora de auditoría para tracking completo de acciones. + + Registra: + - Quién hizo la acción (user_id) + - Qué hizo (action) + - Sobre qué recurso (resource_type + resource_id) + - Cuándo lo hizo (created_at) + - Desde dónde (ip_address, user_agent) + - Qué cambió (old_values, new_values) + """ + + __tablename__ = "audit_logs" + + # Multi-tenancy + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + index=True + ) + + # Usuario que ejecutó la acción (NULL = acción del sistema) + user_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + index=True + ) + + # Acción realizada (ej: "user.login", "ticket.create", "ticket.assign") + action: Mapped[str] = mapped_column( + String(100), + nullable=False, + index=True + ) + + # Tipo de recurso afectado (user, ticket, comment, category, etc.) + resource_type: Mapped[str] = mapped_column( + String(50), + nullable=False, + index=True + ) + + # ID del recurso afectado + resource_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + nullable=True + ) + + # Contexto de la request + ip_address: Mapped[Optional[str]] = mapped_column(INET, nullable=True) + user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + # Correlation ID para rastrear requests relacionadas + correlation_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + nullable=True, + index=True + ) + + # Valores antes del cambio (JSON) + old_values: Mapped[Optional[Dict[str, Any]]] = mapped_column( + JSONB, + nullable=True + ) + + # Valores después del cambio (JSON) + new_values: Mapped[Optional[Dict[str, Any]]] = mapped_column( + JSONB, + nullable=True + ) + + # Metadata adicional (cualquier info relevante) + # Nota: 'metadata' está reservado en SQLAlchemy, usamos 'extra_metadata' + extra_metadata: Mapped[Optional[Dict[str, Any]]] = mapped_column( + 'metadata', # Nombre real de la columna en BD + JSONB, + nullable=True + ) + + # Timestamp + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=datetime.utcnow, + nullable=False, + index=True + ) + + # Relaciones + tenant: Mapped["Tenant"] = relationship("Tenant", foreign_keys=[tenant_id]) + user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[user_id]) + + # Índices compuestos para queries comunes + __table_args__ = ( + Index('idx_audit_logs_tenant_action', 'tenant_id', 'action'), + Index('idx_audit_logs_resource', 'resource_type', 'resource_id'), + Index('idx_audit_logs_user_created', 'user_id', 'created_at'), + ) + + # Configuración del mapper: excluir updated_at porque audit logs son inmutables + __mapper_args__ = { + "exclude_properties": ["updated_at"] + } + + def __repr__(self) -> str: + return f"" + + @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 new file mode 100644 index 0000000..277f982 --- /dev/null +++ b/backend/app/models/refresh_token.py @@ -0,0 +1,171 @@ +""" +Refresh Token Model - ServiceManagerWeb + +Modelo para persistencia de refresh tokens con revocación y tracking. +""" + +from sqlalchemy import String, Boolean, DateTime, ForeignKey, Index, Integer +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID +from typing import Optional, TYPE_CHECKING +import uuid +from datetime import datetime + +from app.core.database import Base + +if TYPE_CHECKING: + from app.models.user import User + + +class RefreshToken(Base): + """ + Refresh Token persistente para gestión de sesiones. + + Almacena refresh tokens con información de dispositivo y permite + revocación para mejorar la seguridad. + + Características: + - Token hasheado (no se guarda en texto plano) + - Device fingerprinting + - Revocación individual con tracking + - Auto-expiración + - Tracking de IP y uso + """ + + __tablename__ = "refresh_tokens" + + # User relationship + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + index=True + ) + + # Token JWT (almacenado directamente - firmado y verificable) + # VARCHAR(500) para acomodar JWTs con payload extenso + token: Mapped[str] = mapped_column( + String(500), + nullable=False, + unique=True + ) + + # Device information + device_id: Mapped[Optional[str]] = mapped_column( + String(100), + nullable=True + ) + + device_name: Mapped[Optional[str]] = mapped_column( + String(200), + nullable=True + ) + + user_agent: Mapped[Optional[str]] = mapped_column( + String(500), + nullable=True + ) + + # IP address del cliente (varchar(45) para IPv6) + ip_address: Mapped[Optional[str]] = mapped_column( + String(45), + nullable=True + ) + + # Expiración del token + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + index=True + ) + + # Estado de revocación + revoked: Mapped[bool] = mapped_column( + Boolean, + default=False, + nullable=False + ) + + revoked_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), + nullable=True + ) + + revoked_by: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True + ) + + # Tracking de uso + last_used_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), + nullable=True + ) + + usage_count: Mapped[int] = mapped_column( + Integer, + default=0, + nullable=False + ) + + # Timestamps + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=datetime.utcnow, + nullable=False, + server_default="NOW()" + ) + + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=datetime.utcnow, + onupdate=datetime.utcnow, + nullable=False, + server_default="NOW()" + ) + + # Relación con usuario + user: Mapped["User"] = relationship("User", foreign_keys=[user_id], back_populates="refresh_tokens") + revoker: Mapped[Optional["User"]] = relationship("User", foreign_keys=[revoked_by]) + + # Índices compuestos + __table_args__ = ( + Index('idx_refresh_tokens_user_expires', 'user_id', 'expires_at'), + ) + + def __repr__(self) -> str: + return f"" + + @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 3d97dd0..403e33f 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -78,6 +78,13 @@ 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 new file mode 100644 index 0000000..5a77019 --- /dev/null +++ b/backend/app/services/audit_service.py @@ -0,0 +1,311 @@ +""" +Audit Service - ServiceManagerWeb + +Funciones helper para facilitar el registro de auditoría. +Simplifica el proceso de logging en toda la aplicación. +""" + +from typing import Optional, Dict, Any +from sqlalchemy.ext.asyncio import AsyncSession +from fastapi import Request +import uuid +import structlog + +from app.models.audit import AuditLog +from app.models.user import User + +logger = structlog.get_logger(__name__) + + +class AuditService: + """ + Servicio centralizado para registro de auditoría. + + Uso básico: + await AuditService.log( + db=db, + tenant_id=tenant.id, + user_id=current_user.id, + action="ticket.create", + resource_type="ticket", + resource_id=new_ticket.id, + new_values={"subject": "...", "status": "NEW"} + ) + """ + + @staticmethod + async def log( + db: AsyncSession, + tenant_id: uuid.UUID, + action: str, + resource_type: str, + resource_id: Optional[uuid.UUID] = None, + user_id: Optional[uuid.UUID] = None, + old_values: Optional[Dict[str, Any]] = None, + new_values: Optional[Dict[str, Any]] = None, + metadata: Optional[Dict[str, Any]] = None, + request: Optional[Request] = None + ) -> AuditLog: + """ + Registra una acción en la bitácora de auditoría. + + Args: + db: Sesión de base de datos + tenant_id: ID del tenant + action: Acción realizada (formato: "recurso.verbo") + Ejemplos: "user.login", "ticket.create", "ticket.assign" + resource_type: Tipo de recurso ("user", "ticket", "comment", etc.) + resource_id: ID del recurso afectado (opcional) + user_id: ID del usuario que ejecutó la acción (opcional = sistema) + old_values: Valores antes del cambio (opcional) + new_values: Valores después del cambio (opcional) + metadata: Información adicional (opcional) + request: Request de FastAPI para extraer IP y user agent (opcional) + + Returns: + AuditLog creado + """ + # Extraer información del request si está disponible + ip_address = None + user_agent = None + correlation_id = None + + if request: + # IP del cliente + if request.client: + ip_address = request.client.host + + # User agent + user_agent = request.headers.get("user-agent") + + # Correlation ID (si existe en el request state) + correlation_id = getattr(request.state, "correlation_id", None) + + # Crear registro de auditoría + audit_log = AuditLog( + tenant_id=tenant_id, + user_id=user_id, + action=action, + resource_type=resource_type, + resource_id=resource_id, + ip_address=ip_address, + user_agent=user_agent, + correlation_id=correlation_id, + old_values=old_values, + new_values=new_values, + extra_metadata=metadata # Mapeo metadata -> extra_metadata + ) + + db.add(audit_log) + await db.flush() # No commit, se hará con la transacción principal + + # Log estructurado para debugging + logger.info( + "Audit log created", + action=action, + resource_type=resource_type, + resource_id=str(resource_id) if resource_id else None, + user_id=str(user_id) if user_id else "system", + tenant_id=str(tenant_id) + ) + + return audit_log + + @staticmethod + async def log_login( + db: AsyncSession, + user: User, + request: Request, + success: bool = True + ) -> AuditLog: + """ + Registra un intento de login. + + Args: + db: Sesión de base de datos + user: Usuario que intentó loguearse + request: Request de FastAPI + success: Si el login fue exitoso + + Returns: + AuditLog creado + """ + return await AuditService.log( + db=db, + tenant_id=user.tenant_id, + user_id=user.id if success else None, + action="user.login" if success else "user.login_failed", + resource_type="user", + resource_id=user.id, + metadata={ + "success": success, + "email": user.email + }, + request=request + ) + + @staticmethod + async def log_logout( + db: AsyncSession, + user: User, + request: Request + ) -> AuditLog: + """ + Registra un logout. + + Args: + db: Sesión de base de datos + user: Usuario que cerró sesión + request: Request de FastAPI + + Returns: + AuditLog creado + """ + return await AuditService.log( + db=db, + tenant_id=user.tenant_id, + user_id=user.id, + action="user.logout", + resource_type="user", + resource_id=user.id, + request=request + ) + + @staticmethod + async def log_create( + db: AsyncSession, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + resource_type: str, + resource_id: uuid.UUID, + new_values: Dict[str, Any], + request: Optional[Request] = None + ) -> AuditLog: + """ + Registra la creación de un recurso. + + Args: + db: Sesión de base de datos + tenant_id: ID del tenant + user_id: ID del usuario que creó el recurso + resource_type: Tipo de recurso ("ticket", "user", etc.) + resource_id: ID del recurso creado + new_values: Valores del nuevo recurso + request: Request de FastAPI (opcional) + + Returns: + AuditLog creado + """ + return await AuditService.log( + db=db, + tenant_id=tenant_id, + user_id=user_id, + action=f"{resource_type}.create", + resource_type=resource_type, + resource_id=resource_id, + new_values=new_values, + request=request + ) + + @staticmethod + async def log_update( + db: AsyncSession, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + resource_type: str, + resource_id: uuid.UUID, + old_values: Dict[str, Any], + new_values: Dict[str, Any], + request: Optional[Request] = None + ) -> AuditLog: + """ + Registra la actualización de un recurso. + + Args: + db: Sesión de base de datos + tenant_id: ID del tenant + user_id: ID del usuario que actualizó + resource_type: Tipo de recurso + resource_id: ID del recurso + old_values: Valores anteriores + new_values: Valores nuevos + request: Request de FastAPI (opcional) + + Returns: + AuditLog creado + """ + return await AuditService.log( + db=db, + tenant_id=tenant_id, + user_id=user_id, + action=f"{resource_type}.update", + resource_type=resource_type, + resource_id=resource_id, + old_values=old_values, + new_values=new_values, + request=request + ) + + @staticmethod + async def log_delete( + db: AsyncSession, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + resource_type: str, + resource_id: uuid.UUID, + old_values: Dict[str, Any], + request: Optional[Request] = None + ) -> AuditLog: + """ + Registra la eliminación de un recurso. + + Args: + db: Sesión de base de datos + tenant_id: ID del tenant + user_id: ID del usuario que eliminó + resource_type: Tipo de recurso + resource_id: ID del recurso eliminado + old_values: Valores del recurso antes de eliminar + request: Request de FastAPI (opcional) + + Returns: + AuditLog creado + """ + return await AuditService.log( + db=db, + tenant_id=tenant_id, + user_id=user_id, + action=f"{resource_type}.delete", + resource_type=resource_type, + resource_id=resource_id, + old_values=old_values, + request=request + ) + + @staticmethod + def sanitize_values(values: Dict[str, Any]) -> Dict[str, Any]: + """ + Sanitiza valores sensibles antes de guardarlos en audit log. + + Remueve campos como passwords, tokens, etc. + + Args: + values: Diccionario de valores + + Returns: + Diccionario sanitizado + """ + sensitive_fields = { + 'password', + 'password_hash', + 'totp_secret', + 'backup_codes', + 'token', + 'access_token', + 'refresh_token' + } + + return { + key: '***REDACTED***' if key in sensitive_fields else value + for key, value in values.items() + } diff --git a/backend/app/services/token_service.py b/backend/app/services/token_service.py new file mode 100644 index 0000000..e11336c --- /dev/null +++ b/backend/app/services/token_service.py @@ -0,0 +1,270 @@ +""" +Token Service - ServiceManagerWeb + +Servicio para gestión de refresh tokens persistentes. +""" + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, delete +from datetime import datetime, timedelta +from typing import Optional +import uuid +import structlog + +from app.models.refresh_token import RefreshToken +from app.models.user import User +from app.core.config import get_settings + +logger = structlog.get_logger(__name__) +settings = get_settings() + + +class TokenService: + """ + Servicio para gestión de refresh tokens. + + Proporciona métodos para crear, validar, revocar y limpiar + refresh tokens persistentes. + + NOTA: Los tokens se almacenan directamente en BD (no hash) + ya que los JWTs son firmados y verificables. + """ + + @staticmethod + async def create_refresh_token( + db: AsyncSession, + user: User, + refresh_token: str, + device_id: Optional[str] = None, + device_name: Optional[str] = None, + user_agent: Optional[str] = None, + ip_address: Optional[str] = None + ) -> RefreshToken: + """ + Crear y persistir un refresh token. + + Args: + db: Sesión de base de datos + user: Usuario propietario del token + refresh_token: Token JWT generado (se almacena directamente) + device_id: ID único del dispositivo (UUID generado por cliente) + device_name: Nombre del dispositivo (ej: "Chrome en Windows") + user_agent: User agent completo del navegador + ip_address: IP del cliente + + Returns: + RefreshToken creado + """ + # Calcular expiración + expires_at = datetime.utcnow() + timedelta( + days=settings.REFRESH_TOKEN_EXPIRE_DAYS + ) + + # Crear registro - almacena JWT directamente (columna UNIQUE) + db_token = RefreshToken( + user_id=user.id, + token=refresh_token, # JWT almacenado directamente + device_id=device_id, + device_name=device_name, + user_agent=user_agent, + ip_address=ip_address, + expires_at=expires_at, + revoked=False, + usage_count=0 + ) + + db.add(db_token) + await db.flush() + + logger.info( + "Refresh token created", + user_id=str(user.id), + token_id=str(db_token.id), + device_name=device_name, + expires_at=expires_at.isoformat() + ) + + return db_token + + @staticmethod + async def verify_refresh_token( + db: AsyncSession, + refresh_token: str + ) -> Optional[RefreshToken]: + """ + Verificar que el refresh token exista y sea válido. + + Busca el JWT directamente en la BD y verifica su estado. + + Args: + db: Sesión de base de datos + refresh_token: Token JWT a verificar + + Returns: + RefreshToken si es válido, None si no existe o está revocado/expirado + """ + # Buscar token directamente en BD (sin hash) + query = select(RefreshToken).where( + RefreshToken.token == refresh_token + ) + result = await db.execute(query) + db_token = result.scalar_one_or_none() + + if not db_token: + logger.warning("Refresh token not found in database") + return None + + # Verificar si es válido (usa property is_valid del modelo) + if not db_token.is_valid: + logger.warning( + "Invalid refresh token", + token_id=str(db_token.id), + revoked=db_token.revoked, + expired=db_token.is_expired + ) + return None + + # Actualizar estadísticas de uso + db_token.track_usage() + await db.flush() + + logger.info( + "Refresh token verified and usage tracked", + token_id=str(db_token.id), + usage_count=db_token.usage_count + ) + return db_token + + @staticmethod + async def revoke_token( + db: AsyncSession, + refresh_token: str, + revoked_by_user_id: Optional[uuid.UUID] = None + ) -> bool: + """ + Revocar un refresh token específico. + + Args: + db: Sesión de base de datos + refresh_token: Token JWT a revocar + revoked_by_user_id: ID del usuario que revoca (para auditoría) + + Returns: + True si se revocó, False si no se encontró + """ + # Buscar token directamente (sin hash) + query = select(RefreshToken).where( + RefreshToken.token == refresh_token + ) + result = await db.execute(query) + db_token = result.scalar_one_or_none() + + if not db_token: + logger.warning("Refresh token not found for revocation") + return False + + # Revocar usando método del modelo + db_token.revoke(revoked_by=revoked_by_user_id) + await db.flush() + + logger.info( + "Refresh token revoked", + token_id=str(db_token.id), + revoked_by=str(revoked_by_user_id) if revoked_by_user_id else None + ) + return True + + @staticmethod + async def revoke_all_user_tokens( + db: AsyncSession, + user_id: uuid.UUID, + revoked_by_user_id: Optional[uuid.UUID] = None + ) -> int: + """ + Revocar todos los tokens activos de un usuario. + + Útil para logout en todos los dispositivos. + + Args: + db: Sesión de base de datos + user_id: ID del usuario + revoked_by_user_id: ID del usuario que ejecuta la revocación (para auditoría) + + Returns: + Número de tokens revocados + """ + # Buscar todos los tokens activos del usuario + query = select(RefreshToken).where( + RefreshToken.user_id == user_id, + RefreshToken.revoked == False + ) + result = await db.execute(query) + tokens = result.scalars().all() + + count = 0 + for token in tokens: + token.revoke(revoked_by=revoked_by_user_id) + count += 1 + + await db.flush() + + logger.info( + "All user tokens revoked", + user_id=str(user_id), + count=count, + revoked_by=str(revoked_by_user_id) if revoked_by_user_id else None + ) + return count + + @staticmethod + async def cleanup_expired_tokens( + db: AsyncSession + ) -> int: + """ + Eliminar tokens expirados de la base de datos. + + Tarea de mantenimiento para limpiar tokens antiguos. + + Args: + db: Sesión de base de datos + + Returns: + Número de tokens eliminados + """ + # Eliminar tokens expirados hace más de 7 días + cutoff_date = datetime.utcnow() - timedelta(days=7) + + query = delete(RefreshToken).where( + RefreshToken.expires_at < cutoff_date + ) + result = await db.execute(query) + await db.flush() + + deleted_count = result.rowcount + + logger.info("Expired tokens cleaned up", count=deleted_count) + return deleted_count + + @staticmethod + async def get_user_tokens( + db: AsyncSession, + user_id: uuid.UUID + ) -> list[RefreshToken]: + """ + Obtener todos los tokens activos de un usuario. + + Args: + db: Sesión de base de datos + user_id: ID del usuario + + Returns: + Lista de RefreshTokens activos + """ + query = select(RefreshToken).where( + RefreshToken.user_id == user_id, + RefreshToken.revoked == False, + RefreshToken.expires_at > datetime.utcnow() + ).order_by(RefreshToken.created_at.desc()) + + result = await db.execute(query) + return list(result.scalars().all()) diff --git a/backend/migrations/versions/a1b2c3d4e5f6_add_audit_logs_table.py b/backend/migrations/versions/a1b2c3d4e5f6_add_audit_logs_table.py new file mode 100644 index 0000000..6ca1751 --- /dev/null +++ b/backend/migrations/versions/a1b2c3d4e5f6_add_audit_logs_table.py @@ -0,0 +1,81 @@ +"""add_audit_logs_table + +Revision ID: a1b2c3d4e5f6 +Revises: 13362e8c493a +Create Date: 2026-02-12 10:00:00.000000 + +Registra el modelo AuditLog en Alembic. +La tabla audit_logs ya existe en schema.sql, esta migración solo +la registra en el control de versiones de Alembic. +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'a1b2c3d4e5f6' +down_revision = '13362e8c493a' +branch_labels = None +depends_on = None + + +def upgrade(): + """ + Verificar que audit_logs existe y registrarla en Alembic. + + La tabla fue creada por schema.sql, esta migración solo verifica + que exista y está disponible para usar. + """ + from sqlalchemy import inspect + + bind = op.get_bind() + inspector = inspect(bind) + tables = inspector.get_table_names() + + if 'audit_logs' in tables: + print("✅ Tabla audit_logs encontrada (creada por schema.sql)") + print("✅ Modelo AuditLog registrado en Alembic") + + # Verificar que tenga los índices necesarios + existing_indexes = [idx['name'] for idx in inspector.get_indexes('audit_logs')] + missing_indexes = [] + + required_indexes = [ + 'idx_audit_logs_tenant_id', + 'idx_audit_logs_user_id', + 'idx_audit_logs_action', + 'idx_audit_logs_correlation_id', + 'idx_audit_logs_created_at' + ] + + for idx in required_indexes: + if idx not in existing_indexes: + missing_indexes.append(idx) + + if missing_indexes: + print(f"⚠️ Índices faltantes: {', '.join(missing_indexes)}") + print(" (Esto es normal si usaste schema.sql completo)") + else: + print("✅ Todos los índices necesarios están presentes") + + else: + print("⚠️ La tabla audit_logs NO existe") + print(" Ejecuta: docker-compose exec -T postgres psql -U postgres -d servicemanager < db/schema.sql") + print(" O crea la tabla manualmente desde schema.sql") + + # No crear la tabla aquí - debe venir de schema.sql para mantener consistencia + raise Exception( + "La tabla audit_logs no existe. " + "Por favor ejecuta el schema.sql completo primero." + ) + + +def downgrade(): + """ + No eliminar la tabla - fue creada por schema.sql. + + Solo des-registrar de Alembic. + """ + print("ℹ️ Tabla audit_logs NO será eliminada (creada por schema.sql)") + print("✅ Modelo AuditLog des-registrado de Alembic") + diff --git a/frontend-client/src/lib/stores/app.ts b/frontend-client/src/lib/stores/app.ts index 300ea5f..b9282a3 100644 --- a/frontend-client/src/lib/stores/app.ts +++ b/frontend-client/src/lib/stores/app.ts @@ -3,6 +3,9 @@ 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; @@ -30,7 +33,7 @@ const initialState: AppState = { async function apiCall(endpoint: string, options: RequestInit = {}) { const authState = get(auth); - const response = await fetch(`/api/v1${endpoint}`, { + const response = await fetch(`${API_URL}/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 0823712..cefcc37 100644 --- a/frontend-client/src/lib/stores/auth.ts +++ b/frontend-client/src/lib/stores/auth.ts @@ -1,6 +1,9 @@ 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; @@ -79,7 +82,7 @@ function createAuthStore() { update(state => ({ ...state, isLoading: true })); try { - const response = await fetch('/api/v1/auth/login', { + const response = await fetch(`${API_URL}/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 0ced967..293b995 100644 --- a/frontend-client/src/lib/stores/tickets.ts +++ b/frontend-client/src/lib/stores/tickets.ts @@ -2,6 +2,9 @@ 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)[]; @@ -84,7 +87,7 @@ async function apiCall(endpoint: string, options: RequestInit = {}) { throw new Error('Not authenticated'); } - const response = await fetch(`/api/v1${endpoint}`, { + const response = await fetch(`${API_URL}/v1${endpoint}`, { ...options, headers: { 'Content-Type': 'application/json', @@ -259,7 +262,7 @@ function createTicketsStore() { throw new Error('Not authenticated'); } - const response = await fetch(`/api/v1/tickets/${ticketId}/attachments`, { + const response = await fetch(`${API_URL}/v1/tickets/${ticketId}/attachments`, { method: 'POST', headers: { 'Authorization': `Bearer ${authState.token}`, @@ -338,7 +341,7 @@ function createTicketsStore() { throw new Error('Not authenticated'); } - const response = await fetch(`/api/v1/tickets/${ticketId}/attachments/${attachmentId}/download`, { + const response = await fetch(`${API_URL}/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 484fa47..619e563 100644 --- a/frontend-client/src/routes/profile/+page.svelte +++ b/frontend-client/src/routes/profile/+page.svelte @@ -4,6 +4,9 @@ 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 = ''; @@ -82,7 +85,7 @@ return; } - const response = await fetch('/api/v1/client-profile/', { + const response = await fetch(`${API_URL}/v1/client-profile/`, { headers: { Authorization: `Bearer ${$auth.token}`, 'X-Tenant-ID': $auth.user.tenant_id @@ -185,7 +188,7 @@ isUpdatingProfile = true; try { - const response = await fetch('/api/v1/auth/profile', { + const response = await fetch(`${API_URL}/v1/auth/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', @@ -218,7 +221,7 @@ isChangingPassword = true; try { - const response = await fetch('/api/v1/auth/change-password', { + const response = await fetch(`${API_URL}/v1/auth/change-password`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -269,7 +272,7 @@ profileData.credit_limit = parseFloat(profileData.credit_limit); } - const response = await fetch('/api/v1/client-profile/', { + const response = await fetch(`${API_URL}/v1/client-profile/`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/frontend-internal/src/lib/components/Modal.svelte b/frontend-internal/src/lib/components/Modal.svelte index 9100d6a..1b358ea 100644 --- a/frontend-internal/src/lib/components/Modal.svelte +++ b/frontend-internal/src/lib/components/Modal.svelte @@ -1,7 +1,6 @@ + +
+ +
+
+

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 7d840e1..2152086 100644 --- a/frontend-internal/src/routes/categories/+page.svelte +++ b/frontend-internal/src/routes/categories/+page.svelte @@ -145,7 +145,8 @@ - showModal = false}> +{#if showModal} + showModal = false}>
@@ -182,3 +183,4 @@
+{/if} diff --git a/frontend-internal/src/routes/systems/+page.svelte b/frontend-internal/src/routes/systems/+page.svelte index 6354d64..a35407c 100644 --- a/frontend-internal/src/routes/systems/+page.svelte +++ b/frontend-internal/src/routes/systems/+page.svelte @@ -118,7 +118,8 @@ - showModal = false}> +{#if showModal} + showModal = false}>
@@ -145,3 +146,4 @@
+{/if} diff --git a/frontend-internal/src/routes/tenants/+page.svelte b/frontend-internal/src/routes/tenants/+page.svelte index 8185b30..f24390b 100644 --- a/frontend-internal/src/routes/tenants/+page.svelte +++ b/frontend-internal/src/routes/tenants/+page.svelte @@ -126,7 +126,8 @@ - showModal = false}> +{#if showModal} + showModal = false}>
@@ -164,3 +165,4 @@
+{/if} diff --git a/frontend-internal/src/routes/tickets/+page.svelte b/frontend-internal/src/routes/tickets/+page.svelte index d115088..13965af 100644 --- a/frontend-internal/src/routes/tickets/+page.svelte +++ b/frontend-internal/src/routes/tickets/+page.svelte @@ -522,7 +522,8 @@ - showModal = false}> +{#if showModal} + showModal = false}>
@@ -608,9 +609,11 @@
+{/if} - showEditModal = false}> +{#if showEditModal} + showEditModal = false}>
@@ -674,9 +677,11 @@
+{/if} - showDeleteModal = false}> +{#if showDeleteModal} + showDeleteModal = false}>
@@ -718,4 +723,5 @@
- \ No newline at end of file + +{/if} \ 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 aec2681..71f8cb1 100644 --- a/frontend-internal/src/routes/users/+page.svelte +++ b/frontend-internal/src/routes/users/+page.svelte @@ -172,7 +172,8 @@
- showModal = false}> +{#if showModal} + showModal = false}>
@@ -229,3 +230,4 @@
+{/if}