-- V010__audit_infrastructure.sql -- UDT-010: Infraestructura de Auditoría y Trazabilidad (Fase 0.5 — transversal). -- -- Cambios: -- 1. Filegroups AUDIT_HOT y AUDIT_COLD (archivos físicos en default data path). -- 2. Partition functions + schemes mensuales (RANGE RIGHT) para AuditEvent y SecurityEvent. -- 3. dbo.AuditEvent particionada + 4 índices + CHECK constraints. -- 4. dbo.SecurityEvent particionada + 3 índices + CHECK constraints. -- 5. SYSTEM_VERSIONING en dbo.Usuario, dbo.Rol, dbo.Permiso, dbo.RolPermiso -- con HISTORY_RETENTION_PERIOD = 10 YEARS y PAGE compression en history tables. -- -- Source of truth del diseño: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md -- Idempotente: seguro para re-ejecutar. -- Reversa: V010_ROLLBACK.sql (pierde TODA la historia auditada). -- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests). Para producción futuro, -- revisar database/README.md (ventana de mantenimiento + backup previo). SET QUOTED_IDENTIFIER ON; SET ANSI_NULLS ON; SET NOCOUNT ON; GO -- ═══════════════════════════════════════════════════════════════════════ -- 1. FILEGROUPS + ARCHIVOS FÍSICOS -- ═══════════════════════════════════════════════════════════════════════ -- Usamos el default data path del server + DB_NAME como prefijo lógico, -- así SIGCM2 y SIGCM2_Test coexisten sin colisión de logical file names. DECLARE @dbName NVARCHAR(128) = DB_NAME(); DECLARE @dataPath NVARCHAR(260) = CAST(SERVERPROPERTY('InstanceDefaultDataPath') AS NVARCHAR(260)); DECLARE @hotLogical NVARCHAR(128) = @dbName + N'_AUDIT_HOT'; DECLARE @coldLogical NVARCHAR(128) = @dbName + N'_AUDIT_COLD'; DECLARE @hotPhysical NVARCHAR(260) = @dataPath + @hotLogical + N'.ndf'; DECLARE @coldPhysical NVARCHAR(260) = @dataPath + @coldLogical + N'.ndf'; DECLARE @sql NVARCHAR(MAX); -- Filegroup AUDIT_HOT IF NOT EXISTS (SELECT 1 FROM sys.filegroups WHERE name = 'AUDIT_HOT') BEGIN EXEC sp_executesql N'ALTER DATABASE CURRENT ADD FILEGROUP AUDIT_HOT;'; PRINT 'Filegroup AUDIT_HOT created.'; END ELSE PRINT 'Filegroup AUDIT_HOT already exists — skip.'; IF NOT EXISTS (SELECT 1 FROM sys.database_files WHERE name = @hotLogical) BEGIN SET @sql = N'ALTER DATABASE CURRENT ADD FILE ( NAME = N''' + @hotLogical + N''', FILENAME = N''' + @hotPhysical + N''', SIZE = 64MB, FILEGROWTH = 64MB ) TO FILEGROUP AUDIT_HOT;'; EXEC sp_executesql @sql; PRINT 'File ' + @hotLogical + ' added to filegroup AUDIT_HOT.'; END ELSE PRINT 'File ' + @hotLogical + ' already exists — skip.'; -- Filegroup AUDIT_COLD IF NOT EXISTS (SELECT 1 FROM sys.filegroups WHERE name = 'AUDIT_COLD') BEGIN EXEC sp_executesql N'ALTER DATABASE CURRENT ADD FILEGROUP AUDIT_COLD;'; PRINT 'Filegroup AUDIT_COLD created.'; END ELSE PRINT 'Filegroup AUDIT_COLD already exists — skip.'; IF NOT EXISTS (SELECT 1 FROM sys.database_files WHERE name = @coldLogical) BEGIN SET @sql = N'ALTER DATABASE CURRENT ADD FILE ( NAME = N''' + @coldLogical + N''', FILENAME = N''' + @coldPhysical + N''', SIZE = 64MB, FILEGROWTH = 64MB ) TO FILEGROUP AUDIT_COLD;'; EXEC sp_executesql @sql; PRINT 'File ' + @coldLogical + ' added to filegroup AUDIT_COLD.'; END ELSE PRINT 'File ' + @coldLogical + ' already exists — skip.'; GO -- ═══════════════════════════════════════════════════════════════════════ -- 2. PARTITION FUNCTIONS + SCHEMES (mensuales, RANGE RIGHT) -- ═══════════════════════════════════════════════════════════════════════ -- Boundaries iniciales: 14 valores de 2026-01-01 a 2027-02-01 → 15 particiones. -- AuditPartitionManagerJob (B11) extiende mes a mes automáticamente. IF NOT EXISTS (SELECT 1 FROM sys.partition_functions WHERE name = 'pf_AuditEvent_Monthly') BEGIN CREATE PARTITION FUNCTION pf_AuditEvent_Monthly (DATETIME2(3)) AS RANGE RIGHT FOR VALUES ( '2026-01-01T00:00:00.000', '2026-02-01T00:00:00.000', '2026-03-01T00:00:00.000', '2026-04-01T00:00:00.000', '2026-05-01T00:00:00.000', '2026-06-01T00:00:00.000', '2026-07-01T00:00:00.000', '2026-08-01T00:00:00.000', '2026-09-01T00:00:00.000', '2026-10-01T00:00:00.000', '2026-11-01T00:00:00.000', '2026-12-01T00:00:00.000', '2027-01-01T00:00:00.000', '2027-02-01T00:00:00.000' ); PRINT 'Partition function pf_AuditEvent_Monthly created.'; END ELSE PRINT 'Partition function pf_AuditEvent_Monthly already exists — skip.'; GO IF NOT EXISTS (SELECT 1 FROM sys.partition_schemes WHERE name = 'ps_AuditEvent_Monthly') BEGIN CREATE PARTITION SCHEME ps_AuditEvent_Monthly AS PARTITION pf_AuditEvent_Monthly ALL TO ([AUDIT_HOT]); PRINT 'Partition scheme ps_AuditEvent_Monthly created.'; END ELSE PRINT 'Partition scheme ps_AuditEvent_Monthly already exists — skip.'; GO IF NOT EXISTS (SELECT 1 FROM sys.partition_functions WHERE name = 'pf_SecurityEvent_Monthly') BEGIN CREATE PARTITION FUNCTION pf_SecurityEvent_Monthly (DATETIME2(3)) AS RANGE RIGHT FOR VALUES ( '2026-01-01T00:00:00.000', '2026-02-01T00:00:00.000', '2026-03-01T00:00:00.000', '2026-04-01T00:00:00.000', '2026-05-01T00:00:00.000', '2026-06-01T00:00:00.000', '2026-07-01T00:00:00.000', '2026-08-01T00:00:00.000', '2026-09-01T00:00:00.000', '2026-10-01T00:00:00.000', '2026-11-01T00:00:00.000', '2026-12-01T00:00:00.000', '2027-01-01T00:00:00.000', '2027-02-01T00:00:00.000' ); PRINT 'Partition function pf_SecurityEvent_Monthly created.'; END ELSE PRINT 'Partition function pf_SecurityEvent_Monthly already exists — skip.'; GO IF NOT EXISTS (SELECT 1 FROM sys.partition_schemes WHERE name = 'ps_SecurityEvent_Monthly') BEGIN CREATE PARTITION SCHEME ps_SecurityEvent_Monthly AS PARTITION pf_SecurityEvent_Monthly ALL TO ([AUDIT_HOT]); PRINT 'Partition scheme ps_SecurityEvent_Monthly created.'; END ELSE PRINT 'Partition scheme ps_SecurityEvent_Monthly already exists — skip.'; GO -- ═══════════════════════════════════════════════════════════════════════ -- 3. dbo.AuditEvent (eventos de dominio, retention 10 años) -- ═══════════════════════════════════════════════════════════════════════ IF OBJECT_ID(N'dbo.AuditEvent', N'U') IS NULL BEGIN CREATE TABLE dbo.AuditEvent ( Id BIGINT IDENTITY(1,1) NOT NULL, OccurredAt DATETIME2(3) NOT NULL CONSTRAINT DF_AuditEvent_OccurredAt DEFAULT(SYSUTCDATETIME()), ActorUserId INT NULL, -- NULL solo para eventos del sistema (jobs) ActorRoleId INT NULL, -- rol efectivo al momento del evento (denormalizado) Action VARCHAR(100) NOT NULL, -- "usuario.create", "cliente.deactivate" TargetType VARCHAR(50) NOT NULL, -- "Usuario", "Cliente", "Factura" TargetId VARCHAR(100) NOT NULL, -- PK del target como string (soporta INT/GUID) CorrelationId UNIQUEIDENTIFIER NULL, -- linkea eventos de una misma operación IpAddress VARCHAR(45) NULL, -- IPv4/IPv6 UserAgent VARCHAR(500) NULL, Metadata NVARCHAR(MAX) NULL, -- JSON libre (ya sanitizado por la app) CONSTRAINT PK_AuditEvent PRIMARY KEY CLUSTERED (OccurredAt, Id) ON ps_AuditEvent_Monthly(OccurredAt), CONSTRAINT CK_AuditEvent_Action CHECK (Action LIKE '%.%'), CONSTRAINT CK_AuditEvent_Metadata CHECK (Metadata IS NULL OR ISJSON(Metadata) = 1) ) ON ps_AuditEvent_Monthly(OccurredAt); PRINT 'Table dbo.AuditEvent created (partitioned monthly on OccurredAt).'; END ELSE PRINT 'Table dbo.AuditEvent already exists — skip.'; GO -- Índices (cubren 95% de queries: actor / target / action / correlation) IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditEvent_Actor' AND object_id = OBJECT_ID('dbo.AuditEvent')) BEGIN CREATE NONCLUSTERED INDEX IX_AuditEvent_Actor ON dbo.AuditEvent(ActorUserId, OccurredAt DESC) INCLUDE (Action, TargetType, TargetId, CorrelationId) WITH (DATA_COMPRESSION = PAGE) ON ps_AuditEvent_Monthly(OccurredAt); PRINT 'Index IX_AuditEvent_Actor created.'; END GO IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditEvent_Target' AND object_id = OBJECT_ID('dbo.AuditEvent')) BEGIN CREATE NONCLUSTERED INDEX IX_AuditEvent_Target ON dbo.AuditEvent(TargetType, TargetId, OccurredAt DESC) INCLUDE (ActorUserId, Action, CorrelationId) WITH (DATA_COMPRESSION = PAGE) ON ps_AuditEvent_Monthly(OccurredAt); PRINT 'Index IX_AuditEvent_Target created.'; END GO IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditEvent_Action' AND object_id = OBJECT_ID('dbo.AuditEvent')) BEGIN CREATE NONCLUSTERED INDEX IX_AuditEvent_Action ON dbo.AuditEvent(Action, OccurredAt DESC) INCLUDE (ActorUserId, TargetType, TargetId) WITH (DATA_COMPRESSION = PAGE) ON ps_AuditEvent_Monthly(OccurredAt); PRINT 'Index IX_AuditEvent_Action created.'; END GO IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditEvent_Correlation' AND object_id = OBJECT_ID('dbo.AuditEvent')) BEGIN CREATE NONCLUSTERED INDEX IX_AuditEvent_Correlation ON dbo.AuditEvent(CorrelationId) WHERE CorrelationId IS NOT NULL ON ps_AuditEvent_Monthly(OccurredAt); PRINT 'Index IX_AuditEvent_Correlation (filtered) created.'; END GO -- ═══════════════════════════════════════════════════════════════════════ -- 4. dbo.SecurityEvent (eventos de seguridad, retention 5 años) -- ═══════════════════════════════════════════════════════════════════════ IF OBJECT_ID(N'dbo.SecurityEvent', N'U') IS NULL BEGIN CREATE TABLE dbo.SecurityEvent ( Id BIGINT IDENTITY(1,1) NOT NULL, OccurredAt DATETIME2(3) NOT NULL CONSTRAINT DF_SecurityEvent_OccurredAt DEFAULT(SYSUTCDATETIME()), ActorUserId INT NULL, -- NULL si login falló y user no existe AttemptedUsername VARCHAR(256) NULL, -- para login failures SessionId UNIQUEIDENTIFIER NULL, Action VARCHAR(100) NOT NULL, -- "login", "logout", "refresh.reuse_detected", "permission.denied" Result VARCHAR(20) NOT NULL, -- "success" | "failure" FailureReason VARCHAR(200) NULL, -- "invalid_password", "account_locked" IpAddress VARCHAR(45) NULL, UserAgent VARCHAR(500) NULL, Metadata NVARCHAR(MAX) NULL, CONSTRAINT PK_SecurityEvent PRIMARY KEY CLUSTERED (OccurredAt, Id) ON ps_SecurityEvent_Monthly(OccurredAt), CONSTRAINT CK_SecurityEvent_Result CHECK (Result IN ('success','failure')), CONSTRAINT CK_SecurityEvent_Metadata CHECK (Metadata IS NULL OR ISJSON(Metadata) = 1) ) ON ps_SecurityEvent_Monthly(OccurredAt); PRINT 'Table dbo.SecurityEvent created (partitioned monthly on OccurredAt).'; END ELSE PRINT 'Table dbo.SecurityEvent already exists — skip.'; GO IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_SecurityEvent_Actor' AND object_id = OBJECT_ID('dbo.SecurityEvent')) BEGIN CREATE NONCLUSTERED INDEX IX_SecurityEvent_Actor ON dbo.SecurityEvent(ActorUserId, OccurredAt DESC) INCLUDE (Action, Result, SessionId) WITH (DATA_COMPRESSION = PAGE) ON ps_SecurityEvent_Monthly(OccurredAt); PRINT 'Index IX_SecurityEvent_Actor created.'; END GO IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_SecurityEvent_Action_Result' AND object_id = OBJECT_ID('dbo.SecurityEvent')) BEGIN CREATE NONCLUSTERED INDEX IX_SecurityEvent_Action_Result ON dbo.SecurityEvent(Action, Result, OccurredAt DESC) INCLUDE (ActorUserId, IpAddress) WITH (DATA_COMPRESSION = PAGE) ON ps_SecurityEvent_Monthly(OccurredAt); PRINT 'Index IX_SecurityEvent_Action_Result created.'; END GO IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_SecurityEvent_Ip_Failure' AND object_id = OBJECT_ID('dbo.SecurityEvent')) BEGIN CREATE NONCLUSTERED INDEX IX_SecurityEvent_Ip_Failure ON dbo.SecurityEvent(IpAddress, OccurredAt DESC) WHERE Result = 'failure' ON ps_SecurityEvent_Monthly(OccurredAt); PRINT 'Index IX_SecurityEvent_Ip_Failure (filtered) created.'; END GO -- ═══════════════════════════════════════════════════════════════════════ -- 5. SYSTEM_VERSIONING — Usuario, Rol, Permiso, RolPermiso -- ═══════════════════════════════════════════════════════════════════════ -- Patrón: (a) agregar PERIOD FOR SYSTEM_TIME con columnas HIDDEN, -- (b) activar SYSTEM_VERSIONING con HISTORY_RETENTION_PERIOD 10 YEARS, -- (c) rebuild history con PAGE compression. -- Tablas con datos: los registros existentes reciben ValidFrom = instante del ALTER. -- ─── Usuario ─────────────────────────────────────────────────────────── IF COL_LENGTH('dbo.Usuario', 'ValidFrom') IS NULL BEGIN ALTER TABLE dbo.Usuario ADD ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL CONSTRAINT DF_Usuario_ValidFrom DEFAULT(SYSUTCDATETIME()), ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL CONSTRAINT DF_Usuario_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); PRINT 'Usuario: PERIOD FOR SYSTEM_TIME added.'; END GO IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Usuario') AND temporal_type = 2) BEGIN ALTER TABLE dbo.Usuario SET (SYSTEM_VERSIONING = ON ( HISTORY_TABLE = dbo.Usuario_History, HISTORY_RETENTION_PERIOD = 10 YEARS )); PRINT 'Usuario: SYSTEM_VERSIONING = ON (history: dbo.Usuario_History, retention: 10 years).'; END ELSE PRINT 'Usuario: SYSTEM_VERSIONING already ON — skip.'; GO IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Usuario_History' AND schema_id = SCHEMA_ID('dbo')) AND NOT EXISTS ( SELECT 1 FROM sys.partitions p JOIN sys.tables t ON t.object_id = p.object_id WHERE t.name = 'Usuario_History' AND p.data_compression = 2 ) BEGIN ALTER TABLE dbo.Usuario_History REBUILD WITH (DATA_COMPRESSION = PAGE); PRINT 'Usuario_History: rebuilt with PAGE compression.'; END GO -- ─── Rol ─────────────────────────────────────────────────────────────── IF COL_LENGTH('dbo.Rol', 'ValidFrom') IS NULL BEGIN ALTER TABLE dbo.Rol ADD ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL CONSTRAINT DF_Rol_ValidFrom DEFAULT(SYSUTCDATETIME()), ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL CONSTRAINT DF_Rol_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); PRINT 'Rol: PERIOD FOR SYSTEM_TIME added.'; END GO IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Rol') AND temporal_type = 2) BEGIN ALTER TABLE dbo.Rol SET (SYSTEM_VERSIONING = ON ( HISTORY_TABLE = dbo.Rol_History, HISTORY_RETENTION_PERIOD = 10 YEARS )); PRINT 'Rol: SYSTEM_VERSIONING = ON.'; END GO IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Rol_History' AND schema_id = SCHEMA_ID('dbo')) AND NOT EXISTS ( SELECT 1 FROM sys.partitions p JOIN sys.tables t ON t.object_id = p.object_id WHERE t.name = 'Rol_History' AND p.data_compression = 2 ) BEGIN ALTER TABLE dbo.Rol_History REBUILD WITH (DATA_COMPRESSION = PAGE); PRINT 'Rol_History: rebuilt with PAGE compression.'; END GO -- ─── Permiso ─────────────────────────────────────────────────────────── IF COL_LENGTH('dbo.Permiso', 'ValidFrom') IS NULL BEGIN ALTER TABLE dbo.Permiso ADD ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL CONSTRAINT DF_Permiso_ValidFrom DEFAULT(SYSUTCDATETIME()), ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL CONSTRAINT DF_Permiso_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); PRINT 'Permiso: PERIOD FOR SYSTEM_TIME added.'; END GO IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Permiso') AND temporal_type = 2) BEGIN ALTER TABLE dbo.Permiso SET (SYSTEM_VERSIONING = ON ( HISTORY_TABLE = dbo.Permiso_History, HISTORY_RETENTION_PERIOD = 10 YEARS )); PRINT 'Permiso: SYSTEM_VERSIONING = ON.'; END GO IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Permiso_History' AND schema_id = SCHEMA_ID('dbo')) AND NOT EXISTS ( SELECT 1 FROM sys.partitions p JOIN sys.tables t ON t.object_id = p.object_id WHERE t.name = 'Permiso_History' AND p.data_compression = 2 ) BEGIN ALTER TABLE dbo.Permiso_History REBUILD WITH (DATA_COMPRESSION = PAGE); PRINT 'Permiso_History: rebuilt with PAGE compression.'; END GO -- ─── RolPermiso ──────────────────────────────────────────────────────── IF COL_LENGTH('dbo.RolPermiso', 'ValidFrom') IS NULL BEGIN ALTER TABLE dbo.RolPermiso ADD ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL CONSTRAINT DF_RolPermiso_ValidFrom DEFAULT(SYSUTCDATETIME()), ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL CONSTRAINT DF_RolPermiso_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); PRINT 'RolPermiso: PERIOD FOR SYSTEM_TIME added.'; END GO IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.RolPermiso') AND temporal_type = 2) BEGIN ALTER TABLE dbo.RolPermiso SET (SYSTEM_VERSIONING = ON ( HISTORY_TABLE = dbo.RolPermiso_History, HISTORY_RETENTION_PERIOD = 10 YEARS )); PRINT 'RolPermiso: SYSTEM_VERSIONING = ON.'; END GO IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'RolPermiso_History' AND schema_id = SCHEMA_ID('dbo')) AND NOT EXISTS ( SELECT 1 FROM sys.partitions p JOIN sys.tables t ON t.object_id = p.object_id WHERE t.name = 'RolPermiso_History' AND p.data_compression = 2 ) BEGIN ALTER TABLE dbo.RolPermiso_History REBUILD WITH (DATA_COMPRESSION = PAGE); PRINT 'RolPermiso_History: rebuilt with PAGE compression.'; END GO PRINT ''; PRINT 'V010 applied successfully — audit infrastructure + temporal tables active.'; PRINT 'Next: runs in B2 onwards (Application.Audit abstractions).'; GO