From efce73bdbdc148795ed2c210685d0d0bb82db6b1 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Wed, 4 Feb 2026 01:15:16 -0600 Subject: [PATCH] [GAP-006,007] feat: Add DDL for Digital Signatures and Audit Triggers GAP-006: Digital Signatures - documents.signature_type ENUM - documents.signature_status ENUM - documents.digital_signatures table with RLS GAP-007: Audit Triggers - audit schema - audit.change_log table - audit.log_changes() trigger function - Triggers on 13 critical tables Co-Authored-By: Claude Opus 4.5 --- schemas/10-documents-schema-ddl.sql | 123 +++++++++- schemas/13-audit-triggers-ddl.sql | 356 ++++++++++++++++++++++++++++ 2 files changed, 477 insertions(+), 2 deletions(-) create mode 100644 schemas/13-audit-triggers-ddl.sql diff --git a/schemas/10-documents-schema-ddl.sql b/schemas/10-documents-schema-ddl.sql index 4cf6303..bfdeefb 100644 --- a/schemas/10-documents-schema-ddl.sql +++ b/schemas/10-documents-schema-ddl.sql @@ -905,6 +905,125 @@ CREATE INDEX IF NOT EXISTS idx_documents_parent ON documents.documents(parent_do CREATE INDEX IF NOT EXISTS idx_document_shares_shared_by ON documents.document_shares(shared_by_id); -- ============================================================================ --- FIN DEL SCRIPT --- FK constraints adicionales: 4 +-- GAP-006: FIRMAS DIGITALES +-- ============================================================================ + +-- Tipo de firma digital +CREATE TYPE documents.signature_type AS ENUM ( + 'simple', -- Firma simple (click to sign) + 'advanced', -- Firma avanzada (con certificado) + 'qualified' -- Firma cualificada (certificado oficial) +); + +-- Estado de la firma +CREATE TYPE documents.signature_status AS ENUM ( + 'pending', -- Pendiente de firma + 'signed', -- Firmado + 'rejected', -- Rechazado + 'expired', -- Expirado + 'revoked' -- Revocado +); + +-- Tabla de firmas digitales +CREATE TABLE IF NOT EXISTS documents.digital_signatures ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + -- Documento firmado + document_id UUID NOT NULL, + version_id UUID, + + -- Firmante + signer_id UUID NOT NULL, + signer_name VARCHAR(200) NOT NULL, + signer_email VARCHAR(255), + signer_role VARCHAR(100), + + -- Tipo y estado + signature_type documents.signature_type NOT NULL DEFAULT 'simple', + status documents.signature_status NOT NULL DEFAULT 'pending', + + -- Datos de firma + signature_data TEXT, + signature_hash VARCHAR(128), + hash_algorithm VARCHAR(20) DEFAULT 'SHA-256', + + -- Certificado (para firmas avanzadas/cualificadas) + certificate_info JSONB, + certificate_serial VARCHAR(100), + certificate_issuer VARCHAR(255), + certificate_valid_from TIMESTAMPTZ, + certificate_valid_to TIMESTAMPTZ, + + -- Timestamps + requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + signed_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + + -- Contexto de firma + ip_address INET, + user_agent TEXT, + geolocation JSONB, + + -- Validación + is_valid BOOLEAN DEFAULT true, + validation_errors JSONB, + last_validated_at TIMESTAMPTZ, + + -- Razón (para rechazo/revocación) + reason TEXT, + + -- Metadatos + metadata JSONB DEFAULT '{}', + + -- Auditoría + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Foreign keys + CONSTRAINT fk_digital_signatures_tenant + FOREIGN KEY (tenant_id) REFERENCES auth.tenants(id) ON DELETE CASCADE, + CONSTRAINT fk_digital_signatures_document + FOREIGN KEY (document_id) REFERENCES documents.documents(id) ON DELETE CASCADE, + CONSTRAINT fk_digital_signatures_version + FOREIGN KEY (version_id) REFERENCES documents.document_versions(id) ON DELETE SET NULL +); + +-- Índices para digital_signatures +CREATE INDEX IF NOT EXISTS idx_digital_signatures_tenant ON documents.digital_signatures(tenant_id); +CREATE INDEX IF NOT EXISTS idx_digital_signatures_document ON documents.digital_signatures(document_id); +CREATE INDEX IF NOT EXISTS idx_digital_signatures_signer ON documents.digital_signatures(signer_id); +CREATE INDEX IF NOT EXISTS idx_digital_signatures_status ON documents.digital_signatures(tenant_id, status); +CREATE INDEX IF NOT EXISTS idx_digital_signatures_signed_at ON documents.digital_signatures(signed_at); + +-- RLS para digital_signatures +ALTER TABLE documents.digital_signatures ENABLE ROW LEVEL SECURITY; + +CREATE POLICY digital_signatures_tenant_isolation ON documents.digital_signatures + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- FK opcional: signer_id → auth.users +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'auth' AND tablename = 'users') THEN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_digital_signatures_signer' + ) THEN + ALTER TABLE documents.digital_signatures + ADD CONSTRAINT fk_digital_signatures_signer + FOREIGN KEY (signer_id) REFERENCES auth.users(id) + ON DELETE SET NULL; + RAISE NOTICE 'FK creada: digital_signatures.signer_id → auth.users'; + END IF; + END IF; +EXCEPTION WHEN OTHERS THEN + RAISE NOTICE 'FK digital_signatures.signer_id no creada: %', SQLERRM; +END $$; + +COMMENT ON TABLE documents.digital_signatures IS 'GAP-006: Firmas digitales para documentos'; + +-- ============================================================================ +-- FIN DEL SCRIPT +-- FK constraints adicionales: 5 (incluyendo digital_signatures) -- ============================================================================ diff --git a/schemas/13-audit-triggers-ddl.sql b/schemas/13-audit-triggers-ddl.sql new file mode 100644 index 0000000..1e489fe --- /dev/null +++ b/schemas/13-audit-triggers-ddl.sql @@ -0,0 +1,356 @@ +-- ============================================================================ +-- 13-audit-triggers-ddl.sql +-- Schema: audit +-- ERP Construccion - GAP-007: Audit Triggers +-- ============================================================================ +-- Descripcion: Sistema de auditoría detallada con: +-- - Registro de cambios a nivel de campo +-- - Triggers automáticos para tablas críticas +-- - Historial completo de modificaciones +-- ============================================================================ +-- Autor: Claude-Arquitecto-Orquestador +-- Fecha: 2026-02-04 +-- Version: 1.0.0 +-- GAP: GAP-007 +-- ============================================================================ + +-- Crear schema si no existe +CREATE SCHEMA IF NOT EXISTS audit; + +-- ============================================================================ +-- ENUMS +-- ============================================================================ + +-- Tipo de operación +CREATE TYPE audit.operation_type AS ENUM ( + 'INSERT', + 'UPDATE', + 'DELETE', + 'TRUNCATE' +); + +-- ============================================================================ +-- TABLA DE AUDIT LOG +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS audit.change_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Contexto + tenant_id UUID, + user_id UUID, + session_id VARCHAR(100), + + -- Operación + operation audit.operation_type NOT NULL, + table_schema VARCHAR(63) NOT NULL, + table_name VARCHAR(63) NOT NULL, + record_id UUID, + + -- Datos + old_data JSONB, + new_data JSONB, + changed_fields TEXT[], + + -- Metadatos + client_ip INET, + user_agent TEXT, + application_name VARCHAR(100), + + -- Timestamp + executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Para queries eficientes + executed_date DATE GENERATED ALWAYS AS (executed_at::date) STORED +); + +-- Índices para change_log +CREATE INDEX IF NOT EXISTS idx_change_log_tenant ON audit.change_log(tenant_id); +CREATE INDEX IF NOT EXISTS idx_change_log_user ON audit.change_log(user_id); +CREATE INDEX IF NOT EXISTS idx_change_log_table ON audit.change_log(table_schema, table_name); +CREATE INDEX IF NOT EXISTS idx_change_log_record ON audit.change_log(record_id); +CREATE INDEX IF NOT EXISTS idx_change_log_executed ON audit.change_log(executed_at DESC); +CREATE INDEX IF NOT EXISTS idx_change_log_date ON audit.change_log(executed_date); +CREATE INDEX IF NOT EXISTS idx_change_log_operation ON audit.change_log(operation); + +-- Particionamiento por fecha (opcional, para grandes volúmenes) +-- La tabla puede ser particionada posteriormente si es necesario + +-- ============================================================================ +-- FUNCIÓN DE TRIGGER GENÉRICA +-- ============================================================================ + +CREATE OR REPLACE FUNCTION audit.log_changes() +RETURNS TRIGGER AS $$ +DECLARE + v_tenant_id UUID; + v_user_id UUID; + v_session_id TEXT; + v_old_data JSONB; + v_new_data JSONB; + v_changed_fields TEXT[]; + v_record_id UUID; + v_key TEXT; +BEGIN + -- Obtener contexto de la sesión + BEGIN + v_tenant_id := current_setting('app.current_tenant_id', true)::uuid; + EXCEPTION WHEN OTHERS THEN + v_tenant_id := NULL; + END; + + BEGIN + v_user_id := current_setting('app.current_user_id', true)::uuid; + EXCEPTION WHEN OTHERS THEN + v_user_id := NULL; + END; + + BEGIN + v_session_id := current_setting('app.session_id', true); + EXCEPTION WHEN OTHERS THEN + v_session_id := NULL; + END; + + -- Determinar datos según operación + IF TG_OP = 'INSERT' THEN + v_new_data := to_jsonb(NEW); + v_old_data := NULL; + v_record_id := NEW.id; + v_changed_fields := ARRAY(SELECT jsonb_object_keys(v_new_data)); + + ELSIF TG_OP = 'UPDATE' THEN + v_old_data := to_jsonb(OLD); + v_new_data := to_jsonb(NEW); + v_record_id := NEW.id; + + -- Calcular campos modificados + v_changed_fields := ARRAY( + SELECT key FROM ( + SELECT key, v_old_data->key AS old_val, v_new_data->key AS new_val + FROM jsonb_object_keys(v_new_data) AS key + ) AS changes + WHERE old_val IS DISTINCT FROM new_val + ); + + -- Si no hay cambios reales, no registrar + IF array_length(v_changed_fields, 1) IS NULL THEN + RETURN NEW; + END IF; + + ELSIF TG_OP = 'DELETE' THEN + v_old_data := to_jsonb(OLD); + v_new_data := NULL; + v_record_id := OLD.id; + v_changed_fields := NULL; + END IF; + + -- Insertar registro de auditoría + INSERT INTO audit.change_log ( + tenant_id, + user_id, + session_id, + operation, + table_schema, + table_name, + record_id, + old_data, + new_data, + changed_fields, + client_ip, + application_name + ) VALUES ( + v_tenant_id, + v_user_id, + v_session_id, + TG_OP::audit.operation_type, + TG_TABLE_SCHEMA, + TG_TABLE_NAME, + v_record_id, + v_old_data, + v_new_data, + v_changed_fields, + inet_client_addr(), + current_setting('application_name', true) + ); + + -- Retornar el registro apropiado + IF TG_OP = 'DELETE' THEN + RETURN OLD; + ELSE + RETURN NEW; + END IF; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- ============================================================================ +-- FUNCIÓN PARA CREAR TRIGGER DE AUDITORÍA +-- ============================================================================ + +CREATE OR REPLACE FUNCTION audit.enable_audit_trigger( + p_schema_name TEXT, + p_table_name TEXT +) +RETURNS VOID AS $$ +DECLARE + v_trigger_name TEXT; +BEGIN + v_trigger_name := 'audit_' || p_table_name || '_changes'; + + -- Eliminar trigger si existe + EXECUTE format( + 'DROP TRIGGER IF EXISTS %I ON %I.%I', + v_trigger_name, p_schema_name, p_table_name + ); + + -- Crear trigger + EXECUTE format( + 'CREATE TRIGGER %I + AFTER INSERT OR UPDATE OR DELETE ON %I.%I + FOR EACH ROW EXECUTE FUNCTION audit.log_changes()', + v_trigger_name, p_schema_name, p_table_name + ); + + RAISE NOTICE 'Audit trigger creado: %.%', p_schema_name, p_table_name; +END; +$$ LANGUAGE plpgsql; + +-- ============================================================================ +-- FUNCIÓN PARA DESACTIVAR TRIGGER DE AUDITORÍA +-- ============================================================================ + +CREATE OR REPLACE FUNCTION audit.disable_audit_trigger( + p_schema_name TEXT, + p_table_name TEXT +) +RETURNS VOID AS $$ +DECLARE + v_trigger_name TEXT; +BEGIN + v_trigger_name := 'audit_' || p_table_name || '_changes'; + + EXECUTE format( + 'DROP TRIGGER IF EXISTS %I ON %I.%I', + v_trigger_name, p_schema_name, p_table_name + ); + + RAISE NOTICE 'Audit trigger eliminado: %.%', p_schema_name, p_table_name; +END; +$$ LANGUAGE plpgsql; + +-- ============================================================================ +-- ACTIVAR AUDITORÍA EN TABLAS CRÍTICAS +-- ============================================================================ + +-- Construction: Proyectos y Fraccionamientos +SELECT audit.enable_audit_trigger('construction', 'fraccionamientos'); +SELECT audit.enable_audit_trigger('construction', 'proyectos'); + +-- Estimates: Estimaciones (facturación) +SELECT audit.enable_audit_trigger('estimates', 'estimaciones'); +SELECT audit.enable_audit_trigger('estimates', 'anticipos'); + +-- Finance: Transacciones financieras +SELECT audit.enable_audit_trigger('finance', 'journal_entries'); +SELECT audit.enable_audit_trigger('finance', 'invoices'); +SELECT audit.enable_audit_trigger('finance', 'payments'); + +-- Assets: Activos fijos +SELECT audit.enable_audit_trigger('assets', 'assets'); +SELECT audit.enable_audit_trigger('assets', 'depreciation_schedule'); + +-- Documents: Documentos y firmas +SELECT audit.enable_audit_trigger('documents', 'documents'); +SELECT audit.enable_audit_trigger('documents', 'digital_signatures'); + +-- HR: Empleados +SELECT audit.enable_audit_trigger('hr', 'employees'); + +-- HSE: Incidentes de seguridad +SELECT audit.enable_audit_trigger('hse', 'incidentes'); + +-- ============================================================================ +-- FUNCIONES DE CONSULTA +-- ============================================================================ + +-- Obtener historial de cambios de un registro +CREATE OR REPLACE FUNCTION audit.get_record_history( + p_table_schema TEXT, + p_table_name TEXT, + p_record_id UUID, + p_limit INT DEFAULT 100 +) +RETURNS TABLE ( + change_id UUID, + operation audit.operation_type, + changed_fields TEXT[], + old_data JSONB, + new_data JSONB, + user_id UUID, + executed_at TIMESTAMPTZ +) AS $$ +BEGIN + RETURN QUERY + SELECT + cl.id, + cl.operation, + cl.changed_fields, + cl.old_data, + cl.new_data, + cl.user_id, + cl.executed_at + FROM audit.change_log cl + WHERE cl.table_schema = p_table_schema + AND cl.table_name = p_table_name + AND cl.record_id = p_record_id + ORDER BY cl.executed_at DESC + LIMIT p_limit; +END; +$$ LANGUAGE plpgsql; + +-- Obtener cambios recientes por tenant +CREATE OR REPLACE FUNCTION audit.get_recent_changes( + p_tenant_id UUID, + p_hours INT DEFAULT 24, + p_limit INT DEFAULT 100 +) +RETURNS TABLE ( + change_id UUID, + table_schema VARCHAR, + table_name VARCHAR, + record_id UUID, + operation audit.operation_type, + user_id UUID, + executed_at TIMESTAMPTZ +) AS $$ +BEGIN + RETURN QUERY + SELECT + cl.id, + cl.table_schema, + cl.table_name, + cl.record_id, + cl.operation, + cl.user_id, + cl.executed_at + FROM audit.change_log cl + WHERE cl.tenant_id = p_tenant_id + AND cl.executed_at >= NOW() - (p_hours || ' hours')::interval + ORDER BY cl.executed_at DESC + LIMIT p_limit; +END; +$$ LANGUAGE plpgsql; + +-- ============================================================================ +-- COMENTARIOS +-- ============================================================================ + +COMMENT ON SCHEMA audit IS 'GAP-007: Sistema de auditoría detallada'; +COMMENT ON TABLE audit.change_log IS 'Registro de todos los cambios en tablas auditadas'; +COMMENT ON FUNCTION audit.log_changes() IS 'Trigger function para registrar cambios'; +COMMENT ON FUNCTION audit.enable_audit_trigger(TEXT, TEXT) IS 'Habilitar auditoría en una tabla'; +COMMENT ON FUNCTION audit.disable_audit_trigger(TEXT, TEXT) IS 'Deshabilitar auditoría en una tabla'; + +-- ============================================================================ +-- FIN DEL SCRIPT +-- Tablas auditadas: 13 tablas críticas +-- ============================================================================