Compare commits

..

1 Commits

Author SHA1 Message Date
96cd09476c Auditoria funcionando 2026-02-12 12:23:11 -07:00
34 changed files with 772 additions and 2174 deletions

View File

@@ -1,7 +1,7 @@
"""
"""
Audit Schemas - ServiceManagerWeb
Schemas Pydantic para endpoints de auditoría
Schemas Pydantic para endpoints de auditoría
"""
from pydantic import BaseModel, Field, UUID4
@@ -11,7 +11,7 @@ from datetime import datetime
class AuditLogBase(BaseModel):
"""Schema base para audit logs."""
action: str = Field(..., description="Acci├│n realizada (ej: ticket.create)")
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")
@@ -21,18 +21,18 @@ class AuditLogResponse(AuditLogBase):
"""
Schema de respuesta para audit logs.
Incluye toda la informaci├│n del log con datos del usuario.
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)
# 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
# Contexto de la acción
ip_address: Optional[str]
user_agent: Optional[str]
correlation_id: Optional[UUID4]
@@ -45,74 +45,25 @@ class AuditLogResponse(AuditLogBase):
created_at: datetime
# Display friendly
action_display: str = Field(description="Acci├│n en formato amigable")
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.
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")
# 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")
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")
@@ -120,26 +71,25 @@ class AuditLogFilters(BaseModel):
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")
# Búsqueda
search: Optional[str] = Field(None, description="Búsqueda en acciones o recursos")
class AuditLogStats(BaseModel):
"""
Estadísticas de auditoría.
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)")
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_actions: Dict[str, int] = Field(description="Acciones más frecuentes")
# Top usuarios
top_users: Dict[str, int] = Field(description="Usuarios más activos")
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")
@@ -151,9 +101,9 @@ class AuditLogListResponse(BaseModel):
"""
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")
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

@@ -1,7 +1,7 @@
"""
"""
Audit Endpoints - ServiceManagerWeb
Endpoints para consulta de logs de auditoría.
Endpoints para consulta de logs de auditoría.
Solo accesible por roles: ADMIN, SUPPORT_MANAGER, AUDITOR
"""
@@ -19,16 +19,11 @@ 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
AuditLogStats
)
router = APIRouter()
@@ -39,14 +34,14 @@ 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.
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"
detail="Solo usuarios con rol ADMIN, SUPPORT_MANAGER o AUDITOR pueden acceder a logs de auditoría"
)
return current_user
@@ -54,38 +49,36 @@ def require_auditor_role(current_user: User = Depends(get_current_user)) -> 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"),
# 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"),
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
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.
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")
- `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
- `resource_id`: ID de recurso específico
- `date_from`, `date_to`: Rango de fechas
- `search`: B├║squeda en acciones
- `search`: Búsqueda en acciones
**Retorna**: Lista paginada de audit logs
"""
@@ -97,28 +90,17 @@ async def get_audit_logs(
"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
"page": page
}
)
# 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)
# 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:
@@ -137,15 +119,16 @@ async def get_audit_logs(
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)
# 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
# Búsqueda en action
search_filter = AuditLog.action.ilike(f"%{search}%")
query = query.where(search_filter)
# Ordenar por fecha descendente (más recientes primero)
# Ordenar por fecha descendente (más recientes primero)
query = query.order_by(desc(AuditLog.created_at))
# Contar total antes de paginar
@@ -153,7 +136,7 @@ async def get_audit_logs(
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Aplicar paginaci├│n
# Aplicar paginación
offset = (page - 1) * per_page
query = query.offset(offset).limit(per_page)
@@ -161,7 +144,7 @@ async def get_audit_logs(
result = await db.execute(query)
logs = result.scalars().all()
# Calcular total de páginas
# Calcular total de páginas
total_pages = (total + per_page - 1) // per_page
# Convertir a response schema (agregar info del usuario)
@@ -205,68 +188,61 @@ async def get_audit_logs(
@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).
Obtener estadísticas de auditoría del tenant.
**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
tenant_id=str(current_tenant.id)
)
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_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)
# 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
and_(
AuditLog.tenant_id == current_tenant.id,
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)
# 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
and_(
AuditLog.tenant_id == current_tenant.id,
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 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(
).where(
AuditLog.tenant_id == current_tenant.id
).group_by(
AuditLog.action
).order_by(
desc('count')
@@ -279,10 +255,9 @@ async def get_audit_stats(
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(
).where(
AuditLog.tenant_id == current_tenant.id
).group_by(
AuditLog.resource_type
).order_by(
desc('count')
@@ -291,48 +266,14 @@ async def get_audit_stats(
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
# 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,
critical_actions_today=critical_actions_today,
top_actions=top_actions,
top_users=top_users,
by_resource_type=by_resource_type
@@ -347,7 +288,7 @@ async def get_audit_log_detail(
db: AsyncSession = Depends(get_db)
):
"""
Obtener detalle de un audit log específico.
Obtener detalle de un audit log específico.
**Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR
@@ -395,321 +336,3 @@ async def get_audit_log_detail(
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.contains('"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
)

View File

@@ -4,13 +4,14 @@ Authentication Endpoints - ServiceManagerWeb
Endpoints para autenticación y autorización
"""
from fastapi import APIRouter, HTTPException, status, Depends
from fastapi import APIRouter, HTTPException, status, Depends, Request
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from pydantic import BaseModel, EmailStr
from typing import Optional
from datetime import datetime # Para actualizar last_login
import structlog
from app.core.database import get_db
@@ -18,7 +19,8 @@ from app.core.security import security
from app.core.config import get_settings
from app.models.user import User
from app.models.tenant import Tenant
from app.services.audit_service import AuditService
from app.services.audit_service import AuditService # Servicio de auditoría
from app.services.token_service import TokenService # Servicio de tokens
router = APIRouter()
logger = structlog.get_logger(__name__)
@@ -68,13 +70,17 @@ class TokenResponse(BaseModel):
@router.post("/login", response_model=LoginResponse)
async def login(
login_data: LoginRequest,
request: Request, # Agregar Request para capturar IP y user agent
db: AsyncSession = Depends(get_db)
):
"""
Authenticate user and return access/refresh tokens.
**Auditoría**: Registra login exitoso y fallido en audit_logs
Args:
login_data: Login credentials
request: FastAPI Request (para auditoría)
db: Database session
Returns:
@@ -101,21 +107,22 @@ async def login(
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))
# 🔍 AUDITORÍA: Registrar intento fallido
if user: # Solo si el usuario existe (password incorrecto)
await AuditService.log(
db=db,
tenant_id=user.tenant_id,
user_id=None, # NULL porque aún no autenticado
action="user.login_failed",
resource_type="user",
resource_id=user.id,
metadata={
"email": login_data.email,
"reason": "invalid_password"
},
request=request
)
await db.commit() # Commit del audit log
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -128,11 +135,31 @@ async def login(
"Login failed - user inactive",
email=login_data.email
)
# 🔍 AUDITORÍA: Registrar intento con usuario inactivo
await AuditService.log(
db=db,
tenant_id=user.tenant_id,
user_id=None,
action="user.login_failed",
resource_type="user",
resource_id=user.id,
metadata={
"email": login_data.email,
"reason": "user_inactive"
},
request=request
)
await db.commit()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Usuario inactivo"
)
# Actualizar último login
user.last_login = datetime.utcnow()
# Create tokens
token_data = {
"sub": str(user.id),
@@ -144,20 +171,61 @@ 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))
# Extraer información del dispositivo para rastreo
user_agent = request.headers.get("user-agent")
client_ip = request.client.host if request.client else None
# device_id: El frontend puede enviarlo en el futuro como header
device_id = request.headers.get("x-device-id") # Opcional
# device_name: Simplificado del user-agent (ej: "Chrome en Windows")
device_name = None
if user_agent:
# Parseo básico para obtener un nombre legible
if "Chrome" in user_agent:
device_name = "Chrome"
elif "Firefox" in user_agent:
device_name = "Firefox"
elif "Safari" in user_agent:
device_name = "Safari"
elif "Edge" in user_agent:
device_name = "Edge"
else:
device_name = "Unknown Browser"
# Agregar OS
if "Windows" in user_agent:
device_name += " en Windows"
elif "Macintosh" in user_agent or "Mac OS" in user_agent:
device_name += " en macOS"
elif "Linux" in user_agent:
device_name += " en Linux"
elif "Android" in user_agent:
device_name += " en Android"
elif "iPhone" in user_agent or "iPad" in user_agent:
device_name += " en iOS"
# Persistir refresh token en BD con tracking completo
await TokenService.create_refresh_token(
db=db,
user=user,
refresh_token=refresh_token,
device_id=device_id,
device_name=device_name,
user_agent=user_agent,
ip_address=client_ip
)
# 🔍 AUDITORÍA: Registrar login exitoso
await AuditService.log_login(
db=db,
user=user,
request=request,
success=True
)
# Commit de todos los cambios (last_login + refresh token + audit log)
await db.commit()
logger.info(
"Login successful",
@@ -192,6 +260,9 @@ async def refresh_token(
"""
Refresh access token using refresh token.
Verifica el JWT, valida en BD que no esté revocado/expirado,
actualiza estadísticas de uso y genera nuevo access token.
Args:
refresh_data: Refresh token data
db: Database session
@@ -204,18 +275,26 @@ async def refresh_token(
"""
logger.info("Token refresh attempt")
# Verify refresh token
# 1. Verify refresh token JWT signature
payload = security.verify_token(refresh_data.refresh_token)
if not payload or payload.get("type") != "refresh":
logger.warning("Token refresh failed - invalid token")
logger.warning("Token refresh failed - invalid JWT")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
# TODO: Check if refresh token exists in database and is not revoked
# 2. Verificar que el token exista en BD y no esté revocado/expirado
# También actualiza last_used_at y usage_count automáticamente
db_token = await TokenService.verify_refresh_token(db, refresh_data.refresh_token)
if not db_token:
logger.warning("Token refresh failed - token not found, revoked or expired")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or revoked refresh token"
)
# Create new access token
# 3. Create new access token
token_data = {
"sub": payload["sub"],
"email": payload["email"],
@@ -225,7 +304,15 @@ async def refresh_token(
access_token = security.create_access_token(token_data)
logger.info("Token refresh successful", user_id=payload["sub"])
# 4. Commit actualización de estadísticas de uso
await db.commit()
logger.info(
"Token refresh successful",
user_id=payload["sub"],
token_id=str(db_token.id),
usage_count=db_token.usage_count
)
return TokenResponse(
access_token=access_token,
@@ -239,14 +326,17 @@ async def logout(
db: AsyncSession = Depends(get_db)
):
"""
Logout user and revoke refresh token.
Logout user and revoke all refresh tokens.
Revoca todos los tokens del usuario (logout en todos los dispositivos)
y registra quién ejecutó la revocación para auditoría.
Args:
token: Access token
db: Database session
Returns:
Success message
Success message with count of revoked tokens
"""
logger.info("Logout attempt")
@@ -258,30 +348,31 @@ async def logout(
detail="Invalid token"
)
# 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")}
# Extraer user_id del token
user_id_str = payload.get("sub")
if not user_id_str:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload"
)
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"])
user_id = uuid.UUID(user_id_str)
return {"message": "Successfully logged out"}
# Revocar todos los tokens del usuario con auditoría de quién lo revocó
count = await TokenService.revoke_all_user_tokens(
db=db,
user_id=user_id,
revoked_by_user_id=user_id # El usuario se revoca a sí mismo
)
await db.commit()
logger.info("Logout successful", user_id=str(user_id), tokens_revoked=count)
return {
"message": "Successfully logged out from all devices",
"tokens_revoked": count
}
@router.get("/me")

View File

@@ -21,7 +21,6 @@ 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()
@@ -144,27 +143,6 @@ async def create_ticket(
await db.commit()
await db.refresh(db_ticket)
# Registrar creación en auditoría
try:
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="ticket.create",
resource_type="ticket",
resource_id=db_ticket.id,
new_values={
"ticket_number": db_ticket.ticket_number,
"subject": db_ticket.subject,
"priority": db_ticket.priority.value,
"status": db_ticket.status.value
}
)
await db.commit()
except Exception as e:
# No fallar si falla el audit log
pass
# ✅ Éxito - retornar ticket creado
return {
"id": str(db_ticket.id),
@@ -262,7 +240,7 @@ async def get_tickets(
"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,
"affected_system_id": str(t.affected_system_id) if t.affected_system_id else None, # ✅ CORREGIDO
"created_by": str(t.created_by),
"assigned_to": str(t.assigned_to) if t.assigned_to else None,
"created_at": t.created_at,
@@ -523,15 +501,6 @@ 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)
@@ -550,34 +519,6 @@ 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),
@@ -846,33 +787,9 @@ 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"}
# ===================================

View File

@@ -9,7 +9,6 @@ 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()
@@ -148,28 +147,6 @@ 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
@@ -237,15 +214,6 @@ 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:
@@ -271,32 +239,6 @@ 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
@@ -364,28 +306,6 @@ 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

View File

@@ -23,6 +23,13 @@ api_router.include_router(
tags=["authentication"]
)
# Audit routes (antes de otros para logging)
api_router.include_router(
audit.router,
prefix="/audit",
tags=["audit"]
)
api_router.include_router(
tenants.router,
prefix="/tenants",
@@ -59,11 +66,4 @@ 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"]
)

View File

@@ -27,7 +27,6 @@ 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

View File

@@ -23,8 +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.models.audit import AuditLog # Nuevo modelo para auditoría
from app.models.refresh_token import RefreshToken # Modelo para refresh tokens
from app.core.logging import setup_logging
from app.api.v1.router import api_router
@@ -56,7 +56,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="ServiceManagerWeb API",
description="Mesa de Ayuda B2B multi-tenant para Aduanasoft",
version=settings.APP_VERSION,
version=settings.API_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,
@@ -163,8 +163,7 @@ async def health_check():
return {
"status": "healthy",
"service": "ServiceManagerWeb API",
"version": settings.APP_VERSION,
"api_version": settings.API_VERSION,
"version": settings.API_VERSION,
"environment": settings.ENVIRONMENT
}
@@ -175,8 +174,7 @@ async def root():
"""Endpoint raíz con información básica."""
return {
"service": "ServiceManagerWeb API",
"version": settings.APP_VERSION,
"api_version": settings.API_VERSION,
"version": settings.API_VERSION,
"docs": f"/{settings.API_VERSION}/docs",
"environment": settings.ENVIRONMENT
}

View File

@@ -1,7 +1,7 @@
"""
"""
Audit Log Model - ServiceManagerWeb
Modelo para bitácora de auditoría y compliance.
Modelo para bitácora de auditoría y compliance.
Registra todas las acciones importantes del sistema.
"""
@@ -21,15 +21,15 @@ if TYPE_CHECKING:
class AuditLog(Base):
"""
Bitácora de auditoría para tracking completo de acciones.
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)
- 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"
@@ -42,7 +42,7 @@ class AuditLog(Base):
index=True
)
# Usuario que ejecut├│ la acci├│n (NULL = acci├│n del sistema)
# 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"),
@@ -50,7 +50,7 @@ class AuditLog(Base):
index=True
)
# Acci├│n realizada (ej: "user.login", "ticket.create", "ticket.assign")
# Acción realizada (ej: "user.login", "ticket.create", "ticket.assign")
action: Mapped[str] = mapped_column(
String(100),
nullable=False,
@@ -87,14 +87,14 @@ class AuditLog(Base):
nullable=True
)
# Valores despu├®s del cambio (JSON)
# 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'
# 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,
@@ -113,14 +113,14 @@ class AuditLog(Base):
tenant: Mapped["Tenant"] = relationship("Tenant", foreign_keys=[tenant_id])
user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[user_id])
# Índices compuestos para queries comunes
# Í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
# Configuración del mapper: excluir updated_at porque audit logs son inmutables
__mapper_args__ = {
"exclude_properties": ["updated_at"]
}
@@ -130,19 +130,19 @@ class AuditLog(Base):
@property
def action_display(self) -> str:
"""Formato amigable de la acci├│n."""
"""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├│'
'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

@@ -1,7 +1,7 @@
"""
"""
Refresh Token Model - ServiceManagerWeb
Modelo para persistencia de refresh tokens con revocaci├│n y tracking.
Modelo para persistencia de refresh tokens con revocación y tracking.
"""
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Index, Integer
@@ -19,16 +19,16 @@ if TYPE_CHECKING:
class RefreshToken(Base):
"""
Refresh Token persistente para gesti├│n de sesiones.
Refresh Token persistente para gestión de sesiones.
Almacena refresh tokens con informaci├│n de dispositivo y permite
revocaci├│n para mejorar la seguridad.
Almacena refresh tokens con información de dispositivo y permite
revocación para mejorar la seguridad.
Características:
Características:
- Token hasheado (no se guarda en texto plano)
- Device fingerprinting
- Revocaci├│n individual con tracking
- Auto-expiraci├│n
- Revocación individual con tracking
- Auto-expiración
- Tracking de IP y uso
"""
@@ -72,14 +72,14 @@ class RefreshToken(Base):
nullable=True
)
# Expiraci├│n del token
# Expiración del token
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
index=True
)
# Estado de revocaci├│n
# Estado de revocación
revoked: Mapped[bool] = mapped_column(
Boolean,
default=False,
@@ -125,11 +125,11 @@ class RefreshToken(Base):
server_default="NOW()"
)
# Relaci├│n con usuario
# 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
# Índices compuestos
__table_args__ = (
Index('idx_refresh_tokens_user_expires', 'user_id', 'expires_at'),
)
@@ -140,10 +140,10 @@ class RefreshToken(Base):
@property
def is_valid(self) -> bool:
"""
Verificar si el token es válido.
Verificar si el token es válido.
Un token es válido si:
- No está revocado
Un token es válido si:
- No está revocado
- No ha expirado
"""
return not self.revoked and self.expires_at > datetime.utcnow()
@@ -158,7 +158,7 @@ class RefreshToken(Base):
Marcar el token como revocado.
Args:
revoked_by: ID del usuario que revoc├│ el token
revoked_by: ID del usuario que revocó el token
"""
self.revoked = True
self.revoked_at = datetime.utcnow()

View File

@@ -78,11 +78,12 @@ class User(Base):
back_populates="assigned_to_user",
foreign_keys="Ticket.assigned_to"
)
# Refresh tokens: especificar user_id como FK (hay 2 FKs: user_id y revoked_by)
refresh_tokens: Mapped[List["RefreshToken"]] = relationship(
"RefreshToken",
back_populates="user",
foreign_keys="RefreshToken.user_id",
cascade="all, delete-orphan"
cascade="all, delete-orphan",
foreign_keys="[RefreshToken.user_id]"
)
# Unique constraint por tenant

View File

@@ -1,8 +1,8 @@
"""
"""
Audit Service - ServiceManagerWeb
Funciones helper para facilitar el registro de auditoría.
Simplifica el proceso de logging en toda la aplicaci├│n.
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
@@ -19,9 +19,9 @@ logger = structlog.get_logger(__name__)
class AuditService:
"""
Servicio centralizado para registro de auditoría.
Servicio centralizado para registro de auditoría.
Uso básico:
Uso básico:
await AuditService.log(
db=db,
tenant_id=tenant.id,
@@ -47,25 +47,25 @@ class AuditService:
request: Optional[Request] = None
) -> AuditLog:
"""
Registra una acción en la bitácora de auditoría.
Registra una acción en la bitácora de auditoría.
Args:
db: Sesi├│n de base de datos
db: Sesión de base de datos
tenant_id: ID del tenant
action: Acci├│n realizada (formato: "recurso.verbo")
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)
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)
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
# Extraer información del request si está disponible
ip_address = None
user_agent = None
correlation_id = None
@@ -81,7 +81,7 @@ class AuditService:
# Correlation ID (si existe en el request state)
correlation_id = getattr(request.state, "correlation_id", None)
# Crear registro de auditoría
# Crear registro de auditoría
audit_log = AuditLog(
tenant_id=tenant_id,
user_id=user_id,
@@ -97,7 +97,7 @@ class AuditService:
)
db.add(audit_log)
await db.flush() # No commit, se hará con la transacción principal
await db.flush() # No commit, se hará con la transacción principal
# Log estructurado para debugging
logger.info(
@@ -122,8 +122,8 @@ class AuditService:
Registra un intento de login.
Args:
db: Sesi├│n de base de datos
user: Usuario que intent├│ loguearse
db: Sesión de base de datos
user: Usuario que intentó loguearse
request: Request de FastAPI
success: Si el login fue exitoso
@@ -154,8 +154,8 @@ class AuditService:
Registra un logout.
Args:
db: Sesi├│n de base de datos
user: Usuario que cerr├│ sesi├│n
db: Sesión de base de datos
user: Usuario que cerró sesión
request: Request de FastAPI
Returns:
@@ -182,12 +182,12 @@ class AuditService:
request: Optional[Request] = None
) -> AuditLog:
"""
Registra la creaci├│n de un recurso.
Registra la creación de un recurso.
Args:
db: Sesi├│n de base de datos
db: Sesión de base de datos
tenant_id: ID del tenant
user_id: ID del usuario que cre├│ el recurso
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
@@ -219,12 +219,12 @@ class AuditService:
request: Optional[Request] = None
) -> AuditLog:
"""
Registra la actualizaci├│n de un recurso.
Registra la actualización de un recurso.
Args:
db: Sesi├│n de base de datos
db: Sesión de base de datos
tenant_id: ID del tenant
user_id: ID del usuario que actualiz├│
user_id: ID del usuario que actualizó
resource_type: Tipo de recurso
resource_id: ID del recurso
old_values: Valores anteriores
@@ -257,12 +257,12 @@ class AuditService:
request: Optional[Request] = None
) -> AuditLog:
"""
Registra la eliminaci├│n de un recurso.
Registra la eliminación de un recurso.
Args:
db: Sesi├│n de base de datos
db: Sesión de base de datos
tenant_id: ID del tenant
user_id: ID del usuario que elimin├│
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

View File

@@ -1,7 +1,7 @@
"""
"""
Token Service - ServiceManagerWeb
Servicio para gesti├│n de refresh tokens persistentes.
Servicio para gestión de refresh tokens persistentes.
"""
from sqlalchemy.ext.asyncio import AsyncSession
@@ -21,9 +21,9 @@ settings = get_settings()
class TokenService:
"""
Servicio para gesti├│n de refresh tokens.
Servicio para gestión de refresh tokens.
Proporciona m├®todos para crear, validar, revocar y limpiar
Proporciona métodos para crear, validar, revocar y limpiar
refresh tokens persistentes.
NOTA: Los tokens se almacenan directamente en BD (no hash)
@@ -44,10 +44,10 @@ class TokenService:
Crear y persistir un refresh token.
Args:
db: Sesi├│n de base de datos
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_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
@@ -55,7 +55,7 @@ class TokenService:
Returns:
RefreshToken creado
"""
# Calcular expiraci├│n
# Calcular expiración
expires_at = datetime.utcnow() + timedelta(
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
)
@@ -92,16 +92,16 @@ class TokenService:
refresh_token: str
) -> Optional[RefreshToken]:
"""
Verificar que el refresh token exista y sea válido.
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
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
RefreshToken si es válido, None si no existe o está revocado/expirado
"""
# Buscar token directamente en BD (sin hash)
query = select(RefreshToken).where(
@@ -114,7 +114,7 @@ class TokenService:
logger.warning("Refresh token not found in database")
return None
# Verificar si es válido (usa property is_valid del modelo)
# Verificar si es válido (usa property is_valid del modelo)
if not db_token.is_valid:
logger.warning(
"Invalid refresh token",
@@ -124,7 +124,7 @@ class TokenService:
)
return None
# Actualizar estadísticas de uso
# Actualizar estadísticas de uso
db_token.track_usage()
await db.flush()
@@ -142,15 +142,15 @@ class TokenService:
revoked_by_user_id: Optional[uuid.UUID] = None
) -> bool:
"""
Revocar un refresh token específico.
Revocar un refresh token específico.
Args:
db: Sesi├│n de base de datos
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)
revoked_by_user_id: ID del usuario que revoca (para auditoría)
Returns:
True si se revoc├│, False si no se encontr├│
True si se revocó, False si no se encontró
"""
# Buscar token directamente (sin hash)
query = select(RefreshToken).where(
@@ -163,7 +163,7 @@ class TokenService:
logger.warning("Refresh token not found for revocation")
return False
# Revocar usando m├®todo del modelo
# Revocar usando método del modelo
db_token.revoke(revoked_by=revoked_by_user_id)
await db.flush()
@@ -183,15 +183,15 @@ class TokenService:
"""
Revocar todos los tokens activos de un usuario.
Útil para logout en todos los dispositivos.
Útil para logout en todos los dispositivos.
Args:
db: Sesi├│n de base de datos
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)
revoked_by_user_id: ID del usuario que ejecuta la revocación (para auditoría)
Returns:
N├║mero de tokens revocados
Número de tokens revocados
"""
# Buscar todos los tokens activos del usuario
query = select(RefreshToken).where(
@@ -226,12 +226,12 @@ class TokenService:
Tarea de mantenimiento para limpiar tokens antiguos.
Args:
db: Sesi├│n de base de datos
db: Sesión de base de datos
Returns:
N├║mero de tokens eliminados
Número de tokens eliminados
"""
# Eliminar tokens expirados hace más de 7 días
# Eliminar tokens expirados hace más de 7 días
cutoff_date = datetime.utcnow() - timedelta(days=7)
query = delete(RefreshToken).where(
@@ -254,7 +254,7 @@ class TokenService:
Obtener todos los tokens activos de un usuario.
Args:
db: Sesi├│n de base de datos
db: Sesión de base de datos
user_id: ID del usuario
Returns:

View File

@@ -1,67 +0,0 @@
"""
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())

View File

@@ -1,11 +1,11 @@
"""add_audit_logs_table
"""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 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
@@ -23,8 +23,8 @@ 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.
La tabla fue creada por schema.sql, esta migración solo verifica
que exista y está disponible para usar.
"""
from sqlalchemy import inspect
@@ -33,10 +33,10 @@ def upgrade():
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")
print(" Tabla audit_logs encontrada (creada por schema.sql)")
print(" Modelo AuditLog registrado en Alembic")
# Verificar que tenga los índices necesarios
# Verificar que tenga los índices necesarios
existing_indexes = [idx['name'] for idx in inspector.get_indexes('audit_logs')]
missing_indexes = []
@@ -53,17 +53,17 @@ def upgrade():
missing_indexes.append(idx)
if missing_indexes:
print(f"ÔÜá´©Å ├ìndices faltantes: {', '.join(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")
print(" Todos los índices necesarios están presentes")
else:
print("ÔÜá´©Å La tabla audit_logs NO existe")
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
# 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."
@@ -76,6 +76,6 @@ def downgrade():
Solo des-registrar de Alembic.
"""
print("Ôä╣´©Å Tabla audit_logs NO ser├í eliminada (creada por schema.sql)")
print(" Modelo AuditLog des-registrado 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]
name = "servicemanager-backend"
version = "1.6.0"
version = "1.5.1"
description = "ServiceManagerWeb Backend - Mesa de Ayuda B2B"
authors = [
{name = "Aduanasoft", email = "dev@aduanasoft.com"}

View File

@@ -369,14 +369,10 @@ CREATE TABLE audit_logs (
CREATE INDEX idx_audit_logs_tenant_id ON audit_logs(tenant_id);
CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id);
CREATE INDEX idx_audit_logs_action ON audit_logs(action);
CREATE INDEX idx_audit_logs_resource ON audit_logs(resource_type, resource_id);
CREATE INDEX idx_audit_logs_correlation_id ON audit_logs(correlation_id);
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at);
-- Índices compuestos para queries comunes de auditoría
CREATE INDEX idx_audit_logs_tenant_action ON audit_logs(tenant_id, action);
CREATE INDEX idx_audit_logs_resource ON audit_logs(resource_type, resource_id);
CREATE INDEX idx_audit_logs_user_created ON audit_logs(user_id, created_at);
-- ===================================
-- FUNCIONES Y TRIGGERS
-- ===================================

View File

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

View File

@@ -3,6 +3,9 @@ import { auth } from './auth.js';
import { get } from 'svelte/store';
import type { Writable } from 'svelte/store';
// API Configuration
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// Types
export interface Category {
id: string;
@@ -30,7 +33,7 @@ const initialState: AppState = {
async function apiCall(endpoint: string, options: RequestInit = {}) {
const authState = get(auth);
const response = await fetch(`/api/v1${endpoint}`, {
const response = await fetch(`${API_URL}/v1${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',

View File

@@ -1,6 +1,9 @@
import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
// API Configuration
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// Types
export interface User {
id: string;
@@ -79,7 +82,7 @@ function createAuthStore() {
update(state => ({ ...state, isLoading: true }));
try {
const response = await fetch('/api/v1/auth/login', {
const response = await fetch(`${API_URL}/v1/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@@ -2,6 +2,9 @@ import type { Writable } from 'svelte/store';
import { get, writable } from 'svelte/store';
import { auth } from './auth';
// API Configuration
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// Types
interface FastAPIValidationError {
loc: (string | number)[];
@@ -84,7 +87,7 @@ async function apiCall(endpoint: string, options: RequestInit = {}) {
throw new Error('Not authenticated');
}
const response = await fetch(`/api/v1${endpoint}`, {
const response = await fetch(`${API_URL}/v1${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
@@ -259,7 +262,7 @@ function createTicketsStore() {
throw new Error('Not authenticated');
}
const response = await fetch(`/api/v1/tickets/${ticketId}/attachments`, {
const response = await fetch(`${API_URL}/v1/tickets/${ticketId}/attachments`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${authState.token}`,
@@ -338,7 +341,7 @@ function createTicketsStore() {
throw new Error('Not authenticated');
}
const response = await fetch(`/api/v1/tickets/${ticketId}/attachments/${attachmentId}/download`, {
const response = await fetch(`${API_URL}/v1/tickets/${ticketId}/attachments/${attachmentId}/download`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${authState.token}`,

View File

@@ -23,11 +23,6 @@
<slot />
</main>
<!-- Footer with version -->
<footer class="py-4 text-center border-t border-gray-200 bg-white">
<p class="text-xs text-gray-400">ServiceManagerWeb v1.6.0 · © 2026 Aduanasoft</p>
</footer>
<!-- Toast notifications -->
{#each $toast.toasts as toastMessage (toastMessage.id)}
<Toast

View File

@@ -4,6 +4,9 @@
import { toast } from '$lib/stores/toast.js';
import { onMount } from 'svelte';
// API Configuration
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
let currentPassword = '';
let newPassword = '';
let confirmPassword = '';
@@ -82,7 +85,7 @@
return;
}
const response = await fetch('/api/v1/client-profile/', {
const response = await fetch(`${API_URL}/v1/client-profile/`, {
headers: {
Authorization: `Bearer ${$auth.token}`,
'X-Tenant-ID': $auth.user.tenant_id
@@ -185,7 +188,7 @@
isUpdatingProfile = true;
try {
const response = await fetch('/api/v1/auth/profile', {
const response = await fetch(`${API_URL}/v1/auth/profile`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
@@ -218,7 +221,7 @@
isChangingPassword = true;
try {
const response = await fetch('/api/v1/auth/change-password', {
const response = await fetch(`${API_URL}/v1/auth/change-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -269,7 +272,7 @@
profileData.credit_limit = parseFloat(profileData.credit_limit);
}
const response = await fetch('/api/v1/client-profile/', {
const response = await fetch(`${API_URL}/v1/client-profile/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

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

View File

@@ -1,9 +1,7 @@
<script>
import { createEventDispatcher } from 'svelte';
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
export let open = false;
export let title = '';
export let size = 'lg'; // sm, md, lg, xl, 2xl
const dispatch = createEventDispatcher();
@@ -12,72 +10,37 @@
}
function handleKeydown(e) {
if (e.key === 'Escape' && open) {
if (e.key === 'Escape') {
close();
}
}
function handleBackdropClick(e) {
if (e.target === e.currentTarget) {
close();
}
}
const sizeClasses = {
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl'
};
</script>
<svelte:window on:keydown={handleKeydown}/>
{#if open}
<div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<!-- Backdrop -->
<div
class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75"
aria-hidden="true"
on:click={handleBackdropClick}
></div>
<div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" aria-hidden="true" on:click={close}></div>
<!-- Center trick -->
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<!-- Modal panel -->
<div class="inline-block w-full align-bottom bg-white rounded-lg shadow-xl transform transition-all sm:my-8 sm:align-middle {sizeClasses[size]} sm:w-full">
<!-- Header -->
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900" id="modal-title">
{title}
</h3>
<button
type="button"
on:click={close}
class="text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-lg p-1"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div class="inline-block px-4 pt-5 pb-4 overflow-hidden text-left align-bottom transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
<h3 class="text-lg leading-6 font-medium text-gray-900" id="modal-title">
{title}
</h3>
<div class="mt-2 text-sm text-gray-500">
<slot />
</div>
</div>
</div>
<!-- Body -->
<div class="px-6 py-4 max-h-[70vh] overflow-y-auto">
<slot />
</div>
<!-- Footer (optional) -->
{#if $$slots.footer}
<div class="px-6 py-4 bg-gray-50 border-t border-gray-200 rounded-b-lg">
<div class="mt-5 sm:mt-6 sm:flex sm:flex-row-reverse">
<slot name="footer" />
</div>
{/if}
</div>
</div>
</div>
{/if}

View File

@@ -62,11 +62,6 @@
name: 'Auditoría',
href: '/audit',
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z'
},
{
name: 'Seguridad',
href: '/audit/security',
icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z'
}
);
}
@@ -183,10 +178,5 @@
</div>
</div>
</div>
<!-- Version info -->
<div class="px-4 py-2 border-t border-gray-100">
<p class="text-xs text-gray-400 text-center">v1.6.0</p>
</div>
</div>
</div>

View File

@@ -1,6 +1,9 @@
import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
// API Configuration
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// Types
export interface InternalUser {
id: string;
@@ -95,7 +98,7 @@ function createAuthStore() {
update(state => ({ ...state, isLoading: true }));
try {
const response = await fetch('/api/v1/auth/login', {
const response = await fetch(`${API_URL}/v1/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -147,7 +150,7 @@ function createAuthStore() {
update (state => ({ ...state, isLoading: true }));
try {
const response = await fetch('/api/v1/auth/refresh', {
const response = await fetch(`${API_URL}/v1/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',

File diff suppressed because it is too large Load Diff

View File

@@ -1,532 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/utils/api';
import { toast } from '$lib/stores/toast';
import { auth } from '$lib/stores/auth';
import Modal from '$lib/components/Modal.svelte';
// Estado
let isLoading = false;
let analysis = null;
let analysisHours = 24;
let selectedThreat = null;
let showActionModal = false;
let actionType = '';
let actionTarget = '';
let actionReason = '';
let actionDuration = 60;
// Usuario actual
$: currentUser = $auth.user;
$: canExecuteActions = currentUser && (currentUser.role === 'ADMIN' || currentUser.role === 'SUPPORT_MANAGER');
/**
* Cargar análisis de seguridad
*/
async function loadSecurityAnalysis() {
isLoading = true;
try {
analysis = await api.get('/audit/security/analysis', { hours: analysisHours });
console.log('Security analysis loaded:', analysis);
} catch (e: any) {
toast.error('Error cargando análisis de seguridad: ' + (e.message || 'Error desconocido'));
} finally {
isLoading = false;
}
}
/**
* Cambiar período de análisis
*/
function changeAnalysisPeriod(hours: number) {
analysisHours = hours;
loadSecurityAnalysis();
}
/**
* Obtener color según nivel de riesgo
*/
function getRiskColor(level: string) {
const colors: any = {
safe: 'bg-green-100 text-green-800 border-green-300',
low: 'bg-blue-100 text-blue-800 border-blue-300',
medium: 'bg-yellow-100 text-yellow-800 border-yellow-300',
high: 'bg-orange-100 text-orange-800 border-orange-300',
critical: 'bg-red-100 text-red-800 border-red-300'
};
return colors[level] || colors.low;
}
/**
* Obtener color de severidad de amenaza
*/
function getSeverityColor(severity: string) {
const colors: any = {
low: 'bg-blue-100 text-blue-800',
medium: 'bg-yellow-100 text-yellow-800',
high: 'bg-orange-100 text-orange-800',
critical: 'bg-red-100 text-red-800'
};
return colors[severity] || colors.low;
}
/**
* Obtener icono de tipo de amenaza
*/
function getThreatIcon(type: string) {
const icons: any = {
brute_force_attack: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z',
privilege_escalation: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
mass_deletion: 'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16',
account_compromise: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z'
};
return icons[type] || icons.account_compromise;
}
/**
* Obtener texto legible del tipo de amenaza
*/
function getThreatTypeText(type: string) {
const texts: any = {
brute_force_attack: 'Ataque de Fuerza Bruta',
privilege_escalation: 'Escalada de Privilegios',
mass_deletion: 'Eliminación Masiva',
account_compromise: 'Cuenta Comprometida'
};
return texts[type] || type;
}
/**
* Abrir modal para acción de seguridad
*/
function openActionModal(threat: any, action: string) {
if (!canExecuteActions) {
toast.error('No tienes permisos para ejecutar acciones de seguridad');
return;
}
selectedThreat = threat;
actionType = action;
// Pre-llenar campos según el tipo de acción
if (action === 'block_ip' && threat.affected_ips.length > 0) {
actionTarget = threat.affected_ips[0];
actionReason = `Bloqueo automático: ${threat.description}`;
} else if (action === 'force_password_reset' && threat.affected_users.length > 0) {
actionTarget = threat.affected_users[0];
actionReason = `Reseteo de seguridad: ${threat.description}`;
} else {
actionTarget = '';
actionReason = threat.description;
}
showActionModal = true;
}
/**
* Ejecutar acción de seguridad
*/
async function executeSecurityAction() {
if (!actionTarget || !actionReason) {
toast.error('Completa todos los campos requeridos');
return;
}
try {
const response = await api.post('/audit/security/action', {
action_type: actionType,
target: actionTarget,
reason: actionReason,
duration_minutes: actionDuration
});
if (response.success) {
toast.success(response.message);
showActionModal = false;
loadSecurityAnalysis(); // Recargar análisis
} else {
toast.error(response.message);
}
} catch (e: any) {
toast.error('Error ejecutando acción: ' + (e.message || 'Error desconocido'));
}
}
/**
* Formatear fecha
*/
function formatDate(dateString: string) {
const date = new Date(dateString);
return new Intl.DateTimeFormat('es-MX', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'UTC',
timeZoneName: 'short'
}).format(date);
}
onMount(() => {
loadSecurityAnalysis();
});
</script>
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<!-- Header -->
<div class="mb-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center gap-2">
<svg class="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
Análisis de Seguridad
</h1>
<p class="mt-1 text-sm text-gray-500">
Detección de amenazas y análisis de vulnerabilidades
</p>
</div>
<button
on:click={() => loadSecurityAnalysis()}
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 flex items-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Actualizar
</button>
</div>
</div>
<!-- Selector de Período -->
<div class="bg-white shadow rounded-lg p-4 mb-6">
<div class="flex items-center gap-2 mb-2">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm font-medium text-gray-700">Período de Análisis</span>
</div>
<div class="flex flex-wrap gap-2">
<button
on:click={() => changeAnalysisPeriod(24)}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 24 ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}"
>
Últimas 24 horas
</button>
<button
on:click={() => changeAnalysisPeriod(48)}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 48 ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}"
>
Últimas 48 horas
</button>
<button
on:click={() => changeAnalysisPeriod(168)}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 168 ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}"
>
Última semana
</button>
</div>
</div>
{#if isLoading}
<div class="flex justify-center items-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
{:else if analysis}
<!-- Resumen de Riesgo -->
<div class="bg-white shadow rounded-lg p-6 mb-6 border-l-4 {getRiskColor(analysis.overall_risk_level)}">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-900">Nivel de Riesgo General</h3>
<p class="text-sm text-gray-600 mt-1">Análisis de {analysis.analysis_period_hours} horas</p>
</div>
<div class="text-right">
<span class="inline-block px-4 py-2 text-2xl font-bold rounded-lg {getRiskColor(analysis.overall_risk_level)}">
{analysis.overall_risk_level.toUpperCase()}
</span>
<p class="text-xs text-gray-500 mt-1">Generado: {formatDate(analysis.generated_at)}</p>
</div>
</div>
</div>
<!-- Estadísticas Rápidas -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-lg shadow p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">Amenazas Detectadas</p>
<p class="text-2xl font-bold text-red-600">{analysis.total_threats_detected}</p>
</div>
<svg class="w-10 h-10 text-red-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
</div>
<div class="bg-white rounded-lg shadow p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">Intentos Fallidos</p>
<p class="text-2xl font-bold text-orange-600">{analysis.failed_login_attempts}</p>
</div>
<svg class="w-10 h-10 text-orange-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
</div>
<div class="bg-white rounded-lg shadow p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">IPs Sospechosas</p>
<p class="text-2xl font-bold text-purple-600">{analysis.suspicious_ips_count}</p>
</div>
<svg class="w-10 h-10 text-purple-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
</div>
</div>
<div class="bg-white rounded-lg shadow p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">Acciones Críticas</p>
<p class="text-2xl font-bold text-amber-600">{analysis.critical_actions_count}</p>
</div>
<svg class="w-10 h-10 text-amber-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
</div>
</div>
<!-- Recomendaciones Generales -->
{#if analysis.recommended_actions && analysis.recommended_actions.length > 0}
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div class="flex items-start gap-3">
<svg class="w-6 h-6 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="flex-1">
<h4 class="text-sm font-semibold text-blue-900 mb-2">Acciones Recomendadas</h4>
<ul class="space-y-1">
{#each analysis.recommended_actions as action}
<li class="text-sm text-blue-800 flex items-start gap-2">
<svg class="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
{action}
</li>
{/each}
</ul>
</div>
</div>
</div>
{/if}
<!-- Lista de Amenazas -->
{#if analysis.threats && analysis.threats.length > 0}
<div class="space-y-4">
<h3 class="text-lg font-semibold text-gray-900">Amenazas Detectadas</h3>
{#each analysis.threats as threat}
<div class="bg-white shadow rounded-lg p-6 border-l-4 {threat.severity === 'critical' ? 'border-red-500' : threat.severity === 'high' ? 'border-orange-500' : threat.severity === 'medium' ? 'border-yellow-500' : 'border-blue-500'}">
<!-- Header de Amenaza -->
<div class="flex items-start justify-between mb-4">
<div class="flex items-start gap-3 flex-1">
<div class="p-2 rounded-lg {threat.severity === 'critical' ? 'bg-red-100' : threat.severity === 'high' ? 'bg-orange-100' : threat.severity === 'medium' ? 'bg-yellow-100' : 'bg-blue-100'}">
<svg class="w-6 h-6 {threat.severity === 'critical' ? 'text-red-600' : threat.severity === 'high' ? 'text-orange-600' : threat.severity === 'medium' ? 'text-yellow-600' : 'text-blue-600'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={getThreatIcon(threat.type)} />
</svg>
</div>
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<h4 class="text-lg font-semibold text-gray-900">{getThreatTypeText(threat.type)}</h4>
<span class="px-2 py-1 text-xs font-semibold rounded-full {getSeverityColor(threat.severity)}">
{threat.severity.toUpperCase()}
</span>
</div>
<p class="text-sm text-gray-700">{threat.description}</p>
</div>
</div>
</div>
<!-- Detalles -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4 text-sm">
<div>
<span class="font-medium text-gray-600">Ocurrencias:</span>
<span class="ml-2 text-gray-900 font-semibold">{threat.occurrences}</span>
</div>
<div>
<span class="font-medium text-gray-600">Primera detección:</span>
<span class="ml-2 text-gray-900">{formatDate(threat.first_seen)}</span>
</div>
<div>
<span class="font-medium text-gray-600">Última detección:</span>
<span class="ml-2 text-gray-900">{formatDate(threat.last_seen)}</span>
</div>
</div>
<!-- IPs y Usuarios Afectados -->
{#if threat.affected_ips.length > 0 || threat.affected_users.length > 0}
<div class="mb-4 text-sm">
{#if threat.affected_ips.length > 0}
<div class="mb-2">
<span class="font-medium text-gray-600">IPs involucradas:</span>
<div class="mt-1 flex flex-wrap gap-1">
{#each threat.affected_ips as ip}
<code class="px-2 py-1 bg-gray-100 rounded text-xs font-mono">{ip}</code>
{/each}
</div>
</div>
{/if}
{#if threat.affected_users.length > 0}
<div>
<span class="font-medium text-gray-600">Usuarios afectados:</span>
<div class="mt-1 flex flex-wrap gap-1">
{#each threat.affected_users as user}
<span class="px-2 py-1 bg-gray-100 rounded text-xs">{user}</span>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<!-- Recomendaciones -->
{#if threat.recommendations && threat.recommendations.length > 0}
<div class="bg-gray-50 rounded-lg p-3 mb-4">
<p class="text-xs font-semibold text-gray-700 mb-2">Recomendaciones:</p>
<ul class="space-y-1">
{#each threat.recommendations as rec}
<li class="text-xs text-gray-600 flex items-start gap-2">
<svg class="w-3 h-3 text-gray-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
{rec}
</li>
{/each}
</ul>
</div>
{/if}
<!-- Acciones -->
{#if canExecuteActions}
<div class="flex flex-wrap gap-2">
{#if threat.affected_ips.length > 0}
<button
on:click={() => openActionModal(threat, 'block_ip')}
class="px-3 py-1.5 bg-red-600 text-white text-sm rounded hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 flex items-center gap-1"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
Bloquear IP
</button>
{/if}
{#if threat.affected_users.length > 0}
<button
on:click={() => openActionModal(threat, 'force_password_reset')}
class="px-3 py-1.5 bg-orange-600 text-white text-sm rounded hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 flex items-center gap-1"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
Resetear Contraseña
</button>
{/if}
<button
on:click={() => openActionModal(threat, 'notify_admin')}
class="px-3 py-1.5 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 flex items-center gap-1"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
Notificar Admin
</button>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<!-- No hay amenazas -->
<div class="bg-green-50 border border-green-200 rounded-lg p-8 text-center">
<svg class="w-16 h-16 text-green-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<h3 class="text-lg font-semibold text-green-900 mb-2">Sistema Seguro</h3>
<p class="text-sm text-green-700">No se detectaron amenazas en el período analizado</p>
</div>
{/if}
{/if}
</div>
<!-- Modal de Acción de Seguridad -->
{#if showActionModal}
<Modal open={showActionModal} size="lg" title="Ejecutar Acción de Seguridad" on:close={() => showActionModal = false}>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Tipo de Acción</label>
<input
type="text"
bind:value={actionType}
readonly
class="block w-full rounded-md border-gray-300 bg-gray-50 shadow-sm sm:text-sm"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Objetivo {#if actionType === 'block_ip'}(IP){:else if actionType === 'force_password_reset'}(Email){/if}
</label>
<input
type="text"
bind:value={actionTarget}
placeholder="IP o email del usuario"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Razón</label>
<textarea
bind:value={actionReason}
rows="3"
placeholder="Razón de la acción de seguridad"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm"
></textarea>
</div>
{#if actionType === 'block_ip'}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Duración del Bloqueo (minutos)</label>
<input
type="number"
bind:value={actionDuration}
min="1"
max="10080"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm"
/>
</div>
{/if}
<div class="flex justify-end gap-3 pt-4 border-t">
<button
on:click={() => showActionModal = false}
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500"
>
Cancelar
</button>
<button
on:click={executeSecurityAction}
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
>
Ejecutar Acción
</button>
</div>
</div>
</Modal>
{/if}

View File

@@ -145,7 +145,8 @@
</div>
</div>
<Modal open={showModal} title={editingCategory ? 'Editar Categoría' : 'Nueva Categoría'} on:close={() => showModal = false}>
{#if showModal}
<Modal title={editingCategory ? 'Editar Categoría' : 'Nueva Categoría'} on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
@@ -182,3 +183,4 @@
</div>
</form>
</Modal>
{/if}

View File

@@ -118,7 +118,8 @@
</div>
</div>
<Modal open={showModal} title={editingSystem ? 'Editar Sistema' : 'Nuevo Sistema'} on:close={() => showModal = false}>
{#if showModal}
<Modal title={editingSystem ? 'Editar Sistema' : 'Nuevo Sistema'} on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
@@ -145,3 +146,4 @@
</div>
</form>
</Modal>
{/if}

View File

@@ -126,7 +126,8 @@
</div>
</div>
<Modal open={showModal} title={editingTenant ? 'Editar Cliente' : 'Nuevo Cliente'} on:close={() => showModal = false}>
{#if showModal}
<Modal title={editingTenant ? 'Editar Cliente' : 'Nuevo Cliente'} on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
@@ -164,3 +165,4 @@
</div>
</form>
</Modal>
{/if}

View File

@@ -522,7 +522,8 @@
</div>
<!-- Modal Crear Ticket -->
<Modal open={showModal} title="Nuevo Ticket" on:close={() => showModal = false}>
{#if showModal}
<Modal title="Nuevo Ticket" on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label for="subject" class="block text-sm font-medium text-gray-700">Asunto *</label>
@@ -608,9 +609,11 @@
</div>
</form>
</Modal>
{/if}
<!-- Modal Editar Ticket -->
<Modal open={showEditModal} title="Editar Ticket #{selectedTicket?.ticket_number || ''}" on:close={() => showEditModal = false}>
{#if showEditModal}
<Modal title="Editar Ticket #{selectedTicket?.ticket_number || ''}" on:close={() => showEditModal = false}>
<form on:submit|preventDefault={handleUpdate} class="space-y-4">
<div>
<label for="editStatus" class="block text-sm font-medium text-gray-700">Estado</label>
@@ -674,9 +677,11 @@
</div>
</form>
</Modal>
{/if}
<!-- Modal Eliminar Ticket -->
<Modal open={showDeleteModal} title="Eliminar Ticket" on:close={() => showDeleteModal = false}>
{#if showDeleteModal}
<Modal title="Eliminar Ticket" on:close={() => showDeleteModal = false}>
<div class="space-y-4">
<div class="bg-red-50 border border-red-200 rounded-md p-4">
<div class="flex">
@@ -718,4 +723,5 @@
</button>
</div>
</div>
</Modal>
</Modal>
{/if}

View File

@@ -172,7 +172,8 @@
</div>
</div>
<Modal open={showModal} title={editingUser ? 'Editar Usuario' : 'Nuevo Usuario'} on:close={() => showModal = false}>
{#if showModal}
<Modal title={editingUser ? 'Editar Usuario' : 'Nuevo Usuario'} on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
@@ -229,3 +230,4 @@
</div>
</form>
</Modal>
{/if}