v1.5.1.2: Integración completa del sistema de auditoría

- Agregado módulo de auditoría con AuditLog model
- Implementado endpoint /api/v1/audit para consulta de logs
- Integrado audit_service para registro automático de acciones
- Agregado token_service para gestión de refresh tokens
- Frontend: Vista de auditoría en panel interno
- Actualizada versión en pyproject.toml y package.json
- Sistema de auditoría completamente funcional y testeado
This commit is contained in:
2026-02-16 08:05:25 -07:00
parent 96084b89c0
commit 1543397212
16 changed files with 2157 additions and 6 deletions

View File

@@ -0,0 +1,109 @@
"""
Audit Schemas - ServiceManagerWeb
Schemas Pydantic para endpoints de auditoría
"""
from pydantic import BaseModel, Field, UUID4
from typing import Optional, Dict, Any
from datetime import datetime
class AuditLogBase(BaseModel):
"""Schema base para audit logs."""
action: str = Field(..., description="Acci├│n realizada (ej: ticket.create)")
resource_type: str = Field(..., description="Tipo de recurso (ticket, user, etc.)")
resource_id: Optional[UUID4] = Field(None, description="ID del recurso afectado")
extra_metadata: Optional[Dict[str, Any]] = Field(None, description="Metadata adicional", alias="metadata")
class AuditLogResponse(AuditLogBase):
"""
Schema de respuesta para audit logs.
Incluye toda la informaci├│n del log con datos del usuario.
"""
id: UUID4
tenant_id: UUID4
user_id: Optional[UUID4]
# Informaci├│n del usuario (si existe)
user_email: Optional[str] = None
user_name: Optional[str] = None
user_role: Optional[str] = None
# Contexto de la acci├│n
ip_address: Optional[str]
user_agent: Optional[str]
correlation_id: Optional[UUID4]
# Cambios realizados
old_values: Optional[Dict[str, Any]]
new_values: Optional[Dict[str, Any]]
# Timestamp
created_at: datetime
# Display friendly
action_display: str = Field(description="Acci├│n en formato amigable")
class Config:
from_attributes = True
class AuditLogFilters(BaseModel):
"""
Filtros para consulta de audit logs.
Permite filtrar por m├║ltiples criterios.
"""
# Paginaci├│n
page: int = Field(default=1, ge=1, description="Número de página")
per_page: int = Field(default=50, ge=1, le=100, description="Elementos por página")
# Filtros
user_id: Optional[UUID4] = Field(None, description="Filtrar por usuario")
action: Optional[str] = Field(None, description="Filtrar por acción específica")
resource_type: Optional[str] = Field(None, description="Filtrar por tipo de recurso")
resource_id: Optional[UUID4] = Field(None, description="Filtrar por ID de recurso")
# Rango de fechas
date_from: Optional[datetime] = Field(None, description="Fecha inicio (ISO 8601)")
date_to: Optional[datetime] = Field(None, description="Fecha fin (ISO 8601)")
# B├║squeda
search: Optional[str] = Field(None, description="B├║squeda en acciones o recursos")
class AuditLogStats(BaseModel):
"""
Estadísticas de auditoría.
Resumen de actividad del sistema.
"""
total_actions: int = Field(description="Total de acciones registradas")
actions_today: int = Field(description="Acciones en las ├║ltimas 24 horas")
actions_this_week: int = Field(description="Acciones en los últimos 7 días")
# Top acciones
top_actions: Dict[str, int] = Field(description="Acciones más frecuentes")
# Top usuarios
top_users: Dict[str, int] = Field(description="Usuarios más activos")
# Actividad por tipo de recurso
by_resource_type: Dict[str, int] = Field(description="Acciones por tipo de recurso")
class AuditLogListResponse(BaseModel):
"""
Respuesta paginada de audit logs.
"""
logs: list[AuditLogResponse]
total: int = Field(description="Total de registros")
page: int = Field(description="Página actual")
per_page: int = Field(description="Registros por página")
total_pages: int = Field(description="Total de páginas")
class Config:
from_attributes = True

View File

@@ -0,0 +1,338 @@
"""
Audit Endpoints - ServiceManagerWeb
Endpoints para consulta de logs de auditoría.
Solo accesible por roles: ADMIN, SUPPORT_MANAGER, AUDITOR
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, or_, desc
from sqlalchemy.orm import selectinload
from typing import Optional, List
from datetime import datetime, timedelta
import uuid
import structlog
from app.core.database import get_db
from app.api.deps import get_current_user, get_current_tenant
from app.models.user import User, UserRole
from app.models.tenant import Tenant
from app.models.audit import AuditLog
from app.api.schemas.audit import (
AuditLogResponse,
AuditLogListResponse,
AuditLogFilters,
AuditLogStats
)
router = APIRouter()
logger = structlog.get_logger(__name__)
def require_auditor_role(current_user: User = Depends(get_current_user)) -> User:
"""
Dependency que verifica que el usuario tenga rol de auditor.
Solo ADMIN, SUPPORT_MANAGER y AUDITOR pueden ver logs de auditoría.
"""
allowed_roles = [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR]
if current_user.role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Solo usuarios con rol ADMIN, SUPPORT_MANAGER o AUDITOR pueden acceder a logs de auditoría"
)
return current_user
@router.get("/", response_model=AuditLogListResponse)
async def get_audit_logs(
# Paginaci├│n
page: int = Query(default=1, ge=1, description="Número de página"),
per_page: int = Query(default=50, ge=1, le=100, description="Registros por página"),
# Filtros
user_id: Optional[uuid.UUID] = Query(None, description="Filtrar por usuario"),
action: Optional[str] = Query(None, description="Filtrar por acci├│n"),
resource_type: Optional[str] = Query(None, description="Filtrar por tipo de recurso"),
resource_id: Optional[uuid.UUID] = Query(None, description="Filtrar por ID de recurso"),
date_from: Optional[datetime] = Query(None, description="Fecha desde"),
date_to: Optional[datetime] = Query(None, description="Fecha hasta"),
search: Optional[str] = Query(None, description="B├║squeda en acci├│n o email"),
# Dependencies
current_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Obtener logs de auditoría con filtros y paginación.
**Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR
**Filtros disponibles**:
- `user_id`: Acciones de un usuario específico
- `action`: Tipo de acci├│n (ej: "ticket.create")
- `resource_type`: Tipo de recurso (ej: "ticket")
- `resource_id`: ID de recurso específico
- `date_from`, `date_to`: Rango de fechas
- `search`: B├║squeda en acciones
**Retorna**: Lista paginada de audit logs
"""
logger.info(
"Fetching audit logs",
user_id=str(current_user.id),
tenant_id=str(current_tenant.id),
filters={
"user_id": str(user_id) if user_id else None,
"action": action,
"resource_type": resource_type,
"page": page
}
)
# Query base - solo logs del tenant actual
# Usar selectinload para cargar la relaci├│n user (eager loading para async)
query = (
select(AuditLog)
.where(AuditLog.tenant_id == current_tenant.id)
.options(selectinload(AuditLog.user))
)
# Aplicar filtros
if user_id:
query = query.where(AuditLog.user_id == user_id)
if action:
query = query.where(AuditLog.action == action)
if resource_type:
query = query.where(AuditLog.resource_type == resource_type)
if resource_id:
query = query.where(AuditLog.resource_id == resource_id)
if date_from:
query = query.where(AuditLog.created_at >= date_from)
if date_to:
# Agregar 1 día para incluir todo el día
date_to_end = date_to + timedelta(days=1)
query = query.where(AuditLog.created_at < date_to_end)
if search:
# B├║squeda en action
search_filter = AuditLog.action.ilike(f"%{search}%")
query = query.where(search_filter)
# Ordenar por fecha descendente (más recientes primero)
query = query.order_by(desc(AuditLog.created_at))
# Contar total antes de paginar
count_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Aplicar paginaci├│n
offset = (page - 1) * per_page
query = query.offset(offset).limit(per_page)
# Ejecutar query
result = await db.execute(query)
logs = result.scalars().all()
# Calcular total de páginas
total_pages = (total + per_page - 1) // per_page
# Convertir a response schema (agregar info del usuario)
logs_response = []
for log in logs:
log_dict = {
"id": log.id,
"tenant_id": log.tenant_id,
"user_id": log.user_id,
"action": log.action,
"resource_type": log.resource_type,
"resource_id": log.resource_id,
"ip_address": str(log.ip_address) if log.ip_address else None,
"user_agent": log.user_agent,
"correlation_id": log.correlation_id,
"old_values": log.old_values,
"new_values": log.new_values,
"metadata": log.extra_metadata,
"created_at": log.created_at,
"action_display": log.action_display,
"user_email": None,
"user_name": None
}
# Agregar info del usuario si existe
if log.user:
log_dict["user_email"] = log.user.email
log_dict["user_name"] = log.user.full_name
log_dict["user_role"] = log.user.role.value if hasattr(log.user.role, 'value') else str(log.user.role)
logs_response.append(AuditLogResponse(**log_dict))
return AuditLogListResponse(
logs=logs_response,
total=total,
page=page,
per_page=per_page,
total_pages=total_pages
)
@router.get("/stats", response_model=AuditLogStats)
async def get_audit_stats(
current_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Obtener estadísticas de auditoría del tenant.
**Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR
**Retorna**: Estadísticas de actividad
"""
logger.info(
"Fetching audit stats",
user_id=str(current_user.id),
tenant_id=str(current_tenant.id)
)
now = datetime.utcnow()
# Total de acciones
total_query = select(func.count()).select_from(AuditLog).where(
AuditLog.tenant_id == current_tenant.id
)
total_result = await db.execute(total_query)
total_actions = total_result.scalar() or 0
# Acciones hoy (├║ltimas 24 horas)
today_start = now - timedelta(days=1)
today_query = select(func.count()).select_from(AuditLog).where(
and_(
AuditLog.tenant_id == current_tenant.id,
AuditLog.created_at >= today_start
)
)
today_result = await db.execute(today_query)
actions_today = today_result.scalar() or 0
# Acciones esta semana (últimos 7 días)
week_start = now - timedelta(days=7)
week_query = select(func.count()).select_from(AuditLog).where(
and_(
AuditLog.tenant_id == current_tenant.id,
AuditLog.created_at >= week_start
)
)
week_result = await db.execute(week_query)
actions_this_week = week_result.scalar() or 0
# Top 5 acciones más frecuentes
top_actions_query = select(
AuditLog.action,
func.count(AuditLog.id).label('count')
).where(
AuditLog.tenant_id == current_tenant.id
).group_by(
AuditLog.action
).order_by(
desc('count')
).limit(5)
top_actions_result = await db.execute(top_actions_query)
top_actions = {row.action: row.count for row in top_actions_result}
# Acciones por tipo de recurso
by_resource_query = select(
AuditLog.resource_type,
func.count(AuditLog.id).label('count')
).where(
AuditLog.tenant_id == current_tenant.id
).group_by(
AuditLog.resource_type
).order_by(
desc('count')
)
by_resource_result = await db.execute(by_resource_query)
by_resource_type = {row.resource_type: row.count for row in by_resource_result}
# Top usuarios (necesitamos hacer join - simplificado por ahora)
# En producción podrías hacer un join con users para obtener nombres
top_users = {} # Placeholder - implementar con join si es necesario
return AuditLogStats(
total_actions=total_actions,
actions_today=actions_today,
actions_this_week=actions_this_week,
top_actions=top_actions,
top_users=top_users,
by_resource_type=by_resource_type
)
@router.get("/{log_id}", response_model=AuditLogResponse)
async def get_audit_log_detail(
log_id: uuid.UUID,
current_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Obtener detalle de un audit log específico.
**Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR
**Retorna**: Detalle completo del audit log
"""
# Buscar el log
query = select(AuditLog).where(
and_(
AuditLog.id == log_id,
AuditLog.tenant_id == current_tenant.id
)
)
result = await db.execute(query)
log = result.scalar_one_or_none()
if not log:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Audit log {log_id} no encontrado"
)
# Convertir a response
log_dict = {
"id": log.id,
"tenant_id": log.tenant_id,
"user_id": log.user_id,
"action": log.action,
"resource_type": log.resource_type,
"resource_id": log.resource_id,
"ip_address": str(log.ip_address) if log.ip_address else None,
"user_agent": log.user_agent,
"correlation_id": log.correlation_id,
"old_values": log.old_values,
"new_values": log.new_values,
"metadata": log.extra_metadata,
"created_at": log.created_at,
"action_display": log.action_display,
"user_email": None,
"user_name": None
}
if log.user:
log_dict["user_email"] = log.user.email
log_dict["user_name"] = log.user.full_name
return AuditLogResponse(**log_dict)

View File

@@ -240,7 +240,7 @@ async def get_tickets(
"status": t.status.value, "status": t.status.value,
"priority": t.priority.value, "priority": t.priority.value,
"category_id": str(t.category_id) if t.category_id else None, "category_id": str(t.category_id) if t.category_id else None,
"affected_system_id": str(t.affected_system_id) if t.affected_system_id else None, # ✅ CORREGIDO "affected_system_id": str(t.affected_system_id) if t.affected_system_id else None,
"created_by": str(t.created_by), "created_by": str(t.created_by),
"assigned_to": str(t.assigned_to) if t.assigned_to else None, "assigned_to": str(t.assigned_to) if t.assigned_to else None,
"created_at": t.created_at, "created_at": t.created_at,

View File

@@ -6,7 +6,7 @@ Router principal para la API v1
from fastapi import APIRouter 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() api_router = APIRouter()
@@ -59,4 +59,11 @@ api_router.include_router(
client_profile.router, client_profile.router,
prefix="/client-profile", prefix="/client-profile",
tags=["client-profile"] tags=["client-profile"]
)
# Audit routes
api_router.include_router(
audit.router,
prefix="/audit",
tags=["audit"]
) )

View File

@@ -23,6 +23,8 @@ from app.models.user import User
from app.models.ticket import Ticket from app.models.ticket import Ticket
from app.models.comment import TicketComment from app.models.comment import TicketComment
from app.models.attachment import TicketAttachment 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.core.logging import setup_logging
from app.api.v1.router import api_router from app.api.v1.router import api_router

View File

@@ -8,6 +8,8 @@ from .system import System
from .category import Category from .category import Category
from .client_profile import ClientProfile from .client_profile import ClientProfile
from .attachment import TicketAttachment from .attachment import TicketAttachment
from .audit import AuditLog
from .refresh_token import RefreshToken
__all__ = [ __all__ = [
"User", "User",
@@ -17,5 +19,7 @@ __all__ = [
"System", "System",
"Category", "Category",
"ClientProfile", "ClientProfile",
"TicketAttachment" "TicketAttachment",
"AuditLog",
"RefreshToken"
] ]

148
backend/app/models/audit.py Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -78,6 +78,12 @@ class User(Base):
back_populates="assigned_to_user", back_populates="assigned_to_user",
foreign_keys="Ticket.assigned_to" 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 # Unique constraint por tenant
__table_args__ = ( __table_args__ = (

View File

@@ -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()
}

View File

@@ -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())

View File

@@ -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")

View File

@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "servicemanager-backend" name = "servicemanager-backend"
version = "1.5.1" version = "1.5.1.2"
description = "ServiceManagerWeb Backend - Mesa de Ayuda B2B" description = "ServiceManagerWeb Backend - Mesa de Ayuda B2B"
authors = [ authors = [
{name = "Aduanasoft", email = "dev@aduanasoft.com"} {name = "Aduanasoft", email = "dev@aduanasoft.com"}

View File

@@ -1,6 +1,6 @@
{ {
"name": "@servicemanager/client-frontend", "name": "@servicemanager/client-frontend",
"version": "1.5.1", "version": "1.5.1.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@servicemanager/internal-frontend", "name": "@servicemanager/internal-frontend",
"version": "1.5.1", "version": "1.5.1.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -0,0 +1,704 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/utils/api';
import { toast } from '$lib/stores/toast';
import Modal from '$lib/components/Modal.svelte';
// Estado de carga y datos
let logs = [];
let stats = null;
let users = [];
let isLoading = false;
let selectedLog = null;
let showDetailModal = false;
// Paginaci├│n
let currentPage = 1;
let totalPages = 1;
let totalLogs = 0;
const perPage = 20;
// Filtros
let filterUserId = '';
let filterAction = '';
let filterResourceType = '';
let filterDateFrom = '';
let filterDateTo = '';
let searchText = '';
// Contador de filtros activos
$: activeFiltersCount = [filterUserId, filterAction, filterResourceType, filterDateFrom, filterDateTo, searchText].filter(f => f && f.trim()).length;
// Tipos de acciones y recursos (extraídos de los logs)
let availableActions = new Set<string>();
let availableResourceTypes = new Set<string>();
/**
* Cargar estadísticas de auditoría
*/
async function loadStats() {
try {
stats = await api.get('/audit/stats');
} catch (e) {
console.error('Error cargando estadísticas:', e);
}
}
/**
* Cargar logs de auditoría con filtros
*/
async function loadLogs() {
isLoading = true;
try {
const params: any = {
page: currentPage,
per_page: perPage
};
if (filterUserId) params.user_id = filterUserId;
if (filterAction) params.action = filterAction;
if (filterResourceType) params.resource_type = filterResourceType;
if (filterDateFrom) params.date_from = filterDateFrom;
if (filterDateTo) params.date_to = filterDateTo;
if (searchText) params.search = searchText;
const response = await api.get('/audit/', params);
logs = response.logs;
totalLogs = response.total;
totalPages = response.total_pages;
currentPage = response.page;
// Extraer acciones y tipos de recursos ├║nicos para los selectores
logs.forEach((log: any) => {
availableActions.add(log.action);
availableResourceTypes.add(log.resource_type);
});
// Convertir Sets a Arrays para bind:value
availableActions = new Set(availableActions);
availableResourceTypes = new Set(availableResourceTypes);
} catch (e) {
toast.error('Error cargando logs: ' + (e.message || 'Error desconocido'));
} finally {
isLoading = false;
}
}
/**
* Cargar usuarios para el filtro
*/
async function loadUsers() {
try {
users = await api.get('/users/');
} catch (e) {
console.error('Error cargando usuarios:', e);
users = [];
}
}
/**
* Aplicar filtros y recargar desde página 1
*/
function applyFilters() {
currentPage = 1;
loadLogs();
}
/**
* Limpiar todos los filtros
*/
function clearFilters() {
filterUserId = '';
filterAction = '';
filterResourceType = '';
filterDateFrom = '';
filterDateTo = '';
searchText = '';
currentPage = 1;
loadLogs();
}
/**
* Cambiar página
*/
function goToPage(page: number) {
if (page >= 1 && page <= totalPages) {
currentPage = page;
loadLogs();
}
}
/**
* Ver detalle de un log
*/
function viewDetail(log: any) {
selectedLog = log;
showDetailModal = true;
}
/**
* Formatear fecha de manera amigable
*/
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleString('es-MX', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
/**
* Obtener color de badge seg├║n tipo de acci├│n
*/
function getActionColor(action: string): string {
if (action.includes('login')) return 'bg-green-100 text-green-800';
if (action.includes('logout')) return 'bg-gray-100 text-gray-800';
if (action.includes('create')) return 'bg-blue-100 text-blue-800';
if (action.includes('update')) return 'bg-yellow-100 text-yellow-800';
if (action.includes('delete')) return 'bg-red-100 text-red-800';
if (action.includes('assign')) return 'bg-purple-100 text-purple-800';
return 'bg-gray-100 text-gray-800';
}
/**
* Formatear acci├│n con informaci├│n del rol del usuario
*/
function formatActionWithRole(log: any): string {
const actionParts = log.action.split('.');
if (actionParts.length !== 2) return log.action;
const [resource, verb] = actionParts;
const verbMap: Record<string, string> = {
'login': 'inici├│ sesi├│n',
'logout': 'cerr├│ sesi├│n',
'create': 'cre├│',
'update': 'actualiz├│',
'delete': 'elimin├│',
'assign': 'asign├│',
'close': 'cerr├│',
'reopen': 'reabri├│'
};
const roleMap: Record<string, string> = {
'ADMIN': 'Administrador',
'SUPPORT_MANAGER': 'Gerente de Soporte',
'AGENT': 'Agente',
'AUDITOR': 'Auditor',
'CLIENT_ADMIN': 'Admin de Cliente',
'CLIENT_USER': 'Usuario de Cliente'
};
const verbText = verbMap[verb] || verb;
const resourceText = resource;
// Si hay rol de usuario, incluirlo
if (log.user_role) {
const roleText = roleMap[log.user_role] || log.user_role;
return `${verbText} ${resourceText} (${roleText})`;
}
return `${verbText} ${resourceText}`;
}
/**
* Inicializar datos
*/
onMount(() => {
loadStats();
loadUsers();
loadLogs();
});
</script>
<div class="px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="sm:flex sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold text-gray-900">Auditoría</h1>
<p class="mt-2 text-sm text-gray-700">
Registro completo de todas las acciones realizadas en el sistema
</p>
</div>
</div>
<!-- Estadísticas -->
{#if stats}
<div class="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Total de Acciones</dt>
<dd class="text-lg font-semibold text-gray-900">{stats.total_actions.toLocaleString()}</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Hoy</dt>
<dd class="text-lg font-semibold text-gray-900">{stats.actions_today}</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Esta Semana</dt>
<dd class="text-lg font-semibold text-gray-900">{stats.actions_this_week}</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Acción más Común</dt>
<dd class="text-sm font-semibold text-gray-900 truncate">
{#if stats.top_actions && Object.keys(stats.top_actions).length > 0}
{Object.keys(stats.top_actions)[0]}
{:else}
N/A
{/if}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
{/if}
<!-- Filtros -->
<div class="mt-6 bg-white shadow rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium text-gray-900">Filtros</h3>
{#if activeFiltersCount > 0}
<button
on:click={clearFilters}
class="text-sm text-primary-600 hover:text-primary-700 font-medium"
>
Limpiar ({activeFiltersCount})
</button>
{/if}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- B├║squeda de texto -->
<div>
<label for="search" class="block text-sm font-medium text-gray-700">Buscar</label>
<input
type="text"
id="search"
bind:value={searchText}
on:input={applyFilters}
placeholder="Buscar en acciones..."
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm"
/>
</div>
<!-- Filtro por usuario -->
<div>
<label for="user" class="block text-sm font-medium text-gray-700">Usuario</label>
<select
id="user"
bind:value={filterUserId}
on:change={applyFilters}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm"
>
<option value="">Todos los usuarios</option>
{#each users as user}
<option value={user.id}>{user.first_name} {user.last_name} ({user.email})</option>
{/each}
</select>
</div>
<!-- Filtro por acci├│n -->
<div>
<label for="action" class="block text-sm font-medium text-gray-700">Acci├│n</label>
<select
id="action"
bind:value={filterAction}
on:change={applyFilters}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm"
>
<option value="">Todas las acciones</option>
{#each Array.from(availableActions).sort() as action}
<option value={action}>{action}</option>
{/each}
</select>
</div>
<!-- Filtro por tipo de recurso -->
<div>
<label for="resource-type" class="block text-sm font-medium text-gray-700">Tipo de Recurso</label>
<select
id="resource-type"
bind:value={filterResourceType}
on:change={applyFilters}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm"
>
<option value="">Todos los tipos</option>
{#each Array.from(availableResourceTypes).sort() as resourceType}
<option value={resourceType}>{resourceType}</option>
{/each}
</select>
</div>
<!-- Fecha desde -->
<div>
<label for="date-from" class="block text-sm font-medium text-gray-700">Desde</label>
<input
type="date"
id="date-from"
bind:value={filterDateFrom}
on:change={applyFilters}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm"
/>
</div>
<!-- Fecha hasta -->
<div>
<label for="date-to" class="block text-sm font-medium text-gray-700">Hasta</label>
<input
type="date"
id="date-to"
bind:value={filterDateTo}
on:change={applyFilters}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm"
/>
</div>
</div>
</div>
<!-- Tabla de Logs -->
<div class="mt-6 bg-white shadow rounded-lg overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">
Logs de Auditoría
<span class="text-sm text-gray-500 font-normal">({totalLogs} registros)</span>
</h3>
</div>
{#if isLoading}
<div class="flex items-center justify-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
{:else if logs.length === 0}
<div class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No hay logs</h3>
<p class="mt-1 text-sm text-gray-500">No se encontraron registros con los filtros aplicados.</p>
</div>
{:else}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Fecha/Hora
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Usuario
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Acci├│n
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Recurso
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
IP
</th>
<th scope="col" class="relative px-6 py-3">
<span class="sr-only">Acciones</span>
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each logs as log (log.id)}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatDate(log.created_at)}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{#if log.user_email}
<div>
<div class="font-medium text-gray-900">{log.user_name || 'N/A'}</div>
<div class="text-gray-500">{log.user_email}</div>
{#if log.user_role}
<div class="text-xs text-gray-400 mt-1">
{log.user_role === 'ADMIN' ? 'Administrador' :
log.user_role === 'SUPPORT_MANAGER' ? 'Gerente de Soporte' :
log.user_role === 'AGENT' ? 'Agente' :
log.user_role === 'AUDITOR' ? 'Auditor' :
log.user_role === 'CLIENT_ADMIN' ? 'Admin de Cliente' :
log.user_role === 'CLIENT_USER' ? 'Usuario de Cliente' :
log.user_role}
</div>
{/if}
</div>
{:else}
<span class="text-gray-500 italic">Sistema</span>
{/if}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {getActionColor(log.action)}">
{formatActionWithRole(log)}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<div>
<div class="font-medium">{log.resource_type}</div>
{#if log.resource_id}
<div class="text-gray-500 text-xs truncate max-w-xs">{log.resource_id}</div>
{/if}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{log.ip_address || 'N/A'}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
on:click={() => viewDetail(log)}
class="text-primary-600 hover:text-primary-900"
>
Ver detalle
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Paginaci├│n -->
{#if totalPages > 1}
<div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div class="flex-1 flex justify-between sm:hidden">
<button
on:click={() => goToPage(currentPage - 1)}
disabled={currentPage === 1}
class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Anterior
</button>
<button
on:click={() => goToPage(currentPage + 1)}
disabled={currentPage === totalPages}
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Siguiente
</button>
</div>
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700">
Mostrando
<span class="font-medium">{(currentPage - 1) * perPage + 1}</span>
a
<span class="font-medium">{Math.min(currentPage * perPage, totalLogs)}</span>
de
<span class="font-medium">{totalLogs}</span>
resultados
</p>
</div>
<div>
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
<button
on:click={() => goToPage(currentPage - 1)}
disabled={currentPage === 1}
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span class="sr-only">Anterior</span>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</button>
{#each Array.from({length: Math.min(5, totalPages)}, (_, i) => i + Math.max(1, Math.min(currentPage - 2, totalPages - 4))) as page}
<button
on:click={() => goToPage(page)}
class="relative inline-flex items-center px-4 py-2 border text-sm font-medium {page === currentPage ? 'z-10 bg-primary-50 border-primary-500 text-primary-600' : 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'}"
>
{page}
</button>
{/each}
<button
on:click={() => goToPage(currentPage + 1)}
disabled={currentPage === totalPages}
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span class="sr-only">Siguiente</span>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
</button>
</nav>
</div>
</div>
</div>
{/if}
{/if}
</div>
</div>
<!-- Modal de Detalle -->
{#if showDetailModal && selectedLog}
<Modal title="Detalle del Log de Auditoría" on:close={() => showDetailModal = false}>
<div class="space-y-4">
<!-- Informaci├│n General -->
<div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Informaci├│n General</h4>
<dl class="grid grid-cols-2 gap-3 text-sm">
<div>
<dt class="font-medium text-gray-500">ID:</dt>
<dd class="text-gray-900 font-mono text-xs">{selectedLog.id}</dd>
</div>
<div>
<dt class="font-medium text-gray-500">Fecha:</dt>
<dd class="text-gray-900">{formatDate(selectedLog.created_at)}</dd>
</div>
<div>
<dt class="font-medium text-gray-500">Usuario:</dt>
<dd class="text-gray-900">{selectedLog.user_name || 'Sistema'}</dd>
</div>
<div>
<dt class="font-medium text-gray-500">Email:</dt>
<dd class="text-gray-900">{selectedLog.user_email || 'N/A'}</dd>
</div>
{#if selectedLog.user_role}
<div>
<dt class="font-medium text-gray-500">Rol:</dt>
<dd class="text-gray-900">
{selectedLog.user_role === 'ADMIN' ? 'Administrador' :
selectedLog.user_role === 'SUPPORT_MANAGER' ? 'Gerente de Soporte' :
selectedLog.user_role === 'AGENT' ? 'Agente' :
selectedLog.user_role === 'AUDITOR' ? 'Auditor' :
selectedLog.user_role === 'CLIENT_ADMIN' ? 'Admin de Cliente' :
selectedLog.user_role === 'CLIENT_USER' ? 'Usuario de Cliente' :
selectedLog.user_role}
</dd>
</div>
{/if}
<div>
<dt class="font-medium text-gray-500">IP:</dt>
<dd class="text-gray-900">{selectedLog.ip_address || 'N/A'}</dd>
</div>
<div>
<dt class="font-medium text-gray-500">Correlation ID:</dt>
<dd class="text-gray-900 font-mono text-xs">{selectedLog.correlation_id || 'N/A'}</dd>
</div>
</dl>
</div>
<!-- Acci├│n -->
<div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Acci├│n</h4>
<div class="bg-gray-50 rounded-lg p-3">
<span class="px-2 py-1 text-xs font-semibold rounded-full {getActionColor(selectedLog.action)}">
{selectedLog.action}
</span>
<p class="mt-2 text-sm text-gray-700">{formatActionWithRole(selectedLog)}</p>
</div>
</div>
<!-- Recurso -->
<div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Recurso Afectado</h4>
<div class="bg-gray-50 rounded-lg p-3 text-sm">
<div><span class="font-medium">Tipo:</span> {selectedLog.resource_type}</div>
{#if selectedLog.resource_id}
<div class="mt-1"><span class="font-medium">ID:</span> <code class="text-xs">{selectedLog.resource_id}</code></div>
{/if}
</div>
</div>
<!-- User Agent -->
{#if selectedLog.user_agent}
<div>
<h4 class="text-sm font-medium text-gray-900 mb-2">User Agent</h4>
<div class="bg-gray-50 rounded-lg p-3 text-xs font-mono text-gray-600 break-all">
{selectedLog.user_agent}
</div>
</div>
{/if}
<!-- Valores Anteriores -->
{#if selectedLog.old_values}
<div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Valores Anteriores</h4>
<pre class="bg-gray-50 rounded-lg p-3 text-xs font-mono text-gray-600 overflow-auto max-h-40">{JSON.stringify(selectedLog.old_values, null, 2)}</pre>
</div>
{/if}
<!-- Valores Nuevos -->
{#if selectedLog.new_values}
<div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Valores Nuevos</h4>
<pre class="bg-gray-50 rounded-lg p-3 text-xs font-mono text-gray-600 overflow-auto max-h-40">{JSON.stringify(selectedLog.new_values, null, 2)}</pre>
</div>
{/if}
<!-- Metadata -->
{#if selectedLog.metadata}
<div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Metadata Adicional</h4>
<pre class="bg-gray-50 rounded-lg p-3 text-xs font-mono text-gray-600 overflow-auto max-h-40">{JSON.stringify(selectedLog.metadata, null, 2)}</pre>
</div>
{/if}
</div>
<div slot="footer" class="flex justify-end">
<button
on:click={() => showDetailModal = false}
class="px-4 py-2 bg-white border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Cerrar
</button>
</div>
</Modal>
{/if}