From 3a061a005cf75ac346bd3653bd3e2e97d3f1264a Mon Sep 17 00:00:00 2001 From: icamarillo Date: Mon, 16 Feb 2026 09:50:30 -0700 Subject: [PATCH] Release v1.6.0 - Audit System Cross-Tenant Viewing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - ✅ Cross-tenant audit log viewing for ADMIN/SUPPORT_MANAGER - ✅ New 'all_tenants' parameter in audit endpoints - ✅ Frontend toggle to view all clients' logs - ✅ Multi-tenant stats support in /audit/stats - ✅ Fixed timezone issue with date filters (UTC consistency) - ✅ Fixed date_to filter overlap causing duplicate records Changes: - backend/app/api/v1/endpoints/audit.py: * Added tenant_id and all_tenants query parameters * Permission checks for cross-tenant viewing * Dynamic tenant filtering based on user role * Fixed date_to filter (removed +1 day overlap) * Updated all stats queries for multi-tenant support - frontend-internal/src/routes/audit/+page.svelte: * Import auth store for role detection * Added 'Ver todos los clientes' toggle for admins * Pass all_tenants parameter to API calls * UI badge indicating multi-tenant mode - Version bump: 1.5.1.2 → 1.6.0 across all packages --- backend/app/api/v1/endpoints/audit.py | 116 +++++++++++------- backend/pyproject.toml | 2 +- frontend-client/package.json | 2 +- frontend-internal/package.json | 2 +- .../src/routes/audit/+page.svelte | 54 +++++++- 5 files changed, 126 insertions(+), 50 deletions(-) diff --git a/backend/app/api/v1/endpoints/audit.py b/backend/app/api/v1/endpoints/audit.py index feba95b..f963e3c 100644 --- a/backend/app/api/v1/endpoints/audit.py +++ b/backend/app/api/v1/endpoints/audit.py @@ -61,8 +61,10 @@ async def get_audit_logs( date_from: Optional[datetime] = Query(None, description="Fecha desde"), date_to: Optional[datetime] = Query(None, description="Fecha hasta"), search: Optional[str] = Query(None, description="B├║squeda en acci├│n o email"), - - # Dependencies + # Multi-tenant filters (solo ADMIN/SUPPORT_MANAGER) + tenant_id: Optional[uuid.UUID] = Query(None, description="Ver logs de un tenant específico"), + all_tenants: bool = Query(False, description="Ver logs de todos los tenants"), + # Dependencies current_user: User = Depends(require_auditor_role), current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db) @@ -90,17 +92,28 @@ async def get_audit_logs( "user_id": str(user_id) if user_id else None, "action": action, "resource_type": resource_type, - "page": page + "page": page, + "tenant_filter": str(tenant_id) if tenant_id else None, + "all_tenants": all_tenants } ) - # 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)) - ) + # Determinar el filtro de tenant + # Solo ADMIN y SUPPORT_MANAGER pueden ver otros tenants o todos los tenants + can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER] + + # Query base con filtro de tenant dinámico + query = select(AuditLog).options(selectinload(AuditLog.user)) + + if all_tenants and can_see_all_tenants: + # Ver todos los tenants (no agregar filtro de tenant) + pass + elif tenant_id and can_see_all_tenants: + # Ver un tenant específico + query = query.where(AuditLog.tenant_id == tenant_id) + else: + # Ver solo el tenant actual (comportamiento default) + query = query.where(AuditLog.tenant_id == current_tenant.id) # Aplicar filtros if user_id: @@ -119,9 +132,8 @@ async def get_audit_logs( query = query.where(AuditLog.created_at >= date_from) if date_to: - # Agregar 1 d├¡a para incluir todo el d├¡a - date_to_end = date_to + timedelta(days=1) - query = query.where(AuditLog.created_at < date_to_end) + # El frontend ya envía el timestamp correcto + query = query.where(AuditLog.created_at < date_to) if search: # B├║squeda en action @@ -188,51 +200,57 @@ 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. + Obtener estadísticas de auditoría del tenant (o todos los tenants si es ADMIN). **Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR - **Retorna**: Estad├¡sticas de actividad + **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) + tenant_id=str(current_tenant.id), + all_tenants=all_tenants, + can_see_all=can_see_all_tenants ) now = datetime.utcnow() + # Determinar si aplicar filtro de tenant + apply_tenant_filter = not (all_tenants and can_see_all_tenants) + # Total de acciones - total_query = select(func.count()).select_from(AuditLog).where( - AuditLog.tenant_id == current_tenant.id - ) + total_query = select(func.count()).select_from(AuditLog) + if apply_tenant_filter: + total_query = total_query.where(AuditLog.tenant_id == current_tenant.id) total_result = await db.execute(total_query) total_actions = total_result.scalar() or 0 # Acciones hoy (├║ltimas 24 horas) today_start = now - timedelta(days=1) today_query = select(func.count()).select_from(AuditLog).where( - and_( - AuditLog.tenant_id == current_tenant.id, - AuditLog.created_at >= today_start - ) + AuditLog.created_at >= today_start ) + if apply_tenant_filter: + today_query = today_query.where(AuditLog.tenant_id == current_tenant.id) today_result = await db.execute(today_query) actions_today = today_result.scalar() or 0 # Acciones esta semana (├║ltimos 7 d├¡as) week_start = now - timedelta(days=7) week_query = select(func.count()).select_from(AuditLog).where( - and_( - AuditLog.tenant_id == current_tenant.id, - AuditLog.created_at >= week_start - ) + 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 @@ -240,9 +258,10 @@ async def get_audit_stats( top_actions_query = select( AuditLog.action, func.count(AuditLog.id).label('count') - ).where( - AuditLog.tenant_id == current_tenant.id - ).group_by( + ) + if apply_tenant_filter: + top_actions_query = top_actions_query.where(AuditLog.tenant_id == current_tenant.id) + top_actions_query = top_actions_query.group_by( AuditLog.action ).order_by( desc('count') @@ -255,9 +274,10 @@ async def get_audit_stats( by_resource_query = select( AuditLog.resource_type, func.count(AuditLog.id).label('count') - ).where( - AuditLog.tenant_id == current_tenant.id - ).group_by( + ) + if apply_tenant_filter: + by_resource_query = by_resource_query.where(AuditLog.tenant_id == current_tenant.id) + by_resource_query = by_resource_query.group_by( AuditLog.resource_type ).order_by( desc('count') @@ -272,9 +292,10 @@ async def get_audit_stats( func.count(AuditLog.id).label('count') ).join( User, AuditLog.user_id == User.id - ).where( - AuditLog.tenant_id == current_tenant.id - ).group_by( + ) + 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') @@ -284,17 +305,20 @@ async def get_audit_stats( top_users = {row.email: row.count for row in top_users_result} # Acciones cr├¡ticas hoy (delete, update sensibles, etc.) - critical_actions_query = select(func.count()).select_from(AuditLog).where( - and_( - AuditLog.tenant_id == current_tenant.id, - 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']) - ) + 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 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index d1ea1cd..f8bd802 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "servicemanager-backend" -version = "1.5.1.2" +version = "1.6.0" description = "ServiceManagerWeb Backend - Mesa de Ayuda B2B" authors = [ {name = "Aduanasoft", email = "dev@aduanasoft.com"} diff --git a/frontend-client/package.json b/frontend-client/package.json index e2bff1a..6308bd5 100644 --- a/frontend-client/package.json +++ b/frontend-client/package.json @@ -1,6 +1,6 @@ { "name": "@servicemanager/client-frontend", - "version": "1.5.1.2", + "version": "1.6.0", "private": true, "type": "module", "scripts": { diff --git a/frontend-internal/package.json b/frontend-internal/package.json index 28286df..4acfec0 100644 --- a/frontend-internal/package.json +++ b/frontend-internal/package.json @@ -1,6 +1,6 @@ { "name": "@servicemanager/internal-frontend", - "version": "1.5.1.2", + "version": "1.6.0", "private": true, "type": "module", "scripts": { diff --git a/frontend-internal/src/routes/audit/+page.svelte b/frontend-internal/src/routes/audit/+page.svelte index d790a7f..a54e242 100644 --- a/frontend-internal/src/routes/audit/+page.svelte +++ b/frontend-internal/src/routes/audit/+page.svelte @@ -2,6 +2,7 @@ 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 de carga y datos @@ -24,6 +25,9 @@ let filterResourceType = ''; let searchText = ''; + // Filtro multi-tenant (solo para ADMIN/SUPPORT_MANAGER) + let allTenants = false; + // Filtro de período let periodFilter: 'today' | 'yesterday' | 'last7days' | 'last30days' | 'custom' = 'today'; let customDateFrom = ''; @@ -32,6 +36,10 @@ // Control de visibilidad de filtros avanzados let showAdvancedFilters = false; + // Usuario actual + $: currentUser = $auth.user; + $: canSeeAllTenants = currentUser && (currentUser.role === 'ADMIN' || currentUser.role === 'SUPPORT_MANAGER'); + // Contador de filtros activos (excluyendo el período que es por defecto) $: activeFiltersCount = [filterUserId, filterAction, filterResourceType, searchText].filter(f => f && f.trim()).length; @@ -108,7 +116,11 @@ */ async function loadStats() { try { - stats = await api.get('/audit/stats'); + const params: any = {}; + if (allTenants && canSeeAllTenants) { + params.all_tenants = true; + } + stats = await api.get('/audit/stats', params); } catch (e) { console.error('Error cargando estadísticas:', e); } @@ -135,6 +147,11 @@ if (filterAction) params.action = filterAction; if (filterResourceType) params.resource_type = filterResourceType; if (searchText) params.search = searchText; + + // Aplicar filtro multi-tenant si el usuario tiene permiso + if (allTenants && canSeeAllTenants) { + params.all_tenants = true; + } const response = await api.get('/audit/', params); @@ -412,6 +429,41 @@ {/if} + + {#if canSeeAllTenants} +
+
+
+ +
+ {#if allTenants} + + + + + Multi-tenant activo + + {/if} +
+
+ {/if} + {#if stats}