erp-core-database-v2/ddl/10-audit.sql
rckrdmrd 5043a640e4 refactor: Restructure DDL with numbered schema files
- Replace old DDL structure with new numbered files (01-24)
- Update migrations and seeds for new schema
- Clean up deprecated files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 00:40:32 -06:00

794 lines
26 KiB
PL/PgSQL

-- =============================================================
-- ARCHIVO: 10-audit.sql
-- DESCRIPCION: Sistema de audit trail, cambios de entidades, logs
-- VERSION: 1.0.0
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-01-10
-- EPIC: SAAS-AUDIT (EPIC-SAAS-004)
-- HISTORIAS: US-050, US-051, US-052
-- =============================================================
-- =====================
-- SCHEMA: audit
-- =====================
CREATE SCHEMA IF NOT EXISTS audit;
-- =====================
-- TABLA: audit.audit_logs
-- Log de auditoría general
-- =====================
CREATE TABLE IF NOT EXISTS audit.audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Actor
user_id UUID REFERENCES auth.users(id),
user_email VARCHAR(255),
user_name VARCHAR(200),
session_id UUID,
impersonator_id UUID REFERENCES auth.users(id), -- Si está siendo impersonado
-- Acción
action VARCHAR(50) NOT NULL, -- create, read, update, delete, login, logout, export, etc.
action_category VARCHAR(50), -- data, auth, system, config, billing
-- Recurso
resource_type VARCHAR(100) NOT NULL, -- user, product, sale, branch, etc.
resource_id UUID,
resource_name VARCHAR(255),
-- Cambios
old_values JSONB,
new_values JSONB,
changed_fields TEXT[],
-- Contexto
ip_address INET,
user_agent TEXT,
device_info JSONB DEFAULT '{}',
location JSONB DEFAULT '{}', -- {country, city, lat, lng}
-- Request info
request_id VARCHAR(100),
request_method VARCHAR(10),
request_path TEXT,
request_params JSONB DEFAULT '{}',
-- Resultado
status VARCHAR(20) DEFAULT 'success', -- success, failure, partial
error_message TEXT,
duration_ms INTEGER,
-- Metadata
metadata JSONB DEFAULT '{}',
tags TEXT[] DEFAULT '{}',
-- Timestamp
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Particionar por fecha para mejor rendimiento (recomendado en producción)
-- CREATE TABLE audit.audit_logs_y2026m01 PARTITION OF audit.audit_logs
-- FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
-- =====================
-- TABLA: audit.entity_changes
-- Historial detallado de cambios por entidad
-- =====================
CREATE TABLE IF NOT EXISTS audit.entity_changes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Entidad
entity_type VARCHAR(100) NOT NULL,
entity_id UUID NOT NULL,
entity_name VARCHAR(255),
-- Versión
version INTEGER NOT NULL DEFAULT 1,
previous_version INTEGER,
-- Snapshot completo de la entidad
data_snapshot JSONB NOT NULL,
-- Cambios específicos
changes JSONB DEFAULT '[]',
-- Ejemplo: [{"field": "price", "old": 100, "new": 150, "type": "update"}]
-- Actor
changed_by UUID REFERENCES auth.users(id),
change_reason TEXT,
-- Tipo de cambio
change_type VARCHAR(20) NOT NULL, -- create, update, delete, restore
-- Timestamp
changed_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Índice único para versiones
CREATE UNIQUE INDEX IF NOT EXISTS idx_entity_version ON audit.entity_changes(entity_type, entity_id, version);
-- =====================
-- TABLA: audit.sensitive_data_access
-- Acceso a datos sensibles
-- =====================
CREATE TABLE IF NOT EXISTS audit.sensitive_data_access (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Actor
user_id UUID NOT NULL REFERENCES auth.users(id),
session_id UUID,
-- Datos accedidos
data_type VARCHAR(100) NOT NULL, -- pii, financial, medical, credentials
data_category VARCHAR(100), -- customer_data, employee_data, payment_info
entity_type VARCHAR(100),
entity_id UUID,
-- Acción
access_type VARCHAR(30) NOT NULL, -- view, export, modify, decrypt
access_reason TEXT,
-- Contexto
ip_address INET,
user_agent TEXT,
-- Resultado
was_authorized BOOLEAN DEFAULT TRUE,
denial_reason TEXT,
-- Timestamp
accessed_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- =====================
-- TABLA: audit.data_exports
-- Log de exportaciones de datos
-- =====================
CREATE TABLE IF NOT EXISTS audit.data_exports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Actor
user_id UUID NOT NULL REFERENCES auth.users(id),
-- Exportación
export_type VARCHAR(50) NOT NULL, -- report, backup, gdpr_request, bulk_export
export_format VARCHAR(20), -- csv, xlsx, pdf, json
entity_types TEXT[] NOT NULL,
-- Filtros aplicados
filters JSONB DEFAULT '{}',
date_range_start TIMESTAMPTZ,
date_range_end TIMESTAMPTZ,
-- Resultado
record_count INTEGER,
file_size_bytes BIGINT,
file_hash VARCHAR(64), -- SHA-256 del archivo
-- Estado
status VARCHAR(20) DEFAULT 'pending', -- pending, processing, completed, failed, expired
-- Archivos
download_url TEXT,
download_expires_at TIMESTAMPTZ,
download_count INTEGER DEFAULT 0,
-- Contexto
ip_address INET,
user_agent TEXT,
-- Timestamps
requested_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ
);
-- =====================
-- TABLA: audit.login_history
-- Historial de inicios de sesión
-- =====================
CREATE TABLE IF NOT EXISTS audit.login_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
-- Identificación del usuario
email VARCHAR(255),
username VARCHAR(100),
-- Resultado
status VARCHAR(20) NOT NULL, -- success, failed, blocked, mfa_required, mfa_failed
-- Método de autenticación
auth_method VARCHAR(30), -- password, sso, oauth, mfa, magic_link, biometric
oauth_provider VARCHAR(30),
-- MFA
mfa_method VARCHAR(20), -- totp, sms, email, push
mfa_verified BOOLEAN,
-- Dispositivo
device_id UUID REFERENCES auth.devices(id),
device_fingerprint VARCHAR(255),
device_type VARCHAR(30), -- desktop, mobile, tablet
device_os VARCHAR(50),
device_browser VARCHAR(50),
-- Ubicación
ip_address INET,
user_agent TEXT,
country_code VARCHAR(2),
city VARCHAR(100),
latitude DECIMAL(10, 8),
longitude DECIMAL(11, 8),
-- Riesgo
risk_score INTEGER, -- 0-100
risk_factors JSONB DEFAULT '[]',
is_suspicious BOOLEAN DEFAULT FALSE,
is_new_device BOOLEAN DEFAULT FALSE,
is_new_location BOOLEAN DEFAULT FALSE,
-- Error info
failure_reason VARCHAR(100),
failure_count INTEGER,
-- Timestamp
attempted_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- =====================
-- TABLA: audit.permission_changes
-- Cambios en permisos y roles
-- =====================
CREATE TABLE IF NOT EXISTS audit.permission_changes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Actor
changed_by UUID NOT NULL REFERENCES auth.users(id),
-- Usuario afectado
target_user_id UUID NOT NULL REFERENCES auth.users(id),
target_user_email VARCHAR(255),
-- Tipo de cambio
change_type VARCHAR(30) NOT NULL, -- role_assigned, role_revoked, permission_granted, permission_revoked
-- Rol/Permiso
role_id UUID,
role_code VARCHAR(50),
permission_id UUID,
permission_code VARCHAR(100),
-- Contexto
branch_id UUID REFERENCES core.branches(id),
scope VARCHAR(30), -- global, tenant, branch
-- Valores anteriores
previous_roles TEXT[],
previous_permissions TEXT[],
-- Razón
reason TEXT,
-- Timestamp
changed_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- =====================
-- TABLA: audit.config_changes
-- Cambios en configuración del sistema
-- =====================
CREATE TABLE IF NOT EXISTS audit.config_changes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE, -- NULL = config global
-- Actor
changed_by UUID NOT NULL REFERENCES auth.users(id),
-- Configuración
config_type VARCHAR(50) NOT NULL, -- tenant_settings, user_settings, system_settings, feature_flags
config_key VARCHAR(100) NOT NULL,
config_path TEXT, -- Path jerárquico: billing.invoicing.prefix
-- Valores
old_value JSONB,
new_value JSONB,
-- Contexto
reason TEXT,
ticket_id VARCHAR(50), -- Referencia a ticket de soporte
-- Timestamp
changed_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- =====================
-- INDICES
-- =====================
-- Indices para audit_logs
CREATE INDEX IF NOT EXISTS idx_audit_logs_tenant ON audit.audit_logs(tenant_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit.audit_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit.audit_logs(action);
CREATE INDEX IF NOT EXISTS idx_audit_logs_resource ON audit.audit_logs(resource_type, resource_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit.audit_logs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_logs_category ON audit.audit_logs(action_category);
CREATE INDEX IF NOT EXISTS idx_audit_logs_status ON audit.audit_logs(status) WHERE status = 'failure';
CREATE INDEX IF NOT EXISTS idx_audit_logs_tags ON audit.audit_logs USING GIN(tags);
-- Indices para entity_changes
CREATE INDEX IF NOT EXISTS idx_entity_changes_tenant ON audit.entity_changes(tenant_id);
CREATE INDEX IF NOT EXISTS idx_entity_changes_entity ON audit.entity_changes(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_entity_changes_changed_by ON audit.entity_changes(changed_by);
CREATE INDEX IF NOT EXISTS idx_entity_changes_date ON audit.entity_changes(changed_at DESC);
-- Indices para sensitive_data_access
CREATE INDEX IF NOT EXISTS idx_sensitive_access_tenant ON audit.sensitive_data_access(tenant_id);
CREATE INDEX IF NOT EXISTS idx_sensitive_access_user ON audit.sensitive_data_access(user_id);
CREATE INDEX IF NOT EXISTS idx_sensitive_access_type ON audit.sensitive_data_access(data_type);
CREATE INDEX IF NOT EXISTS idx_sensitive_access_date ON audit.sensitive_data_access(accessed_at DESC);
CREATE INDEX IF NOT EXISTS idx_sensitive_unauthorized ON audit.sensitive_data_access(was_authorized)
WHERE was_authorized = FALSE;
-- Indices para data_exports
CREATE INDEX IF NOT EXISTS idx_exports_tenant ON audit.data_exports(tenant_id);
CREATE INDEX IF NOT EXISTS idx_exports_user ON audit.data_exports(user_id);
CREATE INDEX IF NOT EXISTS idx_exports_status ON audit.data_exports(status);
CREATE INDEX IF NOT EXISTS idx_exports_date ON audit.data_exports(requested_at DESC);
-- Indices para login_history
CREATE INDEX IF NOT EXISTS idx_login_tenant ON audit.login_history(tenant_id);
CREATE INDEX IF NOT EXISTS idx_login_user ON audit.login_history(user_id);
CREATE INDEX IF NOT EXISTS idx_login_status ON audit.login_history(status);
CREATE INDEX IF NOT EXISTS idx_login_date ON audit.login_history(attempted_at DESC);
CREATE INDEX IF NOT EXISTS idx_login_ip ON audit.login_history(ip_address);
CREATE INDEX IF NOT EXISTS idx_login_suspicious ON audit.login_history(is_suspicious) WHERE is_suspicious = TRUE;
CREATE INDEX IF NOT EXISTS idx_login_failed ON audit.login_history(status, email) WHERE status = 'failed';
-- Indices para permission_changes
CREATE INDEX IF NOT EXISTS idx_perm_changes_tenant ON audit.permission_changes(tenant_id);
CREATE INDEX IF NOT EXISTS idx_perm_changes_target ON audit.permission_changes(target_user_id);
CREATE INDEX IF NOT EXISTS idx_perm_changes_date ON audit.permission_changes(changed_at DESC);
-- Indices para config_changes
CREATE INDEX IF NOT EXISTS idx_config_changes_tenant ON audit.config_changes(tenant_id);
CREATE INDEX IF NOT EXISTS idx_config_changes_type ON audit.config_changes(config_type);
CREATE INDEX IF NOT EXISTS idx_config_changes_date ON audit.config_changes(changed_at DESC);
-- =====================
-- RLS POLICIES
-- =====================
ALTER TABLE audit.audit_logs ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_audit_logs ON audit.audit_logs
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE audit.entity_changes ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_entity_changes ON audit.entity_changes
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE audit.sensitive_data_access ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_sensitive ON audit.sensitive_data_access
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE audit.data_exports ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_exports ON audit.data_exports
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE audit.login_history ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_login ON audit.login_history
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE audit.permission_changes ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_perm_changes ON audit.permission_changes
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE audit.config_changes ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_config_changes ON audit.config_changes
USING (
tenant_id IS NULL
OR tenant_id = current_setting('app.current_tenant_id', true)::uuid
);
-- =====================
-- FUNCIONES
-- =====================
-- Función para registrar log de auditoría
CREATE OR REPLACE FUNCTION audit.log(
p_tenant_id UUID,
p_user_id UUID,
p_action VARCHAR(50),
p_resource_type VARCHAR(100),
p_resource_id UUID DEFAULT NULL,
p_old_values JSONB DEFAULT NULL,
p_new_values JSONB DEFAULT NULL,
p_metadata JSONB DEFAULT '{}'
)
RETURNS UUID AS $$
DECLARE
v_log_id UUID;
v_changed_fields TEXT[];
BEGIN
-- Calcular campos cambiados
IF p_old_values IS NOT NULL AND p_new_values IS NOT NULL THEN
SELECT ARRAY_AGG(key)
INTO v_changed_fields
FROM (
SELECT key FROM jsonb_object_keys(p_old_values) AS key
WHERE p_old_values->key IS DISTINCT FROM p_new_values->key
UNION
SELECT key FROM jsonb_object_keys(p_new_values) AS key
WHERE NOT p_old_values ? key
) AS changed;
END IF;
INSERT INTO audit.audit_logs (
tenant_id, user_id, action, resource_type, resource_id,
old_values, new_values, changed_fields, metadata
) VALUES (
p_tenant_id, p_user_id, p_action, p_resource_type, p_resource_id,
p_old_values, p_new_values, v_changed_fields, p_metadata
) RETURNING id INTO v_log_id;
RETURN v_log_id;
END;
$$ LANGUAGE plpgsql;
-- Función para registrar cambio de entidad con versionamiento
CREATE OR REPLACE FUNCTION audit.log_entity_change(
p_tenant_id UUID,
p_entity_type VARCHAR(100),
p_entity_id UUID,
p_data_snapshot JSONB,
p_changes JSONB DEFAULT '[]',
p_changed_by UUID DEFAULT NULL,
p_change_type VARCHAR(20) DEFAULT 'update',
p_change_reason TEXT DEFAULT NULL
)
RETURNS INTEGER AS $$
DECLARE
v_version INTEGER;
v_prev_version INTEGER;
BEGIN
-- Obtener versión actual
SELECT COALESCE(MAX(version), 0) INTO v_prev_version
FROM audit.entity_changes
WHERE entity_type = p_entity_type AND entity_id = p_entity_id;
v_version := v_prev_version + 1;
INSERT INTO audit.entity_changes (
tenant_id, entity_type, entity_id, version, previous_version,
data_snapshot, changes, changed_by, change_type, change_reason
) VALUES (
p_tenant_id, p_entity_type, p_entity_id, v_version, v_prev_version,
p_data_snapshot, p_changes, p_changed_by, p_change_type, p_change_reason
);
RETURN v_version;
END;
$$ LANGUAGE plpgsql;
-- Función para obtener historial de una entidad
CREATE OR REPLACE FUNCTION audit.get_entity_history(
p_entity_type VARCHAR(100),
p_entity_id UUID,
p_limit INTEGER DEFAULT 50
)
RETURNS TABLE (
version INTEGER,
change_type VARCHAR(20),
data_snapshot JSONB,
changes JSONB,
changed_by UUID,
change_reason TEXT,
changed_at TIMESTAMPTZ
) AS $$
BEGIN
RETURN QUERY
SELECT
ec.version,
ec.change_type,
ec.data_snapshot,
ec.changes,
ec.changed_by,
ec.change_reason,
ec.changed_at
FROM audit.entity_changes ec
WHERE ec.entity_type = p_entity_type
AND ec.entity_id = p_entity_id
ORDER BY ec.version DESC
LIMIT p_limit;
END;
$$ LANGUAGE plpgsql STABLE;
-- Función para obtener snapshot de una entidad en un momento dado
CREATE OR REPLACE FUNCTION audit.get_entity_at_time(
p_entity_type VARCHAR(100),
p_entity_id UUID,
p_at_time TIMESTAMPTZ
)
RETURNS JSONB AS $$
BEGIN
RETURN (
SELECT data_snapshot
FROM audit.entity_changes
WHERE entity_type = p_entity_type
AND entity_id = p_entity_id
AND changed_at <= p_at_time
ORDER BY changed_at DESC
LIMIT 1
);
END;
$$ LANGUAGE plpgsql STABLE;
-- Función para registrar acceso a datos sensibles
CREATE OR REPLACE FUNCTION audit.log_sensitive_access(
p_tenant_id UUID,
p_user_id UUID,
p_data_type VARCHAR(100),
p_access_type VARCHAR(30),
p_entity_type VARCHAR(100) DEFAULT NULL,
p_entity_id UUID DEFAULT NULL,
p_was_authorized BOOLEAN DEFAULT TRUE,
p_access_reason TEXT DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
v_access_id UUID;
BEGIN
INSERT INTO audit.sensitive_data_access (
tenant_id, user_id, data_type, access_type,
entity_type, entity_id, was_authorized, access_reason
) VALUES (
p_tenant_id, p_user_id, p_data_type, p_access_type,
p_entity_type, p_entity_id, p_was_authorized, p_access_reason
) RETURNING id INTO v_access_id;
RETURN v_access_id;
END;
$$ LANGUAGE plpgsql;
-- Función para registrar login
CREATE OR REPLACE FUNCTION audit.log_login(
p_user_id UUID,
p_tenant_id UUID,
p_email VARCHAR(255),
p_status VARCHAR(20),
p_auth_method VARCHAR(30) DEFAULT 'password',
p_ip_address INET DEFAULT NULL,
p_user_agent TEXT DEFAULT NULL,
p_device_info JSONB DEFAULT '{}'
)
RETURNS UUID AS $$
DECLARE
v_login_id UUID;
v_is_new_device BOOLEAN := FALSE;
v_is_new_location BOOLEAN := FALSE;
v_failure_count INTEGER := 0;
BEGIN
-- Verificar si es dispositivo nuevo
IF p_device_info->>'fingerprint' IS NOT NULL THEN
SELECT NOT EXISTS (
SELECT 1 FROM audit.login_history
WHERE user_id = p_user_id
AND device_fingerprint = p_device_info->>'fingerprint'
AND status = 'success'
) INTO v_is_new_device;
END IF;
-- Contar intentos fallidos recientes
IF p_status = 'failed' THEN
SELECT COUNT(*) INTO v_failure_count
FROM audit.login_history
WHERE email = p_email
AND status = 'failed'
AND attempted_at > CURRENT_TIMESTAMP - INTERVAL '1 hour';
END IF;
INSERT INTO audit.login_history (
user_id, tenant_id, email, status, auth_method,
ip_address, user_agent,
device_fingerprint, device_type, device_os, device_browser,
is_new_device, failure_count
) VALUES (
p_user_id, p_tenant_id, p_email, p_status, p_auth_method,
p_ip_address, p_user_agent,
p_device_info->>'fingerprint',
p_device_info->>'type',
p_device_info->>'os',
p_device_info->>'browser',
v_is_new_device, v_failure_count
) RETURNING id INTO v_login_id;
RETURN v_login_id;
END;
$$ LANGUAGE plpgsql;
-- Función para obtener estadísticas de auditoría
CREATE OR REPLACE FUNCTION audit.get_stats(
p_tenant_id UUID,
p_days INTEGER DEFAULT 30
)
RETURNS TABLE (
total_actions BIGINT,
unique_users BIGINT,
actions_by_category JSONB,
actions_by_day JSONB,
top_resources JSONB,
failed_actions BIGINT
) AS $$
BEGIN
RETURN QUERY
SELECT
COUNT(*) as total_actions,
COUNT(DISTINCT al.user_id) as unique_users,
jsonb_object_agg(COALESCE(al.action_category, 'other'), cat_count) as actions_by_category,
jsonb_object_agg(day_date, day_count) as actions_by_day,
jsonb_agg(DISTINCT jsonb_build_object('type', al.resource_type, 'count', res_count)) as top_resources,
COUNT(*) FILTER (WHERE al.status = 'failure') as failed_actions
FROM audit.audit_logs al
LEFT JOIN (
SELECT action_category, COUNT(*) as cat_count
FROM audit.audit_logs
WHERE tenant_id = p_tenant_id
AND created_at > CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL
GROUP BY action_category
) cat ON cat.action_category = al.action_category
LEFT JOIN (
SELECT DATE(created_at) as day_date, COUNT(*) as day_count
FROM audit.audit_logs
WHERE tenant_id = p_tenant_id
AND created_at > CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL
GROUP BY DATE(created_at)
) days ON TRUE
LEFT JOIN (
SELECT resource_type, COUNT(*) as res_count
FROM audit.audit_logs
WHERE tenant_id = p_tenant_id
AND created_at > CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL
GROUP BY resource_type
ORDER BY res_count DESC
LIMIT 10
) res ON res.resource_type = al.resource_type
WHERE al.tenant_id = p_tenant_id
AND al.created_at > CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL
LIMIT 1;
END;
$$ LANGUAGE plpgsql STABLE;
-- Función para limpiar logs antiguos
CREATE OR REPLACE FUNCTION audit.cleanup_old_logs(
p_audit_days INTEGER DEFAULT 365,
p_login_days INTEGER DEFAULT 90,
p_export_days INTEGER DEFAULT 30
)
RETURNS TABLE (
audit_deleted INTEGER,
login_deleted INTEGER,
export_deleted INTEGER
) AS $$
DECLARE
v_audit INTEGER;
v_login INTEGER;
v_export INTEGER;
BEGIN
-- Limpiar audit_logs
DELETE FROM audit.audit_logs
WHERE created_at < CURRENT_TIMESTAMP - (p_audit_days || ' days')::INTERVAL;
GET DIAGNOSTICS v_audit = ROW_COUNT;
-- Limpiar login_history
DELETE FROM audit.login_history
WHERE attempted_at < CURRENT_TIMESTAMP - (p_login_days || ' days')::INTERVAL;
GET DIAGNOSTICS v_login = ROW_COUNT;
-- Limpiar data_exports completados/expirados
DELETE FROM audit.data_exports
WHERE (status IN ('completed', 'expired', 'failed'))
AND requested_at < CURRENT_TIMESTAMP - (p_export_days || ' days')::INTERVAL;
GET DIAGNOSTICS v_export = ROW_COUNT;
RETURN QUERY SELECT v_audit, v_login, v_export;
END;
$$ LANGUAGE plpgsql;
-- =====================
-- TRIGGER GENÉRICO PARA AUDITORÍA
-- =====================
-- Función trigger para auditoría automática
CREATE OR REPLACE FUNCTION audit.audit_trigger_func()
RETURNS TRIGGER AS $$
DECLARE
v_old_data JSONB;
v_new_data JSONB;
v_tenant_id UUID;
v_user_id UUID;
BEGIN
-- Obtener tenant_id y user_id del contexto
v_tenant_id := current_setting('app.current_tenant_id', true)::uuid;
v_user_id := current_setting('app.current_user_id', true)::uuid;
IF TG_OP = 'INSERT' THEN
v_new_data := to_jsonb(NEW);
PERFORM audit.log_entity_change(
v_tenant_id,
TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME,
(v_new_data->>'id')::uuid,
v_new_data,
'[]'::jsonb,
v_user_id,
'create'
);
RETURN NEW;
ELSIF TG_OP = 'UPDATE' THEN
v_old_data := to_jsonb(OLD);
v_new_data := to_jsonb(NEW);
-- Solo registrar si hay cambios reales
IF v_old_data IS DISTINCT FROM v_new_data THEN
PERFORM audit.log_entity_change(
v_tenant_id,
TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME,
(v_new_data->>'id')::uuid,
v_new_data,
jsonb_build_array(jsonb_build_object(
'old', v_old_data,
'new', v_new_data
)),
v_user_id,
'update'
);
END IF;
RETURN NEW;
ELSIF TG_OP = 'DELETE' THEN
v_old_data := to_jsonb(OLD);
PERFORM audit.log_entity_change(
v_tenant_id,
TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME,
(v_old_data->>'id')::uuid,
v_old_data,
'[]'::jsonb,
v_user_id,
'delete'
);
RETURN OLD;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
-- =====================
-- COMENTARIOS
-- =====================
COMMENT ON TABLE audit.audit_logs IS 'Log de auditoría general para todas las acciones';
COMMENT ON TABLE audit.entity_changes IS 'Historial de cambios con versionamiento por entidad';
COMMENT ON TABLE audit.sensitive_data_access IS 'Log de acceso a datos sensibles';
COMMENT ON TABLE audit.data_exports IS 'Registro de exportaciones de datos';
COMMENT ON TABLE audit.login_history IS 'Historial de intentos de inicio de sesión';
COMMENT ON TABLE audit.permission_changes IS 'Log de cambios en permisos y roles';
COMMENT ON TABLE audit.config_changes IS 'Log de cambios en configuración del sistema';
COMMENT ON FUNCTION audit.log IS 'Registra una acción en el log de auditoría';
COMMENT ON FUNCTION audit.log_entity_change IS 'Registra un cambio versionado de una entidad';
COMMENT ON FUNCTION audit.get_entity_history IS 'Obtiene el historial de cambios de una entidad';
COMMENT ON FUNCTION audit.get_entity_at_time IS 'Obtiene el snapshot de una entidad en un momento específico';
COMMENT ON FUNCTION audit.log_login IS 'Registra un intento de inicio de sesión';
COMMENT ON FUNCTION audit.audit_trigger_func IS 'Función trigger para auditoría automática de tablas';