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,7 +61,9 @@ 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)
|
||||||
|
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
|
# 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),
|
||||||
@@ -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.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)
|
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.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)
|
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,9 +305,7 @@ 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.tenant_id == current_tenant.id,
|
|
||||||
AuditLog.created_at >= today_start,
|
AuditLog.created_at >= today_start,
|
||||||
or_(
|
or_(
|
||||||
AuditLog.action.like('%.delete'),
|
AuditLog.action.like('%.delete'),
|
||||||
@@ -294,7 +313,12 @@ async def get_audit_stats(
|
|||||||
AuditLog.action.like('%.assign'),
|
AuditLog.action.like('%.assign'),
|
||||||
AuditLog.action.in_(['user.login_failed', 'user.logout'])
|
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
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user