Merge: Integrar Sistema de Análisis de Seguridad v1.6.0 a main
CARACTERÍSTICAS PRINCIPALES v1.6.0: - Sistema de auditoría multi-tenant completo - Análisis de seguridad con detección de amenazas en tiempo real - Panel de seguridad con 4 algoritmos de detección: * Ataques de fuerza bruta * Escalada de privilegios * Eliminaciones masivas * Cuentas comprometidas - Acciones de seguridad: bloquear IP, resetear contraseña, notificar admin - Cross-tenant viewing para ADMIN/SUPPORT_MANAGER - Frontend completamente funcional con nuevo menú Seguridad - Sistema completamente verificado y operativo Resolución de conflictos: - Versiones actualizadas a 1.6.0 en todos los package.json y pyproject.toml - Menú de seguridad integrado en Sidebar - Tickets endpoint actualizado con auditoría - Correcciones de middleware y queries SQL aplicadas
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
# ===================================
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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"<AuditLog(action='{self.action}', resource='{self.resource_type}:{self.resource_id}')>"
|
||||
|
||||
@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
|
||||
|
||||
@@ -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"<RefreshToken(user_id='{self.user_id}', revoked={self.revoked}, expires={self.expires_at})>"
|
||||
|
||||
@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
|
||||
|
||||
@@ -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__ = (
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
67
backend/create_test_user.py
Normal file
67
backend/create_test_user.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Script para crear/actualizar usuario de prueba con contraseña conocida
|
||||
"""
|
||||
import asyncio
|
||||
from sqlalchemy import select, update
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.core.security import security
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.tenant import Tenant
|
||||
import uuid
|
||||
|
||||
async def create_test_user():
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Buscar tenant
|
||||
tenant_query = select(Tenant).where(Tenant.slug.like('%aduanasoft%')).limit(1)
|
||||
result = await db.execute(tenant_query)
|
||||
tenant = result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
print("❌ No se encontró tenant")
|
||||
return
|
||||
|
||||
print(f"✅ Tenant encontrado: {tenant.name} ({tenant.slug})")
|
||||
|
||||
# Buscar o crear usuario admin
|
||||
user_query = select(User).where(
|
||||
User.email == "admin@aduanasoft.com",
|
||||
User.tenant_id == tenant.id
|
||||
)
|
||||
result = await db.execute(user_query)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
# Hash de la contraseña "admin123"
|
||||
password_hash = security.hash_password("admin123")
|
||||
|
||||
if user:
|
||||
# Actualizar contraseña
|
||||
user.password_hash = password_hash
|
||||
user.is_active = True
|
||||
user.email_verified = True
|
||||
await db.commit()
|
||||
print(f"✅ Usuario actualizado: {user.email}")
|
||||
else:
|
||||
# Crear usuario nuevo
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant.id,
|
||||
email="admin@aduanasoft.com",
|
||||
first_name="Admin",
|
||||
last_name="Sistema",
|
||||
password_hash=password_hash,
|
||||
role=UserRole.ADMIN,
|
||||
is_active=True,
|
||||
email_verified=True
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
print(f"✅ Usuario creado: {user.email}")
|
||||
|
||||
print(f"\n📋 Credenciales de prueba:")
|
||||
print(f" Email: admin@aduanasoft.com")
|
||||
print(f" Password: admin123")
|
||||
print(f" Tenant: {tenant.slug}")
|
||||
print(f" Role: ADMIN")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(create_test_user())
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user