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:
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@servicemanager/client-frontend",
|
||||
"version": "1.5.1.2",
|
||||
"version": "1.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@servicemanager/internal-frontend",
|
||||
"version": "1.5.1.2",
|
||||
"version": "1.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -136,6 +148,11 @@
|
||||
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);
|
||||
|
||||
logs = response.logs;
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user