- 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>
794 lines
26 KiB
PL/PgSQL
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';
|