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

View File

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

View File

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

View File

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

View File

@@ -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}
</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 -->
{#if stats}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">