diff --git a/backend/app/api/v1/endpoints/audit_backup.py b/backend/app/api/v1/endpoints/audit_backup.py deleted file mode 100644 index 457dad1..0000000 --- a/backend/app/api/v1/endpoints/audit_backup.py +++ /dev/null @@ -1,1033 +0,0 @@ -""" -Audit Endpoints - ServiceManagerWeb - -Endpoints para consulta de logs de auditor├¡a. -Solo accesible por roles: ADMIN, SUPPORT_MANAGER, AUDITOR -""" - -from fastapi import APIRouter, Depends, HTTPException, status, Query -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, and_, or_, desc -from sqlalchemy.orm import selectinload -from typing import Optional, List -from datetime import datetime, timedelta, timezone -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, - SecurityIncidentResponse, - SecurityIncidentListResponse -) - -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.now(timezone.utc) - - # 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 - - **Nota**: El contador `critical_actions_count` usa la misma lógica - que el módulo de Auditoría (eliminaciones, login fallidos, cambios de rol/estado). - - **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.now(timezone.utc) - analysis_start = now - timedelta(hours=hours) - - threats = [] - failed_login_attempts = 0 - suspicious_ips = set() - - # 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: - 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 ACCIONES CRÍTICAS (sincronizado con módulo de Auditoría) - critical_actions_count = 0 - - # Contar todos los login fallidos - login_failed_query = select(func.count(AuditLog.id)).where( - and_( - AuditLog.tenant_id == current_tenant.id, - AuditLog.action == 'user.login_failed', - AuditLog.created_at >= analysis_start - ) - ) - login_failed_result = await db.execute(login_failed_query) - critical_actions_count += login_failed_result.scalar() or 0 - - # Contar todas las eliminaciones (no solo masivas) - deletions_query = select(func.count(AuditLog.id)).where( - and_( - AuditLog.tenant_id == current_tenant.id, - AuditLog.action.like('%.delete'), - AuditLog.created_at >= analysis_start - ) - ) - deletions_result = await db.execute(deletions_query) - critical_actions_count += deletions_result.scalar() or 0 - - # Contar user.update con cambios sensibles (role, is_active, etc) - user_updates_query = select(AuditLog).where( - and_( - AuditLog.tenant_id == current_tenant.id, - AuditLog.action == 'user.update', - AuditLog.created_at >= analysis_start - ) - ) - user_updates_result = await db.execute(user_updates_query) - user_updates = user_updates_result.scalars().all() - - # Usar la misma función is_critical_user_update del módulo - for log in user_updates: - # Verificar si cambió algún campo sensible - if log.old_values and log.new_values: - sensitive_fields = ['role', 'is_active', 'is_superuser', 'permissions'] - for field in sensitive_fields: - if field in log.old_values and field in log.new_values: - if log.old_values[field] != log.new_values[field]: - critical_actions_count += 1 - break # Contar solo una vez por log - - # 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 - ) - - -@router.get("/security/incidents", response_model=SecurityIncidentListResponse) -async def get_security_incidents( - # Paginación - page: int = Query(default=1, ge=1, description="Número de página"), - per_page: int = Query(default=20, ge=1, le=100, description="Incidentes por página"), - - # Filtros - severity: Optional[str] = Query(None, description="Filtrar por severidad"), - status: Optional[str] = Query(None, description="Filtrar por estado"), - incident_type: Optional[str] = Query(None, description="Filtrar por tipo"), - search: Optional[str] = Query(None, description="Búsqueda en título o descripción"), - - # Multi-tenant (solo ADMIN/SUPPORT_MANAGER) - all_tenants: bool = Query(False, description="Ver incidentes 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 incidentes de seguridad. - - Los incidentes se generan dinámicamente analizando logs de auditoría - para detectar patrones sospechosos y acciones críticas. - - **Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR - - **Retorna**: Lista paginada de incidentes de seguridad - """ - logger.info( - "Fetching security incidents", - user_id=str(current_user.id), - tenant_id=str(current_tenant.id), - filters={ - "severity": severity, - "status": status, - "type": incident_type, - "page": page, - "per_page": per_page - } - ) - - # Generar incidentes a partir de logs de auditoría - incidents = [] - now = datetime.now(timezone.utc) - - # Determinar rango de tiempo para análisis (últimos 7 días para mejor performance) - analysis_start = now - timedelta(days=7) - - # Construir query base - base_query = select(AuditLog).options( - selectinload(AuditLog.user) - ).where( - AuditLog.created_at >= analysis_start - ) - - # Aplicar filtro de tenant - if all_tenants and current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]: - # Ver incidentes de todos los tenants - pass - else: - base_query = base_query.where(AuditLog.tenant_id == current_tenant.id) - - # 1. DETECTAR ELIMINACIONES MASIVAS - deletion_query = base_query.where( - AuditLog.action.like('%.delete') - ).order_by(desc(AuditLog.created_at)) - - deletion_result = await db.execute(deletion_query) - deletion_logs = deletion_result.scalars().all() - - # Agrupar eliminaciones por usuario y fecha - deletion_groups = {} - for log in deletion_logs: - if not log.user: - continue - - key = f"{log.user.email}_{log.created_at.date()}" - if key not in deletion_groups: - deletion_groups[key] = { - 'user': log.user.email, - 'date': log.created_at.date(), - 'count': 0, - 'logs': [], - 'first_seen': log.created_at, - 'last_seen': log.created_at - } - - deletion_groups[key]['count'] += 1 - deletion_groups[key]['logs'].append(log) - if log.created_at < deletion_groups[key]['first_seen']: - deletion_groups[key]['first_seen'] = log.created_at - if log.created_at > deletion_groups[key]['last_seen']: - deletion_groups[key]['last_seen'] = log.created_at - - # Crear incidentes para eliminaciones masivas (>=3 eliminaciones) - for key, group in deletion_groups.items(): - if group['count'] >= 3: # Umbral para considerar "masivo" - severity = "critical" if group['count'] >= 10 else "high" if group['count'] >= 5 else "medium" - - incidents.append(SecurityIncidentResponse( - id=f"mass_del_{key.replace('_', '-')}", - title=f"Eliminaciones masivas - {group['user']}", - description=f"{group['user']} eliminó {group['count']} elementos el {group['date']}", - severity=severity, - status="active" if (now - group['last_seen']).days <= 1 else "resolved", - incident_type="mass_deletion", - affected_user=group['user'], - source_ip=group['logs'][0].ip_address, - evidence=[ - f"{log.action} - {log.resource_type} - {log.created_at.strftime('%H:%M:%S')}" - for log in group['logs'][:5] # Solo mostrar los primeros 5 - ], - metadata={ - "total_deletions": group['count'], - "resource_types": list(set(log.resource_type for log in group['logs'])), - "time_span_minutes": int((group['last_seen'] - group['first_seen']).total_seconds() / 60) - }, - created_at=group['first_seen'], - updated_at=group['last_seen'] - )) - - # 2. DETECTAR INTENTOS DE LOGIN FALLIDOS - failed_login_query = base_query.where( - AuditLog.action == 'user.login_failed' - ).order_by(desc(AuditLog.created_at)) - - failed_login_result = await db.execute(failed_login_query) - failed_login_logs = failed_login_result.scalars().all() - - # Agrupar por IP - ip_groups = {} - for log in failed_login_logs: - if not log.ip_address: - continue - - ip = str(log.ip_address) - if ip not in ip_groups: - ip_groups[ip] = { - 'count': 0, - 'logs': [], - 'first_seen': log.created_at, - 'last_seen': log.created_at, - 'users': set() - } - - ip_groups[ip]['count'] += 1 - ip_groups[ip]['logs'].append(log) - if log.created_at < ip_groups[ip]['first_seen']: - ip_groups[ip]['first_seen'] = log.created_at - if log.created_at > ip_groups[ip]['last_seen']: - ip_groups[ip]['last_seen'] = log.created_at - if log.user and log.user.email: - ip_groups[ip]['users'].add(log.user.email) - - # Crear incidentes para IPs con muchos fallos (>=5) - for ip, group in ip_groups.items(): - if group['count'] >= 5: - severity = "critical" if group['count'] >= 20 else "high" if group['count'] >= 10 else "medium" - - incidents.append(SecurityIncidentResponse( - id=f"brute_force_{ip.replace('.', '-')}", - title=f"Posible ataque de fuerza bruta desde {ip}", - description=f"Se detectaron {group['count']} intentos fallidos de login desde la IP {ip}", - severity=severity, - status="active" if (now - group['last_seen']).total_seconds() <= 86400 else "investigating", # 24 horas - incident_type="brute_force_attack", - affected_user=', '.join(list(group['users'])[:3]) if group['users'] else None, - source_ip=ip, - evidence=[ - f"Login fallido - {log.user.email if log.user else 'Unknown'} - {log.created_at.strftime('%H:%M:%S')}" - for log in group['logs'][:5] - ], - metadata={ - "total_attempts": group['count'], - "targeted_users": list(group['users']), - "time_span_hours": int((group['last_seen'] - group['first_seen']).total_seconds() / 3600) - }, - created_at=group['first_seen'], - updated_at=group['last_seen'] - )) - - # 3. DETECTAR CAMBIOS DE ROLES/PRIVILEGIOS - privilege_query = base_query.where( - and_( - AuditLog.action == 'user.update', - AuditLog.new_values.op('?')('role') - ) - ).order_by(desc(AuditLog.created_at)) - - privilege_result = await db.execute(privilege_query) - privilege_logs = privilege_result.scalars().all() - - for log in privilege_logs: - if not log.user or not log.new_values or 'role' not in log.new_values: - continue - - old_role = log.old_values.get('role') if log.old_values else 'Unknown' - new_role = log.new_values.get('role') - - # Solo crear incidente si es escalada de privilegios - role_hierarchy = {'CLIENT_USER': 1, 'CLIENT_ADMIN': 2, 'AGENT': 3, 'SUPPORT_MANAGER': 4, 'ADMIN': 5} - old_level = role_hierarchy.get(old_role, 0) - new_level = role_hierarchy.get(new_role, 0) - - if new_level > old_level: - incidents.append(SecurityIncidentResponse( - id=f"priv_esc_{log.id}", - title=f"Escalada de privilegios - {log.user.email}", - description=f"Usuario {log.user.email} cambió de rol {old_role} a {new_role}", - severity="high" if new_role in ['ADMIN', 'SUPPORT_MANAGER'] else "medium", - status="investigating", - incident_type="privilege_escalation", - affected_user=log.user.email, - source_ip=log.ip_address, - evidence=[ - f"Cambio de rol: {old_role} → {new_role} - {log.created_at.strftime('%Y-%m-%d %H:%M')}" - ], - metadata={ - "old_role": old_role, - "new_role": new_role, - "correlation_id": str(log.correlation_id) if log.correlation_id else None - }, - created_at=log.created_at, - updated_at=log.created_at - )) - - # Aplicar filtros de búsqueda - filtered_incidents = incidents - - if severity: - filtered_incidents = [i for i in filtered_incidents if i.severity == severity] - - if status: - filtered_incidents = [i for i in filtered_incidents if i.status == status] - - if incident_type: - filtered_incidents = [i for i in filtered_incidents if i.incident_type == incident_type] - - if search: - search_lower = search.lower() - filtered_incidents = [ - i for i in filtered_incidents - if search_lower in i.title.lower() or (i.description and search_lower in i.description.lower()) - ] - - # Ordenar por fecha de creación (más recientes primero) - filtered_incidents.sort(key=lambda x: x.created_at, reverse=True) - - # Aplicar paginación - total = len(filtered_incidents) - total_pages = (total + per_page - 1) // per_page - - start_idx = (page - 1) * per_page - end_idx = start_idx + per_page - paginated_incidents = filtered_incidents[start_idx:end_idx] - - return SecurityIncidentListResponse( - incidents=paginated_incidents, - total=total, - page=page, - per_page=per_page, - total_pages=total_pages - ) - diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py index aae1fc9..fc6b33b 100644 --- a/backend/app/api/v1/endpoints/auth.py +++ b/backend/app/api/v1/endpoints/auth.py @@ -21,6 +21,7 @@ from app.services.audit_service import AuditService from app.services.token_service import TokenService from app.api.deps import oauth2_scheme, get_current_user from app.core.cache import cache, cache_key +from app.core.limiter import limiter # Nombres de cookie por tipo de usuario CLIENT_ROLES = {"CLIENT_ADMIN", "CLIENT_USER"} @@ -46,6 +47,7 @@ settings = get_settings() # =================================== @router.post("/login", response_model=LoginResponse) +@limiter.limit("10/minute") async def login( login_data: LoginRequest, request: Request, @@ -707,7 +709,9 @@ _RESET_KEY_PREFIX = "pwd_reset:" @router.post("/forgot-password", status_code=status.HTTP_200_OK) +@limiter.limit("5/minute") async def forgot_password( + request: Request, data: ForgotPasswordRequest, db: AsyncSession = Depends(get_db), ): @@ -774,7 +778,9 @@ async def forgot_password( @router.post("/reset-password", status_code=status.HTTP_200_OK) +@limiter.limit("5/minute") async def reset_password( + request: Request, data: ResetPasswordRequest, db: AsyncSession = Depends(get_db), ): diff --git a/backend/app/api/v1/endpoints/tickets_backup.py b/backend/app/api/v1/endpoints/tickets_backup.py deleted file mode 100644 index 3f88df5..0000000 --- a/backend/app/api/v1/endpoints/tickets_backup.py +++ /dev/null @@ -1,1072 +0,0 @@ -""" -Tickets endpoints - ServiceManagerWeb -""" - -from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File -from fastapi.responses import FileResponse -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func -from sqlalchemy.orm import selectinload -from typing import List, Optional -from datetime import datetime -from app.core.database import get_db -from app.api.deps import get_current_user, get_current_tenant -from app.models.ticket import Ticket, TicketStatus, TicketPriority -from app.models.user import User -from app.models.tenant import Tenant -from app.models.category import Category -from app.models.system import System -from app.models.comment import TicketComment -from app.models.attachment import TicketAttachment -from app.api.schemas.attachment import AttachmentResponse -from app.api.schemas.ticket import ( - TicketCreate, TicketUpdate, TicketResponse, - TicketCloseRequest, CommentCreate, CommentResponse -) -from app.core.file_handler import file_handler -from app.services.audit_service import AuditService -import uuid - -router = APIRouter() - -# =================================== -# TICKET ENDPOINTS -# =================================== - -@router.post("/", response_model=TicketResponse, status_code=status.HTTP_201_CREATED) -async def create_ticket( - ticket: TicketCreate, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - Crear un nuevo ticket - """ - # Retry logic para evitar race conditions en generación de ticket_number - max_retries = 3 - last_error = None - - for attempt in range(max_retries): - try: - # Generar número de ticket único basado en el máximo existente - result = await db.execute( - select(Ticket.ticket_number) - .where(Ticket.tenant_id == current_user.tenant_id) - .order_by(Ticket.ticket_number.desc()) - .limit(1) - ) - last_ticket_number = result.scalar_one_or_none() - - if last_ticket_number: - # Extraer el número del formato TK-XXXXXX - last_number = int(last_ticket_number.split('-')[1]) - next_number = last_number + 1 - else: - next_number = 1 - - ticket_number = f"TK-{next_number:06d}" - - # Convertir IDs de string a UUID si son proporcionados - category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None - system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None - - # Validar categoría - category = None - 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 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." - ) - - # Calcular SLA deadlines basados en la categoría - from datetime import timedelta - sla_response_due = None - sla_resolution_due = None - assigned_to_user = None - - if category: - now = datetime.utcnow() - sla_response_due = now + timedelta(hours=category.sla_response_hours) - sla_resolution_due = now + timedelta(hours=category.sla_resolution_hours) - - # Auto-asignar si la categoría tiene configurado auto_assign_to - if category.auto_assign_to: - assigned_to_user = category.auto_assign_to - - 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, - assigned_to=assigned_to_user, - status=TicketStatus.NEW, - sla_response_due=sla_response_due, - sla_resolution_due=sla_resolution_due, - 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 - } - ) - 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: Optional[str] = None, - priority: Optional[str] = None, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - Obtener tickets con filtros opcionales - Roles ADMIN/SUPPORT_MANAGER/AGENT: Ven todos los tickets del tenant - Roles CLIENT_USER/CLIENT_ADMIN: Solo ven sus propios tickets - - Filtros disponibles: - - status: NEW, IN_PROGRESS, WAITING_CUSTOMER, RESOLVED, CLOSED, REOPENED - - priority: LOW, MEDIUM, HIGH, URGENT - """ - # 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) - - # Filtro por estado - if status: - try: - status_enum = TicketStatus[status.upper()] - query = query.where(Ticket.status == status_enum) - except KeyError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid status: {status}. Valid values: NEW, IN_PROGRESS, WAITING_CUSTOMER, RESOLVED, CLOSED, REOPENED" - ) - - # Filtro por prioridad - if priority: - try: - priority_enum = TicketPriority[priority.upper()] - query = query.where(Ticket.priority == priority_enum) - except KeyError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid priority: {priority}. Valid values: LOW, MEDIUM, HIGH, URGENT" - ) - - 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 y agregar campos SLA - 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, - "sla_response_due": t.sla_response_due, - "sla_resolution_due": t.sla_resolution_due, - "first_response_at": t.first_response_at, - "resolved_at": t.resolved_at - } - for t in tickets - ] - - -@router.get("/admin/all", response_model=List[dict]) -async def get_all_tickets_admin( - skip: int = 0, - limit: int = 100, - status_filter: Optional[str] = None, - priority_filter: Optional[str] = None, - tenant_id_filter: Optional[str] = None, - category_filter: Optional[str] = None, - assigned_to_filter: Optional[str] = None, - search: Optional[str] = None, - date_from: Optional[str] = None, - date_to: Optional[str] = None, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - Obtener todos los tickets de todos los tenants (solo para administradores) - Incluye información del tenant y usuario que creó el ticket - Filtros: estado, prioridad, tenant, categoría, asignado a, búsqueda de texto y fechas - """ - # Verificar que el usuario sea administrador - if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="No tienes permisos para acceder a esta función" - ) - - # Query base con joins para obtener información del tenant y usuario creador - query = select(Ticket, Tenant, User).join( - Tenant, Ticket.tenant_id == Tenant.id - ).join( - User, Ticket.created_by == User.id - ) - - # Aplicar filtros - 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}" - ) - - if priority_filter: - try: - priority_enum = TicketPriority[priority_filter.upper()] - query = query.where(Ticket.priority == priority_enum) - except KeyError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid priority: {priority_filter}" - ) - - if tenant_id_filter: - try: - tenant_uuid = uuid.UUID(tenant_id_filter) - query = query.where(Ticket.tenant_id == tenant_uuid) - except ValueError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid tenant ID format" - ) - - if category_filter: - try: - category_uuid = uuid.UUID(category_filter) - query = query.where(Ticket.category_id == category_uuid) - except ValueError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid category ID format" - ) - - if assigned_to_filter: - try: - assigned_uuid = uuid.UUID(assigned_to_filter) - query = query.where(Ticket.assigned_to == assigned_uuid) - except ValueError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid assigned user ID format" - ) - - if search: - # Búsqueda de texto en subject y description - search_pattern = f"%{search}%" - query = query.where( - (Ticket.subject.ilike(search_pattern)) | - (Ticket.description.ilike(search_pattern)) - ) - - if date_from: - try: - from datetime import datetime - date_from_parsed = datetime.fromisoformat(date_from) - query = query.where(Ticket.created_at >= date_from_parsed) - except ValueError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid date_from format. Use YYYY-MM-DD" - ) - - if date_to: - try: - from datetime import datetime, timedelta - # Agregar 1 día para incluir todo el día final - date_to_parsed = datetime.fromisoformat(date_to) + timedelta(days=1) - query = query.where(Ticket.created_at < date_to_parsed) - except ValueError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid date_to format. Use YYYY-MM-DD" - ) - - query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit) - - result = await db.execute(query) - rows = result.all() - - return [ - { - "id": str(ticket.id), - "ticket_number": ticket.ticket_number, - "subject": ticket.subject, - "title": ticket.subject, - "description": ticket.description, - "status": ticket.status.value, - "priority": ticket.priority.value, - "category_id": str(ticket.category_id) if ticket.category_id else None, - "affected_system_id": str(ticket.affected_system_id) if ticket.affected_system_id else None, - "created_by": str(ticket.created_by), - "assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None, - "created_at": ticket.created_at, - "updated_at": ticket.updated_at, - # Información del tenant/cliente - "tenant": { - "id": str(tenant.id), - "name": tenant.name, - "slug": tenant.slug, - "contact_email": tenant.contact_email - }, - # Información del usuario creador - "created_by_user": { - "id": str(user.id), - "email": user.email, - "first_name": user.first_name, - "last_name": user.last_name, - "role": user.role.value if hasattr(user.role, 'value') else str(user.role) - } - } - for ticket, tenant, user in rows - ] - - -@router.get("/{ticket_id}", response_model=TicketResponse) -async def get_ticket( - ticket_id: str, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 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) - except ValueError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - 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 - ) - - # 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() - - if not ticket: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Ticket {ticket_id} not found" - ) - - # ✅ CORREGIDO: Usar affected_system_id + campos SLA - return { - "id": str(ticket.id), - "ticket_number": ticket.ticket_number, - "subject": ticket.subject, - "title": ticket.subject, - "description": ticket.description, - "status": ticket.status.value, - "priority": ticket.priority.value, - "category_id": str(ticket.category_id) if ticket.category_id else None, - "affected_system_id": str(ticket.affected_system_id) if ticket.affected_system_id else None, - "created_by": str(ticket.created_by), - "assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None, - "created_at": ticket.created_at, - "updated_at": ticket.updated_at, - "first_response_at": ticket.first_response_at, - "resolved_at": ticket.resolved_at, - "sla_response_due": ticket.sla_response_due, - "sla_resolution_due": ticket.sla_resolution_due, - } - - -@router.patch("/{ticket_id}", response_model=TicketResponse) -async def update_ticket( - ticket_id: str, - ticket_update: TicketUpdate, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 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) - except ValueError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - 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 - ) - - # 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() - - if not db_ticket: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - 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) - - for field, value in update_data.items(): - if field == "status" and value: - new_status = TicketStatus[value.upper()] - setattr(db_ticket, field, new_status) - # Registrar timestamp de resolución - if new_status in (TicketStatus.RESOLVED, TicketStatus.CLOSED): - if db_ticket.resolved_at is None: - db_ticket.resolved_at = datetime.utcnow() - # Si reabre el ticket, limpiar resolved_at - elif new_status in (TicketStatus.NEW, TicketStatus.IN_PROGRESS, TicketStatus.WAITING_CUSTOMER): - db_ticket.resolved_at = None - elif field == "priority" and value: - setattr(db_ticket, field, TicketPriority[value.upper()]) - elif field == "assigned_to" and value: - setattr(db_ticket, field, uuid.UUID(value)) - else: - setattr(db_ticket, field, value) - - db_ticket.updated_at = datetime.utcnow() - - 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), - "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 - } - - except Exception as e: - await db.rollback() - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Error updating ticket: {str(e)}" - ) - - -@router.patch("/{ticket_id}/close", response_model=TicketResponse) -async def close_ticket( - ticket_id: str, - close_request: TicketCloseRequest, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 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) - except ValueError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - 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 - ) - - # 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() - - if not db_ticket: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Ticket {ticket_id} not found" - ) - - try: - db_ticket.status = TicketStatus.CLOSED - if db_ticket.resolved_at is None: - db_ticket.resolved_at = datetime.utcnow() - db_ticket.updated_at = datetime.utcnow() - - await db.commit() - await db.refresh(db_ticket) - - 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, - "resolved_at": db_ticket.resolved_at - } - - except Exception as e: - await db.rollback() - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Error closing ticket: {str(e)}" - ) - - -## =================================== -# COMMENT ENDPOINTS -# =================================== - - -@router.get("/{ticket_id}/comments", response_model=List[CommentResponse]) -async def get_ticket_comments( - ticket_id: str, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - Obtener comentarios de un ticket - """ - try: - ticket_uuid = uuid.UUID(ticket_id) - except ValueError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid ticket ID format" - ) - - # Verificar que el ticket existe y el usuario tiene acceso - ticket_query = select(Ticket).where( - Ticket.id == ticket_uuid, - Ticket.tenant_id == current_user.tenant_id - ) - ticket_result = await db.execute(ticket_query) - ticket = ticket_result.scalars().first() - - if not ticket: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Ticket {ticket_id} not found" - ) - - # Obtener comentarios - comments_query = select(TicketComment, User).join( - User, TicketComment.author_id == User.id - ).where( - TicketComment.ticket_id == ticket_uuid - ).order_by(TicketComment.created_at.asc()) - - result = await db.execute(comments_query) - comments_with_users = result.all() - - # Formatear respuesta - return [ - { - "id": str(comment.id), - "ticket_id": str(comment.ticket_id), - "author_id": str(comment.author_id), - "author_name": f"{user.first_name} {user.last_name}", - "content": comment.content, - "is_internal": comment.is_internal, - "created_at": comment.created_at, - "updated_at": comment.updated_at - } - for comment, user in comments_with_users - ] - - -@router.post("/{ticket_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED) -async def create_comment( - ticket_id: str, - comment: CommentCreate, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - Agregar un comentario a un ticket - """ - try: - ticket_uuid = uuid.UUID(ticket_id) - except ValueError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid ticket ID format" - ) - - # Verificar que el ticket existe y el usuario tiene acceso - ticket_query = select(Ticket).where( - Ticket.id == ticket_uuid, - Ticket.tenant_id == current_user.tenant_id - ) - ticket_result = await db.execute(ticket_query) - ticket_obj = ticket_result.scalars().first() - - if not ticket_obj: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Ticket {ticket_id} not found" - ) - - # Crear comentario - new_comment = TicketComment( - id=uuid.uuid4(), - ticket_id=ticket_uuid, - author_id=current_user.id, - content=comment.content, - is_internal=comment.is_internal, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow() - ) - - db.add(new_comment) - - # Registrar primera respuesta de staff si aún no se ha hecho - staff_roles = ["ADMIN", "SUPPORT_MANAGER", "AGENT"] - if ( - current_user.role in staff_roles - and not comment.is_internal - and ticket_obj.first_response_at is None - ): - ticket_obj.first_response_at = datetime.utcnow() - - # Actualizar el ticket updated_at - ticket_obj.updated_at = datetime.utcnow() - - await db.commit() - await db.refresh(new_comment) - - # Retornar con el nombre del autor - return { - "id": str(new_comment.id), - "ticket_id": str(new_comment.ticket_id), - "author_id": str(new_comment.author_id), - "author_name": f"{current_user.first_name} {current_user.last_name}", - "content": new_comment.content, - "is_internal": new_comment.is_internal, - "created_at": new_comment.created_at, - "updated_at": new_comment.updated_at - } -@router.delete("/{ticket_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_ticket( - ticket_id: str, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - Eliminar un ticket (solo admin/manager) - """ - try: - ticket_uuid = uuid.UUID(ticket_id) - except ValueError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid ticket ID format" - ) - - query = select(Ticket).where( - Ticket.id == ticket_uuid, - Ticket.tenant_id == current_user.tenant_id - ) - - result = await db.execute(query) - db_ticket = result.scalars().first() - - if not db_ticket: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - 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"} - -# =================================== -# =================================== -# ATTACHMENT ENDPOINTS -# =================================== - -@router.get("/{ticket_id}/attachments", response_model=List[AttachmentResponse]) -async def get_ticket_attachments( - ticket_id: str, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), - current_tenant: Tenant = Depends(get_current_tenant) -): - """Obtener adjuntos de un ticket""" - try: - ticket_uuid = uuid.UUID(ticket_id) - except ValueError: - raise HTTPException(status_code=400, detail="ID de ticket inválido") - - # Verificar que el ticket existe y pertenece al tenant - result = await db.execute( - select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_tenant.id) - ) - ticket = result.scalar_one_or_none() - - if not ticket: - raise HTTPException(status_code=404, detail="Ticket no encontrado") - - # Obtener attachments - result = await db.execute( - select(TicketAttachment) - .where(TicketAttachment.ticket_id == ticket_uuid) - .options(selectinload(TicketAttachment.uploaded_by_user)) - .order_by(TicketAttachment.created_at.desc()) - ) - attachments = result.scalars().all() - - # Construir respuesta - response = [] - for att in attachments: - response.append(AttachmentResponse( - id=att.id, - ticket_id=att.ticket_id, - comment_id=att.comment_id, - uploaded_by=att.uploaded_by, - filename=att.filename, - original_filename=att.original_filename, - mime_type=att.mime_type, - file_size=att.file_size, - file_path=att.file_path, - uploaded_by_name=f"{att.uploaded_by_user.first_name} {att.uploaded_by_user.last_name}" if att.uploaded_by_user else "Unknown", - created_at=att.created_at, - download_url=f"/api/v1/tickets/{ticket_id}/attachments/{att.id}/download" - )) - - return response - - -@router.post("/{ticket_id}/attachments", status_code=status.HTTP_201_CREATED) -async def upload_attachment( - ticket_id: str, - file: UploadFile = File(...), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), - current_tenant: Tenant = Depends(get_current_tenant) -): - """Subir un archivo adjunto a un ticket""" - try: - ticket_uuid = uuid.UUID(ticket_id) - except ValueError: - raise HTTPException(status_code=400, detail="ID de ticket inválido") - - # Verificar ticket - result = await db.execute( - select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_tenant.id) - ) - ticket = result.scalar_one_or_none() - - if not ticket: - raise HTTPException(status_code=404, detail="Ticket no encontrado") - - # Guardar archivo - file_metadata = await file_handler.save_upload(file, current_tenant.id, ticket_uuid) - - # Crear registro en BD - attachment = TicketAttachment( - id=uuid.uuid4(), - ticket_id=ticket_uuid, - uploaded_by=current_user.id, - filename=file_metadata["filename"], - original_filename=file_metadata["original_filename"], - mime_type=file_metadata["mime_type"], - file_size=file_metadata["file_size"], - file_path=file_metadata["file_path"], - md5_hash=file_metadata["md5_hash"], - sha256_hash=file_metadata["sha256_hash"], - created_at=datetime.utcnow() - ) - - db.add(attachment) - await db.commit() - await db.refresh(attachment, ["uploaded_by_user"]) - - return { - "success": True, - "message": "Archivo subido exitosamente", - "data": AttachmentResponse( - id=attachment.id, - ticket_id=attachment.ticket_id, - comment_id=attachment.comment_id, - uploaded_by=attachment.uploaded_by, - filename=attachment.filename, - original_filename=attachment.original_filename, - mime_type=attachment.mime_type, - file_size=attachment.file_size, - file_path=attachment.file_path, - uploaded_by_name=f"{attachment.uploaded_by_user.first_name} {attachment.uploaded_by_user.last_name}", - created_at=attachment.created_at, - download_url=f"/api/v1/tickets/{ticket_id}/attachments/{attachment.id}/download" - ) - } - - -@router.get("/{ticket_id}/attachments/{attachment_id}/download") -async def download_attachment( - ticket_id: str, - attachment_id: str, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), - current_tenant: Tenant = Depends(get_current_tenant) -): - """Descargar un archivo adjunto""" - import logging - logger = logging.getLogger(__name__) - - logger.info(f"Download request - ticket_id: {ticket_id}, attachment_id: {attachment_id}") - - try: - ticket_uuid = uuid.UUID(ticket_id) - attachment_uuid = uuid.UUID(attachment_id) - except ValueError: - logger.error(f"Invalid UUID format - ticket_id: {ticket_id}, attachment_id: {attachment_id}") - raise HTTPException(status_code=400, detail="ID inválido") - - # Verificar ticket - result = await db.execute( - select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_tenant.id) - ) - ticket = result.scalar_one_or_none() - - if not ticket: - logger.error(f"Ticket not found - ticket_id: {ticket_id}") - raise HTTPException(status_code=404, detail="Ticket no encontrado") - - # Obtener attachment - result = await db.execute( - select(TicketAttachment) - .where(TicketAttachment.id == attachment_uuid, TicketAttachment.ticket_id == ticket_uuid) - ) - attachment = result.scalar_one_or_none() - - if not attachment: - logger.error(f"Attachment not found - attachment_id: {attachment_id}") - raise HTTPException(status_code=404, detail="Adjunto no encontrado") - - logger.info(f"Attachment found - file_path: {attachment.file_path}, original_filename: {attachment.original_filename}") - - # Obtener path del archivo - try: - file_path = file_handler.get_file_path(attachment.file_path) - logger.info(f"Absolute file path: {file_path}") - - if not file_path.exists(): - logger.error(f"File does not exist at path: {file_path}") - raise HTTPException(status_code=404, detail="Archivo no encontrado en el sistema") - - except Exception as e: - logger.error(f"Error getting file path: {str(e)}") - raise - - # Retornar archivo - logger.info(f"Returning file: {attachment.original_filename}") - return FileResponse( - path=file_path, - filename=attachment.original_filename, - media_type=attachment.mime_type - ) \ No newline at end of file diff --git a/backend/app/core/config.py b/backend/app/core/config.py index f971989..815d05f 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -5,7 +5,6 @@ Configuración centralizada usando Pydantic Settings v2 """ from functools import lru_cache -from typing import List, Optional from pydantic_settings import BaseSettings from pydantic import field_validator, Field import os @@ -60,8 +59,8 @@ class Settings(BaseSettings): # =================================== SMTP_HOST: str = Field(default="localhost") SMTP_PORT: int = Field(default=587) - SMTP_USER: Optional[str] = Field(default=None) - SMTP_PASSWORD: Optional[str] = Field(default=None) + SMTP_USER: str | None = Field(default=None) + SMTP_PASSWORD: str | None = Field(default=None) SMTP_USE_TLS: bool = Field(default=True) SMTP_USE_SSL: bool = Field(default=False) @@ -79,7 +78,7 @@ class Settings(BaseSettings): UPLOAD_PATH: str = Field(default="/app/uploads") @property - def ALLOWED_FILE_EXTENSIONS(self) -> List[str]: + def ALLOWED_FILE_EXTENSIONS(self) -> list[str]: """Parse the comma-separated file extensions.""" return [ext.strip().lower() for ext in self.ALLOWED_FILE_EXTENSIONS_STR.split(",")] @@ -102,7 +101,7 @@ class Settings(BaseSettings): # =================================== LOG_LEVEL: str = Field(default="INFO") LOG_FORMAT: str = Field(default="json") - LOG_FILE: Optional[str] = Field(default=None) + LOG_FILE: str | None = Field(default=None) # =================================== # FRONTEND URLS diff --git a/backend/app/core/limiter.py b/backend/app/core/limiter.py new file mode 100644 index 0000000..e4d8684 --- /dev/null +++ b/backend/app/core/limiter.py @@ -0,0 +1,20 @@ +""" +Rate Limiter - ServiceManagerWeb + +Configura slowapi con Redis como storage backend. +Respeta settings.RATE_LIMIT_ENABLED: si está desactivado usa memoria +y el limiter queda en modo noop (enabled=False). +""" + +from slowapi import Limiter +from slowapi.util import get_remote_address + +from app.core.config import get_settings + +settings = get_settings() + +limiter = Limiter( + key_func=get_remote_address, + storage_uri=settings.REDIS_URL if settings.RATE_LIMIT_ENABLED else "memory://", + enabled=settings.RATE_LIMIT_ENABLED, +) diff --git a/backend/app/main.py b/backend/app/main.py index efd96ec..f11cae0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,6 +9,9 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import JSONResponse from contextlib import asynccontextmanager +from slowapi import _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded +from slowapi.middleware import SlowAPIMiddleware import structlog import time import uuid @@ -31,6 +34,7 @@ from app.api.v1.router import api_router from app.middleware.tenant import TenantMiddleware from app.middleware.correlation_id import CorrelationIDMiddleware from app.core.cache import cache +from app.core.limiter import limiter settings = get_settings() setup_logging() @@ -70,6 +74,11 @@ app = FastAPI( openapi_url=f"/{settings.API_VERSION}/openapi.json" ) +# SlowAPI rate limiting +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) +app.add_middleware(SlowAPIMiddleware) + # =================================== # MIDDLEWARE # =================================== diff --git a/backend/migrations/versions/b7c8d9e0f1a2_add_ticket_indexes.py b/backend/migrations/versions/b7c8d9e0f1a2_add_ticket_indexes.py new file mode 100644 index 0000000..5c971dc --- /dev/null +++ b/backend/migrations/versions/b7c8d9e0f1a2_add_ticket_indexes.py @@ -0,0 +1,43 @@ +"""add_ticket_indexes + +Revision ID: b7c8d9e0f1a2 +Revises: fix_client_timestamps +Create Date: 2026-03-03 00:00:00.000000 + +Agrega índices a la tabla tickets para optimizar queries frecuentes: + - idx_tickets_status → filtros por estado + - idx_tickets_priority → filtros por prioridad + - idx_tickets_assigned_to → tickets por agente asignado + - idx_tickets_tenant_status → compuesto multi-tenant (tenant_id, status) +""" +from alembic import op + +# revision identifiers, used by Alembic. +revision = 'b7c8d9e0f1a2' +down_revision = 'fix_client_timestamps' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + "CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets (status)" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS idx_tickets_priority ON tickets (priority)" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS idx_tickets_assigned_to " + "ON tickets (assigned_to) WHERE assigned_to IS NOT NULL" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS idx_tickets_tenant_status " + "ON tickets (tenant_id, status)" + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS idx_tickets_tenant_status") + op.execute("DROP INDEX IF EXISTS idx_tickets_assigned_to") + op.execute("DROP INDEX IF EXISTS idx_tickets_priority") + op.execute("DROP INDEX IF EXISTS idx_tickets_status") diff --git a/backend/requirements.txt b/backend/requirements.txt index 61789b5..de37312 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -31,6 +31,7 @@ pyotp==2.9.0 # TOTP/2FA support # =================================== celery==5.3.4 redis==5.0.1 +slowapi==0.1.9 # Rate limiting middleware # =================================== # EMAIL diff --git a/docker-compose.yml b/docker-compose.yml index 1b85d3a..9814035 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,7 +32,7 @@ services: container_name: servicemanager-redis restart: unless-stopped command: redis-server --appendonly yes - volumes: + volumes: - redis_data:/data ports: - "6379:6379" diff --git a/frontend-client/src/app.d.ts b/frontend-client/src/app.d.ts new file mode 100644 index 0000000..2639382 --- /dev/null +++ b/frontend-client/src/app.d.ts @@ -0,0 +1,11 @@ +import type { User } from '$lib/stores/auth'; + +declare global { + namespace App { + interface Locals { + user: User | null; + } + } +} + +export {}; diff --git a/frontend-client/src/hooks.server.ts b/frontend-client/src/hooks.server.ts new file mode 100644 index 0000000..19e5436 --- /dev/null +++ b/frontend-client/src/hooks.server.ts @@ -0,0 +1,19 @@ +import type { Handle } from '@sveltejs/kit'; + +export const handle: Handle = async ({ event, resolve }) => { + const cookie = event.request.headers.get('cookie') ?? ''; + if (cookie) { + try { + const apiUrl = process.env.PUBLIC_API_URL ?? 'http://backend:8000'; + const response = await fetch(`${apiUrl}/v1/auth/me`, { + headers: { cookie, 'X-App': 'client' } + }); + event.locals.user = response.ok ? await response.json() : null; + } catch { + event.locals.user = null; + } + } else { + event.locals.user = null; + } + return resolve(event); +}; diff --git a/frontend-client/src/lib/stores/auth.ts b/frontend-client/src/lib/stores/auth.ts index 3edfd2e..0316596 100644 --- a/frontend-client/src/lib/stores/auth.ts +++ b/frontend-client/src/lib/stores/auth.ts @@ -128,6 +128,11 @@ function createAuthStore() { update(state => ({ ...state, user })); }, + // Set user from SSR pre-load (no fetch required) + setUser: (user: User) => { + set({ user, token: null, isAuthenticated: true, isLoading: false }); + }, + // Set loading state setLoading: (isLoading: boolean) => { update(state => ({ ...state, isLoading })); diff --git a/frontend-client/src/routes/+layout.server.ts b/frontend-client/src/routes/+layout.server.ts new file mode 100644 index 0000000..5993cb1 --- /dev/null +++ b/frontend-client/src/routes/+layout.server.ts @@ -0,0 +1,7 @@ +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = ({ locals }) => { + return { + user: locals.user ?? null + }; +}; diff --git a/frontend-client/src/routes/+layout.svelte b/frontend-client/src/routes/+layout.svelte index e4a99c8..6f00420 100644 --- a/frontend-client/src/routes/+layout.svelte +++ b/frontend-client/src/routes/+layout.svelte @@ -9,10 +9,14 @@ import { browser } from '$app/environment'; import '../app.css'; + export let data; + let mounted = false; - onMount(async () => { - await auth.init(); + onMount(() => { + if (data.user && !$auth.isAuthenticated) { + auth.setUser(data.user); + } mounted = true; }); diff --git a/frontend-internal/src/app.d.ts b/frontend-internal/src/app.d.ts new file mode 100644 index 0000000..bd3ac90 --- /dev/null +++ b/frontend-internal/src/app.d.ts @@ -0,0 +1,11 @@ +import type { InternalUser } from '$lib/stores/auth'; + +declare global { + namespace App { + interface Locals { + user: InternalUser | null; + } + } +} + +export {}; diff --git a/frontend-internal/src/hooks.server.ts b/frontend-internal/src/hooks.server.ts new file mode 100644 index 0000000..7b47380 --- /dev/null +++ b/frontend-internal/src/hooks.server.ts @@ -0,0 +1,19 @@ +import type { Handle } from '@sveltejs/kit'; + +export const handle: Handle = async ({ event, resolve }) => { + const cookie = event.request.headers.get('cookie') ?? ''; + if (cookie) { + try { + const apiUrl = process.env.PUBLIC_API_URL ?? 'http://backend:8000'; + const response = await fetch(`${apiUrl}/v1/auth/me`, { + headers: { cookie, 'X-App': 'internal' } + }); + event.locals.user = response.ok ? await response.json() : null; + } catch { + event.locals.user = null; + } + } else { + event.locals.user = null; + } + return resolve(event); +}; diff --git a/frontend-internal/src/lib/stores/auth.ts b/frontend-internal/src/lib/stores/auth.ts index 9a5c74c..188b11e 100644 --- a/frontend-internal/src/lib/stores/auth.ts +++ b/frontend-internal/src/lib/stores/auth.ts @@ -190,6 +190,11 @@ function createAuthStore() { update(state => ({ ...state, user })); }, + // Set user from SSR pre-load (no fetch required) + setUser: (user: InternalUser) => { + set({ user, token: null, refreshToken: null, isAuthenticated: true, isLoading: false }); + }, + // Set loading state setLoading: (isLoading: boolean) => { update(state => ({ ...state, isLoading })); diff --git a/frontend-internal/src/routes/+layout.server.ts b/frontend-internal/src/routes/+layout.server.ts new file mode 100644 index 0000000..5993cb1 --- /dev/null +++ b/frontend-internal/src/routes/+layout.server.ts @@ -0,0 +1,7 @@ +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = ({ locals }) => { + return { + user: locals.user ?? null + }; +}; diff --git a/frontend-internal/src/routes/+layout.svelte b/frontend-internal/src/routes/+layout.svelte index 195dd8e..9fe6705 100644 --- a/frontend-internal/src/routes/+layout.svelte +++ b/frontend-internal/src/routes/+layout.svelte @@ -10,11 +10,15 @@ import { browser } from '$app/environment'; import '../app.css'; + export let data; + let sidebarOpen = false; let mounted = false; - onMount(async () => { - await auth.init(); + onMount(() => { + if (data.user && !$auth.isAuthenticated) { + auth.setUser(data.user); + } mounted = true; });