diff --git a/backend/app/api/schemas/audit.py b/backend/app/api/schemas/audit.py index e69de29..fcd101d 100644 --- a/backend/app/api/schemas/audit.py +++ b/backend/app/api/schemas/audit.py @@ -0,0 +1,159 @@ +""" +Audit Schemas - ServiceManagerWeb + +Schemas Pydantic para endpoints de auditor├¡a +""" + +from pydantic import BaseModel, Field, UUID4 +from typing import Optional, Dict, Any +from datetime import datetime + + +class AuditLogBase(BaseModel): + """Schema base para audit logs.""" + action: str = Field(..., description="Acci├│n realizada (ej: ticket.create)") + resource_type: str = Field(..., description="Tipo de recurso (ticket, user, etc.)") + resource_id: Optional[UUID4] = Field(None, description="ID del recurso afectado") + extra_metadata: Optional[Dict[str, Any]] = Field(None, description="Metadata adicional", alias="metadata") + + +class AuditLogResponse(AuditLogBase): + """ + Schema de respuesta para audit logs. + + Incluye toda la informaci├│n del log con datos del usuario. + """ + id: UUID4 + tenant_id: UUID4 + user_id: Optional[UUID4] + + # Informaci├│n del usuario (si existe) + user_email: Optional[str] = None + user_name: Optional[str] = None + user_role: Optional[str] = None + + # Contexto de la acci├│n + ip_address: Optional[str] + user_agent: Optional[str] + correlation_id: Optional[UUID4] + + # Cambios realizados + old_values: Optional[Dict[str, Any]] + new_values: Optional[Dict[str, Any]] + + # Timestamp + created_at: datetime + + # Display friendly + action_display: str = Field(description="Acci├│n en formato amigable") + + class Config: + from_attributes = True + + +# =================================== +# SECURITY ANALYSIS SCHEMAS +# =================================== + +class SecurityThreatPattern(BaseModel): + """Patrón de amenaza detectado.""" + type: str = Field(description="Tipo de amenaza (brute_force, privilege_escalation, etc.)") + severity: str = Field(description="Severidad: low, medium, high, critical") + description: str = Field(description="Descripción de la amenaza") + occurrences: int = Field(description="Número de ocurrencias") + affected_ips: list[str] = Field(default=[], description="IPs involucradas") + affected_users: list[str] = Field(default=[], description="Usuarios afectados") + first_seen: datetime = Field(description="Primera ocurrencia") + last_seen: datetime = Field(description="Última ocurrencia") + recommendations: list[str] = Field(default=[], description="Recomendaciones de acción") + + +class SecurityAnalysisResponse(BaseModel): + """Análisis completo de seguridad.""" + overall_risk_level: str = Field(description="Nivel de riesgo general: safe, low, medium, high, critical") + total_threats_detected: int = Field(description="Total de amenazas detectadas") + threats: list[SecurityThreatPattern] = Field(description="Lista de amenazas detectadas") + analysis_period_hours: int = Field(description="Período de análisis en horas") + generated_at: datetime = Field(description="Timestamp del análisis") + + # Estadísticas de seguridad + failed_login_attempts: int = Field(description="Intentos fallidos de login") + suspicious_ips_count: int = Field(description="IPs sospechosas detectadas") + critical_actions_count: int = Field(description="Acciones críticas realizadas") + + # Opciones de acción + recommended_actions: list[str] = Field(default=[], description="Acciones recomendadas") + + +class SecurityActionRequest(BaseModel): + """Solicitud de acción de seguridad.""" + action_type: str = Field(description="Tipo de acción: block_ip, notify_admin, reset_password, etc.") + target: str = Field(description="Objetivo de la acción (IP, email, etc.)") + reason: str = Field(description="Razón de la acción") + duration_minutes: Optional[int] = Field(None, description="Duración del bloqueo en minutos") + + +class SecurityActionResponse(BaseModel): + """Respuesta de acción de seguridad.""" + success: bool = Field(description="Si la acción fue exitosa") + message: str = Field(description="Mensaje descriptivo") + action_id: Optional[UUID4] = Field(None, description="ID de la acción registrada") + + +class AuditLogFilters(BaseModel): + """ + Filtros para consulta de audit logs. + + Permite filtrar por m├║ltiples criterios. + """ + # Paginaci├│n + page: int = Field(default=1, ge=1, description="N├║mero de p├ígina") + per_page: int = Field(default=50, ge=1, le=100, description="Elementos por p├ígina") + + # Filtros + user_id: Optional[UUID4] = Field(None, description="Filtrar por usuario") + action: Optional[str] = Field(None, description="Filtrar por acci├│n espec├¡fica") + resource_type: Optional[str] = Field(None, description="Filtrar por tipo de recurso") + resource_id: Optional[UUID4] = Field(None, description="Filtrar por ID de recurso") + + # Rango de fechas + date_from: Optional[datetime] = Field(None, description="Fecha inicio (ISO 8601)") + date_to: Optional[datetime] = Field(None, description="Fecha fin (ISO 8601)") + + # B├║squeda + search: Optional[str] = Field(None, description="B├║squeda en acciones o recursos") + + +class AuditLogStats(BaseModel): + """ + Estad├¡sticas de auditor├¡a. + + Resumen de actividad del sistema. + """ + total_actions: int = Field(description="Total de acciones registradas") + actions_today: int = Field(description="Acciones en las ├║ltimas 24 horas") + actions_this_week: int = Field(description="Acciones en los ├║ltimos 7 d├¡as") + critical_actions_today: int = Field(description="Acciones cr├¡ticas hoy (delete, cambios sensibles)") + + # Top acciones + top_actions: Dict[str, int] = Field(description="Acciones m├ís frecuentes") + + # Top usuarios + top_users: Dict[str, int] = Field(description="Usuarios m├ís activos") + + # Actividad por tipo de recurso + by_resource_type: Dict[str, int] = Field(description="Acciones por tipo de recurso") + + +class AuditLogListResponse(BaseModel): + """ + Respuesta paginada de audit logs. + """ + logs: list[AuditLogResponse] + total: int = Field(description="Total de registros") + page: int = Field(description="P├ígina actual") + per_page: int = Field(description="Registros por p├ígina") + total_pages: int = Field(description="Total de p├íginas") + + class Config: + from_attributes = True diff --git a/backend/app/api/v1/endpoints/audit.py b/backend/app/api/v1/endpoints/audit.py index e69de29..2ddea5d 100644 --- a/backend/app/api/v1/endpoints/audit.py +++ b/backend/app/api/v1/endpoints/audit.py @@ -0,0 +1,715 @@ +""" +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.services.audit_service import AuditService +from app.api.schemas.audit import ( + AuditLogResponse, + AuditLogListResponse, + AuditLogFilters, + AuditLogStats, + SecurityAnalysisResponse, + SecurityThreatPattern, + SecurityActionRequest, + SecurityActionResponse +) + +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"), + # Multi-tenant filters (solo ADMIN/SUPPORT_MANAGER) + tenant_id: Optional[uuid.UUID] = Query(None, description="Ver logs de un tenant específico"), + all_tenants: bool = Query(False, description="Ver logs de todos los tenants"), + # 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, + "tenant_filter": str(tenant_id) if tenant_id else None, + "all_tenants": all_tenants + } + ) + + # Determinar el filtro de tenant + # Solo ADMIN y SUPPORT_MANAGER pueden ver otros tenants o todos los tenants + can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER] + + # Query base con filtro de tenant dinámico + query = select(AuditLog).options(selectinload(AuditLog.user)) + + if all_tenants and can_see_all_tenants: + # Ver todos los tenants (no agregar filtro de tenant) + pass + elif tenant_id and can_see_all_tenants: + # Ver un tenant específico + query = query.where(AuditLog.tenant_id == tenant_id) + else: + # Ver solo el tenant actual (comportamiento default) + query = query.where(AuditLog.tenant_id == current_tenant.id) + + # 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: + # El frontend ya envía el timestamp correcto + query = query.where(AuditLog.created_at < date_to) + + 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( + all_tenants: bool = Query(False, description="Ver stats de todos los tenants"), + 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 (o todos los tenants si es ADMIN). + + **Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR + + **Retorna**: Estadísticas de actividad + """ + can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER] + + logger.info( + "Fetching audit stats", + user_id=str(current_user.id), + tenant_id=str(current_tenant.id), + all_tenants=all_tenants, + can_see_all=can_see_all_tenants + ) + + now = datetime.utcnow() + + # Determinar si aplicar filtro de tenant + apply_tenant_filter = not (all_tenants and can_see_all_tenants) + + # Total de acciones + total_query = select(func.count()).select_from(AuditLog) + if apply_tenant_filter: + total_query = total_query.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( + AuditLog.created_at >= today_start + ) + if apply_tenant_filter: + today_query = today_query.where(AuditLog.tenant_id == current_tenant.id) + 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( + AuditLog.created_at >= week_start + ) + if apply_tenant_filter: + week_query = week_query.where(AuditLog.tenant_id == current_tenant.id) + 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') + ) + if apply_tenant_filter: + top_actions_query = top_actions_query.where(AuditLog.tenant_id == current_tenant.id) + top_actions_query = top_actions_query.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') + ) + if apply_tenant_filter: + by_resource_query = by_resource_query.where(AuditLog.tenant_id == current_tenant.id) + by_resource_query = by_resource_query.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 (con join a users para obtener nombres) + top_users_query = select( + User.email, + func.count(AuditLog.id).label('count') + ).join( + User, AuditLog.user_id == User.id + ) + if apply_tenant_filter: + top_users_query = top_users_query.where(AuditLog.tenant_id == current_tenant.id) + top_users_query = top_users_query.group_by( + User.email + ).order_by( + desc('count') + ).limit(5) + + top_users_result = await db.execute(top_users_query) + top_users = {row.email: row.count for row in top_users_result} + + # Acciones cr├¡ticas hoy (delete, update sensibles, etc.) + critical_conditions = [ + AuditLog.created_at >= today_start, + or_( + AuditLog.action.like('%.delete'), + AuditLog.action.like('user.update'), + AuditLog.action.like('%.assign'), + AuditLog.action.in_(['user.login_failed', 'user.logout']) + ) + ] + if apply_tenant_filter: + critical_conditions.append(AuditLog.tenant_id == current_tenant.id) + + critical_actions_query = select(func.count()).select_from(AuditLog).where( + and_(*critical_conditions) + ) + critical_result = await db.execute(critical_actions_query) + critical_actions_today = critical_result.scalar() or 0 + + return AuditLogStats( + total_actions=total_actions, + actions_today=actions_today, + actions_this_week=actions_this_week, + critical_actions_today=critical_actions_today, + top_actions=top_actions, + top_users=top_users, + by_resource_type=by_resource_type + ) + + +@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) + + +# =================================== +# SECURITY ANALYSIS ENDPOINTS +# =================================== + +@router.get("/security/analysis", response_model=SecurityAnalysisResponse) +async def get_security_analysis( + hours: int = Query(default=24, ge=1, le=168, description="Período de análisis en horas"), + current_user: User = Depends(require_auditor_role), + current_tenant: Tenant = Depends(get_current_tenant), + db: AsyncSession = Depends(get_db) +): + """ + Análisis de seguridad y detección de amenazas. + + **Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR + + **Detecta**: + - Intentos de fuerza bruta (login_failed) + - Escalada de privilegios + - Eliminaciones masivas + - Accesos desde IPs sospechosas + - Patrones anómalos de actividad + + **Retorna**: Análisis completo con amenazas y recomendaciones + """ + logger.info( + "Security analysis requested", + user_id=str(current_user.id), + tenant_id=str(current_tenant.id), + hours=hours + ) + + now = datetime.utcnow() + analysis_start = now - timedelta(hours=hours) + + threats = [] + failed_login_attempts = 0 + suspicious_ips = set() + critical_actions_count = 0 + + # 1. DETECCIÓN DE FUERZA BRUTA + brute_force_query = select( + AuditLog.ip_address, + func.count(AuditLog.id).label('attempts'), + func.min(AuditLog.created_at).label('first_seen'), + func.max(AuditLog.created_at).label('last_seen') + ).where( + and_( + AuditLog.tenant_id == current_tenant.id, + AuditLog.action == 'user.login_failed', + AuditLog.created_at >= analysis_start + ) + ).group_by(AuditLog.ip_address).having(func.count(AuditLog.id) >= 5) + + brute_force_result = await db.execute(brute_force_query) + brute_force_ips = brute_force_result.all() + + for ip_data in brute_force_ips: + if ip_data.ip_address: + suspicious_ips.add(str(ip_data.ip_address)) + failed_login_attempts += ip_data.attempts + + severity = "high" if ip_data.attempts > 20 else "medium" if ip_data.attempts > 10 else "low" + + threats.append(SecurityThreatPattern( + type="brute_force_attack", + severity=severity, + description=f"Ataque de fuerza bruta detectado desde {ip_data.ip_address}", + occurrences=ip_data.attempts, + affected_ips=[str(ip_data.ip_address)], + affected_users=[], + first_seen=ip_data.first_seen, + last_seen=ip_data.last_seen, + recommendations=[ + f"Bloquear IP {ip_data.ip_address} temporalmente", + "Revisar logs de firewall", + "Considerar implementar CAPTCHA", + "Notificar al equipo de seguridad" + ] + )) + + # 2. ESCALADA DE PRIVILEGIOS + privilege_query = select( + User.email, + func.count(AuditLog.id).label('changes'), + func.min(AuditLog.created_at).label('first_seen'), + func.max(AuditLog.created_at).label('last_seen') + ).join( + User, AuditLog.user_id == User.id + ).where( + and_( + AuditLog.tenant_id == current_tenant.id, + AuditLog.action == 'user.update', + AuditLog.created_at >= analysis_start, + AuditLog.new_values.op('?')('role') + ) + ).group_by(User.email).having(func.count(AuditLog.id) >= 3) + + privilege_result = await db.execute(privilege_query) + privilege_changes = privilege_result.all() + + for priv_data in privilege_changes: + threats.append(SecurityThreatPattern( + type="privilege_escalation", + severity="critical", + description=f"Posible escalada de privilegios - {priv_data.email} ha modificado roles {priv_data.changes} veces", + occurrences=priv_data.changes, + affected_ips=[], + affected_users=[priv_data.email], + first_seen=priv_data.first_seen, + last_seen=priv_data.last_seen, + recommendations=[ + f"Revisar permisos del usuario {priv_data.email}", + "Auditar todos los cambios de roles realizados", + "Verificar si los cambios fueron autorizados", + "Considerar revertir cambios no autorizados" + ] + )) + + # 3. ELIMINACIONES MASIVAS + deletion_query = select( + User.email, + func.count(AuditLog.id).label('deletions'), + func.min(AuditLog.created_at).label('first_seen'), + func.max(AuditLog.created_at).label('last_seen') + ).join( + User, AuditLog.user_id == User.id + ).where( + and_( + AuditLog.tenant_id == current_tenant.id, + AuditLog.action.like('%.delete'), + AuditLog.created_at >= analysis_start + ) + ).group_by(User.email).having(func.count(AuditLog.id) >= 10) + + deletion_result = await db.execute(deletion_query) + mass_deletions = deletion_result.all() + + for del_data in mass_deletions: + critical_actions_count += del_data.deletions + threats.append(SecurityThreatPattern( + type="mass_deletion", + severity="high", + description=f"Eliminaciones masivas detectadas - {del_data.email} ha eliminado {del_data.deletions} recursos", + occurrences=del_data.deletions, + affected_ips=[], + affected_users=[del_data.email], + first_seen=del_data.first_seen, + last_seen=del_data.last_seen, + recommendations=[ + f"Verificar urgentemente las eliminaciones de {del_data.email}", + "Comprobar si hay backups disponibles", + "Contactar al usuario para verificar la acción", + "Revisar sistema de permisos" + ] + )) + + # 4. ACCESOS DESDE MÚLTIPLES IPS (Cuenta comprometida) + multi_ip_query = select( + User.email, + func.count(func.distinct(AuditLog.ip_address)).label('ip_count'), + func.min(AuditLog.created_at).label('first_seen'), + func.max(AuditLog.created_at).label('last_seen') + ).join( + User, AuditLog.user_id == User.id + ).where( + and_( + AuditLog.tenant_id == current_tenant.id, + AuditLog.action.in_(['user.login', 'user.logout']), + AuditLog.created_at >= analysis_start + ) + ).group_by(User.email).having(func.count(func.distinct(AuditLog.ip_address)) >= 5) + + multi_ip_result = await db.execute(multi_ip_query) + multi_ip_users = multi_ip_result.all() + + for ip_data in multi_ip_users: + threats.append(SecurityThreatPattern( + type="account_compromise", + severity="medium", + description=f"Posible cuenta comprometida - {ip_data.email} accedió desde {ip_data.ip_count} IPs diferentes", + occurrences=ip_data.ip_count, + affected_ips=[], + affected_users=[ip_data.email], + first_seen=ip_data.first_seen, + last_seen=ip_data.last_seen, + recommendations=[ + f"Contactar a {ip_data.email} para verificar actividad", + "Forzar cambio de contraseña", + "Revisar ubicaciones de acceso", + "Considerar habilitar 2FA obligatorio" + ] + )) + + # Calcular nivel de riesgo general + critical_count = sum(1 for t in threats if t.severity == "critical") + high_count = sum(1 for t in threats if t.severity == "high") + medium_count = sum(1 for t in threats if t.severity == "medium") + + if critical_count > 0: + overall_risk = "critical" + elif high_count >= 3: + overall_risk = "high" + elif high_count > 0 or medium_count >= 3: + overall_risk = "medium" + elif medium_count > 0 or len(threats) > 0: + overall_risk = "low" + else: + overall_risk = "safe" + + # Recomendaciones generales + recommended_actions = [] + if failed_login_attempts > 20: + recommended_actions.append("Implementar límite de intentos de login por IP") + if len(suspicious_ips) > 0: + recommended_actions.append(f"Bloquear {len(suspicious_ips)} IPs sospechosas identificadas") + if critical_actions_count > 50: + recommended_actions.append("Revisar políticas de permisos - demasiadas acciones críticas") + if len(threats) == 0: + recommended_actions.append("Sistema seguro - continuar monitoreando") + + return SecurityAnalysisResponse( + overall_risk_level=overall_risk, + total_threats_detected=len(threats), + threats=threats, + analysis_period_hours=hours, + generated_at=now, + failed_login_attempts=failed_login_attempts, + suspicious_ips_count=len(suspicious_ips), + critical_actions_count=critical_actions_count, + recommended_actions=recommended_actions + ) + + +@router.post("/security/action", response_model=SecurityActionResponse) +async def execute_security_action( + action: SecurityActionRequest, + current_user: User = Depends(require_auditor_role), + current_tenant: Tenant = Depends(get_current_tenant), + db: AsyncSession = Depends(get_db) +): + """ + Ejecutar acción de seguridad. + + **Permisos**: ADMIN, SUPPORT_MANAGER (solo ellos pueden ejecutar acciones) + + **Acciones disponibles**: + - `block_ip`: Bloquear IP temporalmente + - `notify_admin`: Notificar administradores + - `force_password_reset`: Forzar cambio de contraseña + - `disable_user`: Desactivar usuario temporalmente + + **Retorna**: Resultado de la acción + """ + # Verificar que solo ADMIN y SUPPORT_MANAGER puedan ejecutar acciones + if current_user.role not in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Solo administradores pueden ejecutar acciones de seguridad" + ) + + logger.info( + "Security action requested", + user_id=str(current_user.id), + action_type=action.action_type, + target=action.target + ) + + # Registrar la acción en auditoría + try: + await AuditService.log( + db=db, + tenant_id=current_tenant.id, + user_id=current_user.id, + action=f"security.{action.action_type}", + resource_type="security", + resource_id=None, + metadata={ + "target": action.target, + "reason": action.reason, + "duration_minutes": action.duration_minutes + } + ) + await db.commit() + except Exception as e: + logger.error("Failed to log security action", error=str(e)) + + # Por ahora, simular la ejecución (en producción conectar con firewall, email, etc.) + message = "" + success = True + + if action.action_type == "block_ip": + message = f"IP {action.target} bloqueada por {action.duration_minutes or 60} minutos. Razón: {action.reason}" + # TODO: Integrar con firewall/WAF + + elif action.action_type == "notify_admin": + message = f"Notificación enviada a administradores sobre: {action.reason}" + # TODO: Enviar email/Slack notification + + elif action.action_type == "force_password_reset": + message = f"Se forzará cambio de contraseña para {action.target}. Razón: {action.reason}" + # TODO: Marcar usuario para reset password + + elif action.action_type == "disable_user": + message = f"Usuario {action.target} desactivado temporalmente. Razón: {action.reason}" + # TODO: Desactivar usuario en BD + + else: + success = False + message = f"Tipo de acción no reconocida: {action.action_type}" + + return SecurityActionResponse( + success=success, + message=message, + action_id=None # TODO: Retornar ID del audit log creado + ) diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py index 97fe54b..4687266 100644 --- a/backend/app/api/v1/endpoints/auth.py +++ b/backend/app/api/v1/endpoints/auth.py @@ -18,6 +18,7 @@ from app.core.security import security from app.core.config import get_settings from app.models.user import User from app.models.tenant import Tenant +from app.services.audit_service import AuditService router = APIRouter() logger = structlog.get_logger(__name__) @@ -99,6 +100,23 @@ async def login( "Login failed - invalid credentials", email=login_data.email ) + + # Registrar intento fallido en auditoría (si el usuario existe) + if user: + try: + await AuditService.log( + db=db, + tenant_id=user.tenant_id, + user_id=None, # Login fallido = sin user_id + action="user.login_failed", + resource_type="user", + resource_id=user.id, + metadata={"email": login_data.email, "reason": "invalid_password"} + ) + await db.commit() + except Exception as e: + logger.warning("Failed to log audit entry", error=str(e)) + raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Credenciales inválidas" @@ -126,6 +144,21 @@ async def login( access_token = security.create_access_token(token_data) refresh_token = security.create_refresh_token(token_data) + # Registrar login exitoso en auditoría + try: + await AuditService.log( + db=db, + tenant_id=user.tenant_id, + user_id=user.id, + action="user.login", + resource_type="user", + resource_id=user.id, + metadata={"email": user.email, "success": True} + ) + await db.commit() + except Exception as e: + logger.warning("Failed to log audit entry", error=str(e)) + logger.info( "Login successful", email=login_data.email, @@ -227,6 +260,25 @@ async def logout( # TODO: Revoke refresh token in database + # Registrar logout en auditoría + try: + import uuid + user_id = uuid.UUID(payload["sub"]) + tenant_id = uuid.UUID(payload["tenant_id"]) + + await AuditService.log( + db=db, + tenant_id=tenant_id, + user_id=user_id, + action="user.logout", + resource_type="user", + resource_id=user_id, + metadata={"email": payload.get("email")} + ) + await db.commit() + except Exception as e: + logger.warning("Failed to log audit entry", error=str(e)) + logger.info("Logout successful", user_id=payload["sub"]) return {"message": "Successfully logged out"} diff --git a/backend/app/api/v1/endpoints/tickets.py b/backend/app/api/v1/endpoints/tickets.py index ca2d1bc..44d61ec 100644 --- a/backend/app/api/v1/endpoints/tickets.py +++ b/backend/app/api/v1/endpoints/tickets.py @@ -21,6 +21,7 @@ from app.models.comment import TicketComment from app.models.attachment import TicketAttachment from app.api.schemas.attachment import AttachmentResponse from app.core.file_handler import file_handler +from app.services.audit_service import AuditService import uuid router = APIRouter() @@ -78,85 +79,209 @@ async def create_ticket( """ Crear un nuevo ticket """ - try: - # Generar número de ticket único - result = await db.execute( - select(func.count(Ticket.id)).where(Ticket.tenant_id == current_user.tenant_id) - ) - count = result.scalar() or 0 - ticket_number = f"TK-{count + 1:06d}" - - # Generar número de ticket único - result = await db.execute( - select(func.count(Ticket.id)).where(Ticket.tenant_id == current_user.tenant_id) - ) - count = result.scalar() or 0 - ticket_number = f"TK-{count + 1:06d}" - - # Convertir IDs de string a UUID si son proporcionados - category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None - system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None # ✅ CORREGIDO - - # ✅ CORREGIDO: Validar en la tabla correcta con el nombre correcto del modelo - if category_uuid: - category = await db.get(Category, category_uuid) # ✅ Category, no TicketCategory - if not category: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"La categoría con ID {ticket.category_id} no existe." - ) + # Retry logic para evitar race conditions en generación de ticket_number + max_retries = 3 + last_error = None + + for attempt in range(max_retries): + try: + # Generar número de ticket único basado en el máximo existente + result = await db.execute( + select(Ticket.ticket_number) + .where(Ticket.tenant_id == current_user.tenant_id) + .order_by(Ticket.ticket_number.desc()) + .limit(1) + ) + last_ticket_number = result.scalar_one_or_none() + + if last_ticket_number: + # Extraer el número del formato TK-XXXXXX + last_number = int(last_ticket_number.split('-')[1]) + next_number = last_number + 1 + else: + next_number = 1 + + ticket_number = f"TK-{next_number:06d}" + + # Convertir IDs de string a UUID si son proporcionados + category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None + system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None + + # Validar categoría + if category_uuid: + category = await db.get(Category, category_uuid) + if not category: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"La categoría con ID {ticket.category_id} no existe." + ) - # Validar si el system_id existe en la tabla affected_systems - if system_uuid: - system = await db.get(System, system_uuid) - if not system: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"El sistema con ID {ticket.affected_system_id} no existe." + # Validar sistema + if system_uuid: + system = await db.get(System, system_uuid) + if not system: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"El sistema con ID {ticket.affected_system_id} no existe." + ) + + db_ticket = Ticket( + id=uuid.uuid4(), + tenant_id=current_user.tenant_id, + ticket_number=ticket_number, + subject=ticket.subject, + description=ticket.description, + category_id=category_uuid, + affected_system_id=system_uuid, + priority=TicketPriority[ticket.priority.upper()], + created_by=current_user.id, + status=TicketStatus.NEW, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow() + ) + + db.add(db_ticket) + await db.commit() + await db.refresh(db_ticket) + + # Registrar creación en auditoría + try: + await AuditService.log( + db=db, + tenant_id=current_user.tenant_id, + user_id=current_user.id, + action="ticket.create", + resource_type="ticket", + resource_id=db_ticket.id, + new_values={ + "ticket_number": db_ticket.ticket_number, + "subject": db_ticket.subject, + "priority": db_ticket.priority.value, + "status": db_ticket.status.value + } ) - - db_ticket = Ticket( - id=uuid.uuid4(), - tenant_id=current_user.tenant_id, - ticket_number=ticket_number, - subject=ticket.subject, - description=ticket.description, - category_id=category_uuid, - affected_system_id=system_uuid, # ✅ CORREGIDO: Nombre correcto del campo - priority=TicketPriority[ticket.priority.upper()], - created_by=current_user.id, - status=TicketStatus.NEW, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow() - ) - - db.add(db_ticket) - await db.commit() - await db.refresh(db_ticket) - - # ✅ CORREGIDO: Usar affected_system_id en respuesta - return { - "id": str(db_ticket.id), - "ticket_number": db_ticket.ticket_number, - "subject": db_ticket.subject, - "title": db_ticket.subject, - "description": db_ticket.description, - "status": db_ticket.status.value, - "priority": db_ticket.priority.value, - "category_id": str(db_ticket.category_id) if db_ticket.category_id else None, - "affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None, # ✅ CORREGIDO - "created_by": str(db_ticket.created_by), - "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None, - "created_at": db_ticket.created_at, - "updated_at": db_ticket.updated_at + await db.commit() + except Exception as e: + # No fallar si falla el audit log + pass + + # ✅ Éxito - retornar ticket creado + return { + "id": str(db_ticket.id), + "ticket_number": db_ticket.ticket_number, + "subject": db_ticket.subject, + "title": db_ticket.subject, + "description": db_ticket.description, + "status": db_ticket.status.value, + "priority": db_ticket.priority.value, + "category_id": str(db_ticket.category_id) if db_ticket.category_id else None, + "affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None, + "created_by": str(db_ticket.created_by), + "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None, + "created_at": db_ticket.created_at, + "updated_at": db_ticket.updated_at + } + + except ValueError as e: + await db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid UUID format: {str(e)}" + ) + except HTTPException: + # Re-lanzar HTTPExceptions directamente + await db.rollback() + raise + except Exception as e: + await db.rollback() + last_error = e + + # Si es un error de llave duplicada, reintentar + if "duplicate key" in str(e).lower() and "ticket_number" in str(e).lower(): + if attempt < max_retries - 1: + continue # Reintentar + + # Para cualquier otro error, fallar inmediatamente + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Error creating ticket: {str(e)}" + ) + + # Si llegamos aquí después de todos los reintentos + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"No se pudo crear el ticket después de {max_retries} intentos: {str(last_error)}" + ) + + +@router.get("/", response_model=List[TicketResponse]) +async def get_tickets( + skip: int = 0, + limit: int = 100, + status_filter: Optional[str] = None, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Obtener tickets + Roles ADMIN/SUPPORT_MANAGER/AGENT: Ven todos los tickets del tenant + Roles CLIENT_USER/CLIENT_ADMIN: Solo ven sus propios tickets + """ + # Construir query base filtrado por tenant + query = select(Ticket).where( + Ticket.tenant_id == current_user.tenant_id + ) + + # Si es cliente, solo puede ver sus propios tickets + if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]: + query = query.where(Ticket.created_by == current_user.id) + + if status_filter: + try: + status_enum = TicketStatus[status_filter.upper()] + query = query.where(Ticket.status == status_enum) + except KeyError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid status: {status_filter}" + ) + + query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit) + + result = await db.execute(query) + tickets = result.scalars().all() + + # ✅ CORREGIDO: Usar affected_system_id + return [ + { + "id": str(t.id), + "ticket_number": t.ticket_number, + "subject": t.subject, + "title": t.subject, + "description": t.description, + "status": t.status.value, + "priority": t.priority.value, + "category_id": str(t.category_id) if t.category_id else None, + "affected_system_id": str(t.affected_system_id) if t.affected_system_id else None, + "created_by": str(t.created_by), + "assigned_to": str(t.assigned_to) if t.assigned_to else None, + "created_at": t.created_at, + "updated_at": t.updated_at } - - except ValueError as e: - await db.rollback() - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid UUID format: {str(e)}" - ) + for t in tickets + ] + + +@router.get("/admin/all", response_model=List[dict]) +async def get_all_tickets_admin( + skip: int = 0, + limit: int = 100, + status_filter: Optional[str] = None, + priority_filter: Optional[str] = None, + tenant_id_filter: Optional[str] = None, + category_filter: Optional[str] = None, + assigned_to_filter: Optional[str] = None, + search: Optional[str] = None, date_from: Optional[str] = None, date_to: Optional[str] = None, db: AsyncSession = Depends(get_db), @@ -311,6 +436,8 @@ async def get_ticket( ): """ Obtener un ticket específico + Roles ADMIN/SUPPORT_MANAGER/AGENT: Pueden ver todos los tickets del tenant + Roles CLIENT_USER/CLIENT_ADMIN: Solo pueden ver sus propios tickets """ try: ticket_uuid = uuid.UUID(ticket_id) @@ -320,12 +447,16 @@ async def get_ticket( detail="Invalid ticket ID format" ) + # Construir query basado en el rol del usuario query = select(Ticket).where( Ticket.id == ticket_uuid, - Ticket.tenant_id == current_user.tenant_id, - Ticket.created_by == current_user.id + Ticket.tenant_id == current_user.tenant_id ) + # Si es cliente, solo puede ver sus propios tickets + if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]: + query = query.where(Ticket.created_by == current_user.id) + result = await db.execute(query) ticket = result.scalars().first() @@ -362,6 +493,8 @@ async def update_ticket( ): """ Actualizar un ticket + Roles ADMIN/SUPPORT_MANAGER/AGENT: Pueden actualizar cualquier ticket del tenant + Roles CLIENT_USER/CLIENT_ADMIN: Solo pueden actualizar sus propios tickets """ try: ticket_uuid = uuid.UUID(ticket_id) @@ -371,12 +504,16 @@ async def update_ticket( detail="Invalid ticket ID format" ) + # Construir query basado en el rol del usuario query = select(Ticket).where( Ticket.id == ticket_uuid, - Ticket.tenant_id == current_user.tenant_id, - Ticket.created_by == current_user.id + Ticket.tenant_id == current_user.tenant_id ) + # Si es cliente, solo puede actualizar sus propios tickets + if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]: + query = query.where(Ticket.created_by == current_user.id) + result = await db.execute(query) db_ticket = result.scalars().first() @@ -386,6 +523,15 @@ async def update_ticket( detail=f"Ticket {ticket_id} not found" ) + # Guardar valores anteriores para audit + old_values = { + "subject": db_ticket.subject, + "description": db_ticket.description, + "status": db_ticket.status.value, + "priority": db_ticket.priority.value, + "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None + } + try: update_data = ticket_update.dict(exclude_unset=True) @@ -404,6 +550,34 @@ async def update_ticket( await db.commit() await db.refresh(db_ticket) + # Registrar actualización en auditoría + try: + new_values = { + "subject": db_ticket.subject, + "description": db_ticket.description, + "status": db_ticket.status.value, + "priority": db_ticket.priority.value, + "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None + } + + # Si cambió assigned_to, registrar como acción de asignación + action = "ticket.assign" if old_values["assigned_to"] != new_values["assigned_to"] else "ticket.update" + + await AuditService.log( + db=db, + tenant_id=current_user.tenant_id, + user_id=current_user.id, + action=action, + resource_type="ticket", + resource_id=db_ticket.id, + old_values=old_values, + new_values=new_values + ) + await db.commit() + except Exception as e: + # No fallar si falla el audit log + pass + # ✅ CORREGIDO: Usar affected_system_id return { "id": str(db_ticket.id), @@ -438,6 +612,8 @@ async def close_ticket( ): """ Cerrar un ticket + Roles ADMIN/SUPPORT_MANAGER/AGENT: Pueden cerrar cualquier ticket del tenant + Roles CLIENT_USER/CLIENT_ADMIN: Solo pueden cerrar sus propios tickets """ try: ticket_uuid = uuid.UUID(ticket_id) @@ -447,12 +623,16 @@ async def close_ticket( detail="Invalid ticket ID format" ) + # Construir query basado en el rol del usuario query = select(Ticket).where( Ticket.id == ticket_uuid, - Ticket.tenant_id == current_user.tenant_id, - Ticket.created_by == current_user.id + Ticket.tenant_id == current_user.tenant_id ) + # Si es cliente, solo puede cerrar sus propios tickets + if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]: + query = query.where(Ticket.created_by == current_user.id) + result = await db.execute(query) db_ticket = result.scalars().first() @@ -666,9 +846,33 @@ async def delete_ticket( detail=f"Ticket {ticket_id} not found" ) + # Guardar datos del ticket antes de eliminar para audit + old_values = { + "ticket_number": db_ticket.ticket_number, + "subject": db_ticket.subject, + "status": db_ticket.status.value, + "priority": db_ticket.priority.value + } + await db.delete(db_ticket) await db.commit() + # Registrar eliminación en auditoría + try: + await AuditService.log( + db=db, + tenant_id=current_user.tenant_id, + user_id=current_user.id, + action="ticket.delete", + resource_type="ticket", + resource_id=ticket_uuid, + old_values=old_values + ) + await db.commit() + except Exception as e: + # No fallar si falla el audit log + pass + return {"message": "Ticket deleted successfully"} # =================================== diff --git a/backend/app/api/v1/endpoints/users.py b/backend/app/api/v1/endpoints/users.py index 0f52932..102dca1 100644 --- a/backend/app/api/v1/endpoints/users.py +++ b/backend/app/api/v1/endpoints/users.py @@ -9,6 +9,7 @@ import uuid from app.core.database import get_db from app.core.security import security from app.models.user import User, UserRole +from app.services.audit_service import AuditService from app.api import deps router = APIRouter() @@ -147,6 +148,28 @@ async def create_user( db.add(db_user) await db.commit() await db.refresh(db_user) + + # Registrar creación en auditoría + try: + await AuditService.log( + db=db, + tenant_id=current_user.tenant_id, + user_id=current_user.id, + action="user.create", + resource_type="user", + resource_id=db_user.id, + new_values=AuditService.sanitize_values({ + "email": db_user.email, + "first_name": db_user.first_name, + "last_name": db_user.last_name, + "role": db_user.role.value + }) + ) + await db.commit() + except Exception as e: + # No fallar si falla el audit log + pass + return db_user @@ -214,6 +237,15 @@ async def update_user( detail="User not found" ) + # Guardar valores anteriores para audit + old_values = { + "email": db_user.email, + "first_name": db_user.first_name, + "last_name": db_user.last_name, + "role": db_user.role.value, + "is_active": db_user.is_active + } + # Verificar email único si se está cambiando update_data = user_update.model_dump(exclude_unset=True) if "email" in update_data and update_data["email"] != db_user.email: @@ -239,6 +271,32 @@ async def update_user( await db.commit() await db.refresh(db_user) + + # Registrar actualización en auditoría + try: + new_values = { + "email": db_user.email, + "first_name": db_user.first_name, + "last_name": db_user.last_name, + "role": db_user.role.value, + "is_active": db_user.is_active + } + + await AuditService.log( + db=db, + tenant_id=current_user.tenant_id, + user_id=current_user.id, + action="user.update", + resource_type="user", + resource_id=db_user.id, + old_values=AuditService.sanitize_values(old_values), + new_values=AuditService.sanitize_values(new_values) + ) + await db.commit() + except Exception as e: + # No fallar si falla el audit log + pass + return db_user @@ -306,6 +364,28 @@ async def delete_user( # Soft delete db_user.is_active = False await db.commit() + + # Registrar eliminación en auditoría + try: + await AuditService.log( + db=db, + tenant_id=current_user.tenant_id, + user_id=current_user.id, + action="user.delete", + resource_type="user", + resource_id=db_user.id, + old_values={ + "email": db_user.email, + "role": db_user.role.value, + "was_active": True + }, + metadata={"action_type": "soft_delete"} + ) + await db.commit() + except Exception as e: + # No fallar si falla el audit log + pass + return None diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 5edd7fa..f280cce 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() @@ -59,4 +59,11 @@ api_router.include_router( client_profile.router, prefix="/client-profile", tags=["client-profile"] +) + +# Audit routes +api_router.include_router( + audit.router, + prefix="/audit", + tags=["audit"] ) \ No newline at end of file diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 74c1fad..acf0ff0 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -27,6 +27,7 @@ class Settings(BaseSettings): DEBUG: bool = Field(default=False) SECRET_KEY: str = Field(...) API_VERSION: str = Field(default="v1") + APP_VERSION: str = Field(default="1.6.0") # =================================== # DATABASE diff --git a/backend/app/main.py b/backend/app/main.py index a1eb6cf..0d7b86e 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 +from app.models.refresh_token import RefreshToken from app.core.logging import setup_logging from app.api.v1.router import api_router @@ -54,7 +56,7 @@ async def lifespan(app: FastAPI): app = FastAPI( title="ServiceManagerWeb API", description="Mesa de Ayuda B2B multi-tenant para Aduanasoft", - version=settings.API_VERSION, + version=settings.APP_VERSION, lifespan=lifespan, docs_url=f"/{settings.API_VERSION}/docs" if settings.ENVIRONMENT == "development" else None, redoc_url=f"/{settings.API_VERSION}/redoc" if settings.ENVIRONMENT == "development" else None, @@ -161,7 +163,8 @@ async def health_check(): return { "status": "healthy", "service": "ServiceManagerWeb API", - "version": settings.API_VERSION, + "version": settings.APP_VERSION, + "api_version": settings.API_VERSION, "environment": settings.ENVIRONMENT } @@ -172,7 +175,8 @@ async def root(): """Endpoint raíz con información básica.""" return { "service": "ServiceManagerWeb API", - "version": settings.API_VERSION, + "version": settings.APP_VERSION, + "api_version": settings.API_VERSION, "docs": f"/{settings.API_VERSION}/docs", "environment": settings.ENVIRONMENT } diff --git a/backend/app/middleware/tenant.py b/backend/app/middleware/tenant.py index 11aa855..45e8811 100644 --- a/backend/app/middleware/tenant.py +++ b/backend/app/middleware/tenant.py @@ -24,10 +24,17 @@ class TenantMiddleware(BaseHTTPMiddleware): EXCLUDED_PATHS = { "/health", "/", + "/api/v1/auth/login", "/v1/auth/login", "/docs", + "/api/v1/docs", + "/v1/docs", "/openapi.json", - "/redoc" + "/api/v1/openapi.json", + "/v1/openapi.json", + "/redoc", + "/api/v1/redoc", + "/v1/redoc" } async def dispatch(self, request: Request, call_next) -> Response: 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 index e69de29..d3e05d2 100644 --- a/backend/app/models/audit.py +++ 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 index e69de29..077e250 100644 --- a/backend/app/models/refresh_token.py +++ 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..943e7d0 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -78,6 +78,12 @@ class User(Base): back_populates="assigned_to_user", foreign_keys="Ticket.assigned_to" ) + refresh_tokens: Mapped[List["RefreshToken"]] = relationship( + "RefreshToken", + back_populates="user", + foreign_keys="RefreshToken.user_id", + cascade="all, delete-orphan" + ) # Unique constraint por tenant __table_args__ = ( diff --git a/backend/app/services/audit_service.py b/backend/app/services/audit_service.py index e69de29..c058bad 100644 --- a/backend/app/services/audit_service.py +++ 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 index e69de29..30e0dac 100644 --- a/backend/app/services/token_service.py +++ 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/create_test_user.py b/backend/create_test_user.py new file mode 100644 index 0000000..8c3fcb7 --- /dev/null +++ b/backend/create_test_user.py @@ -0,0 +1,67 @@ +""" +Script para crear/actualizar usuario de prueba con contraseña conocida +""" +import asyncio +from sqlalchemy import select, update +from app.core.database import AsyncSessionLocal +from app.core.security import security +from app.models.user import User, UserRole +from app.models.tenant import Tenant +import uuid + +async def create_test_user(): + async with AsyncSessionLocal() as db: + # Buscar tenant + tenant_query = select(Tenant).where(Tenant.slug.like('%aduanasoft%')).limit(1) + result = await db.execute(tenant_query) + tenant = result.scalar_one_or_none() + + if not tenant: + print("❌ No se encontró tenant") + return + + print(f"✅ Tenant encontrado: {tenant.name} ({tenant.slug})") + + # Buscar o crear usuario admin + user_query = select(User).where( + User.email == "admin@aduanasoft.com", + User.tenant_id == tenant.id + ) + result = await db.execute(user_query) + user = result.scalar_one_or_none() + + # Hash de la contraseña "admin123" + password_hash = security.hash_password("admin123") + + if user: + # Actualizar contraseña + user.password_hash = password_hash + user.is_active = True + user.email_verified = True + await db.commit() + print(f"✅ Usuario actualizado: {user.email}") + else: + # Crear usuario nuevo + user = User( + id=uuid.uuid4(), + tenant_id=tenant.id, + email="admin@aduanasoft.com", + first_name="Admin", + last_name="Sistema", + password_hash=password_hash, + role=UserRole.ADMIN, + is_active=True, + email_verified=True + ) + db.add(user) + await db.commit() + print(f"✅ Usuario creado: {user.email}") + + print(f"\n📋 Credenciales de prueba:") + print(f" Email: admin@aduanasoft.com") + print(f" Password: admin123") + print(f" Tenant: {tenant.slug}") + print(f" Role: ADMIN") + +if __name__ == "__main__": + asyncio.run(create_test_user()) diff --git a/backend/migrations/versions/a1b2c3d4e5f6_add_audit_logs_table.py b/backend/migrations/versions/a1b2c3d4e5f6_add_audit_logs_table.py index e69de29..5064a86 100644 --- a/backend/migrations/versions/a1b2c3d4e5f6_add_audit_logs_table.py +++ 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/backend/pyproject.toml b/backend/pyproject.toml index d6386bb..f8bd802 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "servicemanager-backend" -version = "0.1.0" +version = "1.6.0" description = "ServiceManagerWeb Backend - Mesa de Ayuda B2B" authors = [ {name = "Aduanasoft", email = "dev@aduanasoft.com"} diff --git a/db/schema.sql b/db/schema.sql index c38f58d..2285005 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -369,10 +369,14 @@ CREATE TABLE audit_logs ( CREATE INDEX idx_audit_logs_tenant_id ON audit_logs(tenant_id); CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id); CREATE INDEX idx_audit_logs_action ON audit_logs(action); -CREATE INDEX idx_audit_logs_resource ON audit_logs(resource_type, resource_id); CREATE INDEX idx_audit_logs_correlation_id ON audit_logs(correlation_id); CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at); +-- Índices compuestos para queries comunes de auditoría +CREATE INDEX idx_audit_logs_tenant_action ON audit_logs(tenant_id, action); +CREATE INDEX idx_audit_logs_resource ON audit_logs(resource_type, resource_id); +CREATE INDEX idx_audit_logs_user_created ON audit_logs(user_id, created_at); + -- =================================== -- FUNCIONES Y TRIGGERS -- =================================== diff --git a/frontend-client/package.json b/frontend-client/package.json index 51f0ca9..6308bd5 100644 --- a/frontend-client/package.json +++ b/frontend-client/package.json @@ -1,6 +1,6 @@ { "name": "@servicemanager/client-frontend", - "version": "0.1.0", + "version": "1.6.0", "private": true, "type": "module", "scripts": { diff --git a/frontend-client/src/routes/+layout.svelte b/frontend-client/src/routes/+layout.svelte index 8fbff0a..019574c 100644 --- a/frontend-client/src/routes/+layout.svelte +++ b/frontend-client/src/routes/+layout.svelte @@ -23,6 +23,11 @@ + +
+

ServiceManagerWeb v1.6.0 · © 2026 Aduanasoft

+
+ {#each $toast.toasts as toastMessage (toastMessage.id)} - import { createEventDispatcher, onMount, onDestroy } from 'svelte'; + import { createEventDispatcher } from 'svelte'; export let open = false; export let title = ''; + export let size = 'lg'; // sm, md, lg, xl, 2xl const dispatch = createEventDispatcher(); @@ -15,6 +16,20 @@ close(); } } + + function handleBackdropClick(e) { + if (e.target === e.currentTarget) { + close(); + } + } + + const sizeClasses = { + sm: 'sm:max-w-sm', + md: 'sm:max-w-md', + lg: 'sm:max-w-lg', + xl: 'sm:max-w-xl', + '2xl': 'sm:max-w-2xl' + }; @@ -23,21 +38,45 @@ + + +
+

v1.6.0

+
diff --git a/frontend-internal/src/routes/audit/+page.svelte b/frontend-internal/src/routes/audit/+page.svelte index e69de29..a54e242 100644 --- a/frontend-internal/src/routes/audit/+page.svelte +++ b/frontend-internal/src/routes/audit/+page.svelte @@ -0,0 +1,979 @@ + + +
+ +
+
+

Auditoría del Sistema

+

+ Registro de actividades • + + {periodFilter === 'today' ? 'Hoy' : + periodFilter === 'yesterday' ? 'Ayer' : + periodFilter === 'last7days' ? 'Últimos 7 días' : + periodFilter === 'last30days' ? 'Últimos 30 días' : + 'Período personalizado'} + +

+
+
+ + +
+
+ + + + + +
+ + + {#if periodFilter === 'custom'} +
+
+ + +
+
+ + +
+
+ {/if} +
+ + + {#if canSeeAllTenants} +
+
+
+ +
+ {#if allTenants} + + + + + Multi-tenant activo + + {/if} +
+
+ {/if} + + + {#if stats} +
+
+
Total
+
{stats.total_actions.toLocaleString()}
+
+
+
Hoy
+
{stats.actions_today}
+
+
+
Esta Semana
+
{stats.actions_this_week}
+
+
+
+ + + +
Vulnerabilidad
+
+
+
+ {stats.critical_actions_today} +
+ +
+
Acciones críticas hoy
+
+
+ {/if} + + +
+ + + {#if showAdvancedFilters} +
+
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ + {#if activeFiltersCount > 0} +
+ +
+ {/if} +
+ {/if} +
+ + +
+
+
+

+ Registros de Auditoría +

+ {totalLogs} registros +
+
+ + {#if isLoading} +
+
+
+

Cargando registros...

+
+
+ {:else if logs.length === 0} +
+
+ + + +

No hay registros

+

+ {periodFilter === 'today' ? 'No hay actividad registrada hoy.' : 'No se encontraron registros para el período seleccionado.'} +

+
+
+ {:else} + +
+ + + + +
+ {#each logs as log (log.id)} +
+
+
+ + {#if log.user_email} +
+ + {(log.user_name || '?').charAt(0).toUpperCase()} + +
+ {:else} +
+ + + +
+ {/if} + + +
+
+ {formatDateShort(log.created_at)} + + {formatActionText(log.action)} + +
+
+ {log.user_name || 'Sistema'} + • {getRoleText(log.user_role)} +
+
+ {log.resource_type} + {#if log.ip_address} + • {log.ip_address} + {/if} +
+
+
+ + + +
+
+ {/each} +
+
+ + + {#if totalPages > 1} +
+ +
+ + + Página {currentPage} de {totalPages} + + +
+ + + +
+ {/if} + {/if} +
+
+ + +{#if showDetailModal && selectedLog} + showDetailModal = false}> +
+ +
+

Información General

+
+
+
Fecha y Hora:
+
{formatDate(selectedLog.created_at)}
+
+
+
Usuario:
+
{selectedLog.user_name || 'Sistema'}
+
+
+
Email:
+
{selectedLog.user_email || 'N/A'}
+
+ {#if selectedLog.user_role} +
+
Rol:
+
{getRoleText(selectedLog.user_role)}
+
+ {/if} +
+
IP:
+
{selectedLog.ip_address || 'N/A'}
+
+
+
Correlation ID:
+
{selectedLog.correlation_id || 'N/A'}
+
+
+
+ + +
+

Acción

+
+ + {selectedLog.action} + +

{formatActionText(selectedLog.action)}

+
+
+ + +
+

Recurso Afectado

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

Navegador / Dispositivo

+
+ {selectedLog.user_agent} +
+
+ {/if} + + + {#if selectedLog.old_values && Object.keys(selectedLog.old_values).length > 0} +
+

Valores Anteriores

+
{JSON.stringify(selectedLog.old_values, null, 2)}
+
+ {/if} + + + {#if selectedLog.new_values && Object.keys(selectedLog.new_values).length > 0} +
+

Valores Nuevos

+
{JSON.stringify(selectedLog.new_values, null, 2)}
+
+ {/if} + + + {#if selectedLog.metadata && Object.keys(selectedLog.metadata).length > 0} +
+

Información Adicional

+
{JSON.stringify(selectedLog.metadata, null, 2)}
+
+ {/if} +
+ +
+ +
+
+{/if} diff --git a/frontend-internal/src/routes/audit/security/+page.svelte b/frontend-internal/src/routes/audit/security/+page.svelte new file mode 100644 index 0000000..aa3b3e6 --- /dev/null +++ b/frontend-internal/src/routes/audit/security/+page.svelte @@ -0,0 +1,532 @@ + + +
+ +
+
+
+

+ + + + Análisis de Seguridad +

+

+ Detección de amenazas y análisis de vulnerabilidades +

+
+ +
+
+ + +
+
+ + + + Período de Análisis +
+
+ + + +
+
+ + {#if isLoading} +
+
+
+ {:else if analysis} + +
+
+
+

Nivel de Riesgo General

+

Análisis de {analysis.analysis_period_hours} horas

+
+
+ + {analysis.overall_risk_level.toUpperCase()} + +

Generado: {formatDate(analysis.generated_at)}

+
+
+
+ + +
+
+
+
+

Amenazas Detectadas

+

{analysis.total_threats_detected}

+
+ + + +
+
+ +
+
+
+

Intentos Fallidos

+

{analysis.failed_login_attempts}

+
+ + + +
+
+ +
+
+
+

IPs Sospechosas

+

{analysis.suspicious_ips_count}

+
+ + + +
+
+ +
+
+
+

Acciones Críticas

+

{analysis.critical_actions_count}

+
+ + + +
+
+
+ + + {#if analysis.recommended_actions && analysis.recommended_actions.length > 0} +
+
+ + + +
+

Acciones Recomendadas

+
    + {#each analysis.recommended_actions as action} +
  • + + + + {action} +
  • + {/each} +
+
+
+
+ {/if} + + + {#if analysis.threats && analysis.threats.length > 0} +
+

Amenazas Detectadas

+ + {#each analysis.threats as threat} +
+ +
+
+
+ + + +
+
+
+

{getThreatTypeText(threat.type)}

+ + {threat.severity.toUpperCase()} + +
+

{threat.description}

+
+
+
+ + +
+
+ Ocurrencias: + {threat.occurrences} +
+
+ Primera detección: + {formatDate(threat.first_seen)} +
+
+ Última detección: + {formatDate(threat.last_seen)} +
+
+ + + {#if threat.affected_ips.length > 0 || threat.affected_users.length > 0} +
+ {#if threat.affected_ips.length > 0} +
+ IPs involucradas: +
+ {#each threat.affected_ips as ip} + {ip} + {/each} +
+
+ {/if} + {#if threat.affected_users.length > 0} +
+ Usuarios afectados: +
+ {#each threat.affected_users as user} + {user} + {/each} +
+
+ {/if} +
+ {/if} + + + {#if threat.recommendations && threat.recommendations.length > 0} +
+

Recomendaciones:

+
    + {#each threat.recommendations as rec} +
  • + + + + {rec} +
  • + {/each} +
+
+ {/if} + + + {#if canExecuteActions} +
+ {#if threat.affected_ips.length > 0} + + {/if} + {#if threat.affected_users.length > 0} + + {/if} + +
+ {/if} +
+ {/each} +
+ {:else} + +
+ + + +

Sistema Seguro

+

No se detectaron amenazas en el período analizado

+
+ {/if} + {/if} +
+ + +{#if showActionModal} + showActionModal = false}> +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + {#if actionType === 'block_ip'} +
+ + +
+ {/if} + +
+ + +
+
+
+{/if}