Release v1.6.0 - Audit System Cross-Tenant Viewing

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
This commit is contained in:
2026-02-16 09:50:30 -07:00
parent d9b783107f
commit 3a061a005c
5 changed files with 126 additions and 50 deletions

View File

@@ -61,8 +61,10 @@ async def get_audit_logs(
date_from: Optional[datetime] = Query(None, description="Fecha desde"), date_from: Optional[datetime] = Query(None, description="Fecha desde"),
date_to: Optional[datetime] = Query(None, description="Fecha hasta"), date_to: Optional[datetime] = Query(None, description="Fecha hasta"),
search: Optional[str] = Query(None, description="B├║squeda en acci├│n o email"), search: Optional[str] = Query(None, description="B├║squeda en acci├│n o email"),
# Multi-tenant filters (solo ADMIN/SUPPORT_MANAGER)
# Dependencies 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_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant), current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
@@ -90,17 +92,28 @@ async def get_audit_logs(
"user_id": str(user_id) if user_id else None, "user_id": str(user_id) if user_id else None,
"action": action, "action": action,
"resource_type": resource_type, "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 # Determinar el filtro de tenant
# Usar selectinload para cargar la relaci├│n user (eager loading para async) # Solo ADMIN y SUPPORT_MANAGER pueden ver otros tenants o todos los tenants
query = ( can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]
select(AuditLog)
.where(AuditLog.tenant_id == current_tenant.id) # Query base con filtro de tenant dinámico
.options(selectinload(AuditLog.user)) 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 # Aplicar filtros
if user_id: if user_id:
@@ -119,9 +132,8 @@ async def get_audit_logs(
query = query.where(AuditLog.created_at >= date_from) query = query.where(AuditLog.created_at >= date_from)
if date_to: if date_to:
# Agregar 1 d├¡a para incluir todo el d├¡a # El frontend ya envía el timestamp correcto
date_to_end = date_to + timedelta(days=1) query = query.where(AuditLog.created_at < date_to)
query = query.where(AuditLog.created_at < date_to_end)
if search: if search:
# B├║squeda en action # B├║squeda en action
@@ -188,51 +200,57 @@ async def get_audit_logs(
@router.get("/stats", response_model=AuditLogStats) @router.get("/stats", response_model=AuditLogStats)
async def get_audit_stats( 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_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant), current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db) 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 **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( logger.info(
"Fetching audit stats", "Fetching audit stats",
user_id=str(current_user.id), 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() now = datetime.utcnow()
# Determinar si aplicar filtro de tenant
apply_tenant_filter = not (all_tenants and can_see_all_tenants)
# Total de acciones # Total de acciones
total_query = select(func.count()).select_from(AuditLog).where( total_query = select(func.count()).select_from(AuditLog)
AuditLog.tenant_id == current_tenant.id if apply_tenant_filter:
) total_query = total_query.where(AuditLog.tenant_id == current_tenant.id)
total_result = await db.execute(total_query) total_result = await db.execute(total_query)
total_actions = total_result.scalar() or 0 total_actions = total_result.scalar() or 0
# Acciones hoy (├║ltimas 24 horas) # Acciones hoy (├║ltimas 24 horas)
today_start = now - timedelta(days=1) today_start = now - timedelta(days=1)
today_query = select(func.count()).select_from(AuditLog).where( today_query = select(func.count()).select_from(AuditLog).where(
and_( AuditLog.created_at >= today_start
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) today_result = await db.execute(today_query)
actions_today = today_result.scalar() or 0 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_start = now - timedelta(days=7)
week_query = select(func.count()).select_from(AuditLog).where( week_query = select(func.count()).select_from(AuditLog).where(
and_( AuditLog.created_at >= week_start
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) week_result = await db.execute(week_query)
actions_this_week = week_result.scalar() or 0 actions_this_week = week_result.scalar() or 0
@@ -240,9 +258,10 @@ async def get_audit_stats(
top_actions_query = select( top_actions_query = select(
AuditLog.action, AuditLog.action,
func.count(AuditLog.id).label('count') func.count(AuditLog.id).label('count')
).where( )
AuditLog.tenant_id == current_tenant.id if apply_tenant_filter:
).group_by( top_actions_query = top_actions_query.where(AuditLog.tenant_id == current_tenant.id)
top_actions_query = top_actions_query.group_by(
AuditLog.action AuditLog.action
).order_by( ).order_by(
desc('count') desc('count')
@@ -255,9 +274,10 @@ async def get_audit_stats(
by_resource_query = select( by_resource_query = select(
AuditLog.resource_type, AuditLog.resource_type,
func.count(AuditLog.id).label('count') func.count(AuditLog.id).label('count')
).where( )
AuditLog.tenant_id == current_tenant.id if apply_tenant_filter:
).group_by( by_resource_query = by_resource_query.where(AuditLog.tenant_id == current_tenant.id)
by_resource_query = by_resource_query.group_by(
AuditLog.resource_type AuditLog.resource_type
).order_by( ).order_by(
desc('count') desc('count')
@@ -272,9 +292,10 @@ async def get_audit_stats(
func.count(AuditLog.id).label('count') func.count(AuditLog.id).label('count')
).join( ).join(
User, AuditLog.user_id == User.id User, AuditLog.user_id == User.id
).where( )
AuditLog.tenant_id == current_tenant.id if apply_tenant_filter:
).group_by( top_users_query = top_users_query.where(AuditLog.tenant_id == current_tenant.id)
top_users_query = top_users_query.group_by(
User.email User.email
).order_by( ).order_by(
desc('count') desc('count')
@@ -284,17 +305,20 @@ async def get_audit_stats(
top_users = {row.email: row.count for row in top_users_result} top_users = {row.email: row.count for row in top_users_result}
# Acciones críticas hoy (delete, update sensibles, etc.) # Acciones críticas hoy (delete, update sensibles, etc.)
critical_actions_query = select(func.count()).select_from(AuditLog).where( critical_conditions = [
and_( AuditLog.created_at >= today_start,
AuditLog.tenant_id == current_tenant.id, or_(
AuditLog.created_at >= today_start, AuditLog.action.like('%.delete'),
or_( AuditLog.action.like('user.update'),
AuditLog.action.like('%.delete'), AuditLog.action.like('%.assign'),
AuditLog.action.like('user.update'), AuditLog.action.in_(['user.login_failed', 'user.logout'])
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_result = await db.execute(critical_actions_query)
critical_actions_today = critical_result.scalar() or 0 critical_actions_today = critical_result.scalar() or 0

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { api } from '$lib/utils/api'; import { api } from '$lib/utils/api';
import { toast } from '$lib/stores/toast'; import { toast } from '$lib/stores/toast';
import { auth } from '$lib/stores/auth';
import Modal from '$lib/components/Modal.svelte'; import Modal from '$lib/components/Modal.svelte';
// Estado de carga y datos // Estado de carga y datos
@@ -24,6 +25,9 @@
let filterResourceType = ''; let filterResourceType = '';
let searchText = ''; let searchText = '';
// Filtro multi-tenant (solo para ADMIN/SUPPORT_MANAGER)
let allTenants = false;
// Filtro de período // Filtro de período
let periodFilter: 'today' | 'yesterday' | 'last7days' | 'last30days' | 'custom' = 'today'; let periodFilter: 'today' | 'yesterday' | 'last7days' | 'last30days' | 'custom' = 'today';
let customDateFrom = ''; let customDateFrom = '';
@@ -32,6 +36,10 @@
// Control de visibilidad de filtros avanzados // Control de visibilidad de filtros avanzados
let showAdvancedFilters = false; 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) // Contador de filtros activos (excluyendo el período que es por defecto)
$: activeFiltersCount = [filterUserId, filterAction, filterResourceType, searchText].filter(f => f && f.trim()).length; $: activeFiltersCount = [filterUserId, filterAction, filterResourceType, searchText].filter(f => f && f.trim()).length;
@@ -108,7 +116,11 @@
*/ */
async function loadStats() { async function loadStats() {
try { 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) { } catch (e) {
console.error('Error cargando estadísticas:', e); console.error('Error cargando estadísticas:', e);
} }
@@ -136,6 +148,11 @@
if (filterResourceType) params.resource_type = filterResourceType; if (filterResourceType) params.resource_type = filterResourceType;
if (searchText) params.search = searchText; 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); const response = await api.get('/audit/', params);
logs = response.logs; logs = response.logs;
@@ -412,6 +429,41 @@
{/if} {/if}
</div> </div>
<!-- Filtro Multi-Tenant (solo para ADMIN/SUPPORT_MANAGER) -->
{#if canSeeAllTenants}
<div class="bg-white shadow rounded-lg p-4 mb-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
<label for="all-tenants-toggle" class="flex items-center cursor-pointer">
<input
type="checkbox"
id="all-tenants-toggle"
bind:checked={allTenants}
on:change={() => {
currentPage = 1;
loadLogs();
loadStats();
}}
class="rounded border-gray-300 text-primary-600 shadow-sm focus:border-primary-500 focus:ring-primary-500 h-4 w-4 mr-3"
/>
<div>
<span class="text-sm font-medium text-gray-900">Ver todos los clientes</span>
<p class="text-xs text-gray-500">Mostrar registros de auditoría de todas las organizaciones</p>
</div>
</label>
</div>
{#if allTenants}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2a8 8 0 100 16 8 8 0 000-16zM9 9a1 1 0 012 0v4a1 1 0 11-2 0V9zm1-5a1 1 0 100 2 1 1 0 000-2z" />
</svg>
Multi-tenant activo
</span>
{/if}
</div>
</div>
{/if}
<!-- Estadísticas Rápidas --> <!-- Estadísticas Rápidas -->
{#if stats} {#if stats}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6"> <div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">