UDT-010: Infraestructura de Auditoría y Trazabilidad — Closes #6 #14

Merged
dmolinari merged 14 commits from feature/UDT-010 into main 2026-04-16 20:30:17 +00:00
5 changed files with 883 additions and 0 deletions
Showing only changes of commit 1c79dfa0a4 - Show all commits

85
database/README.md Normal file
View File

@@ -0,0 +1,85 @@
# `database/` — SIG-CM 2.0
Todo el DDL del sistema vive acá: migraciones versionadas, stored procedures, functions, seeds.
## Estructura
```
database/
├── migrations/ # Migraciones versionadas V0XX__<descripcion>.sql (SQL puro, idempotentes)
├── procedures/ # Stored procedures — creados/alterados por UDTs específicas
├── functions/ # User-defined functions
├── seeds/ # Data de referencia no-versionada
└── schemas/ # Extracciones de schema (referencia)
```
## Migraciones aplicadas (orden obligatorio)
| Versión | Archivo | UDT | Descripción |
|---|---|---|---|
| V001 | `V001__create_usuario.sql` | UDT-001 | Tabla Usuario + IX_Usuario_Username_Activo |
| V002 | `V002__create_refresh_token.sql` | UDT-002 | Tabla RefreshToken |
| V003 | `V003__create_rol.sql` | UDT-004 | Tabla Rol + 8 roles canónicos |
| V004 | `V004__alter_usuario_rol_fk.sql` | UDT-004 | FK Usuario.Rol → Rol.Codigo |
| V005 | `V005__create_permiso.sql` | UDT-005 | Tabla Permiso + 18 permisos canónicos |
| V006 | `V006__create_rol_permiso.sql` | UDT-005 | Tabla RolPermiso + seed 36 rows |
| V007 | `V007__add_admin_permissions_udt006.sql` | UDT-006 | 3 permisos administrativos RBAC |
| V008 | `V008__add_mustchangepassword_and_indexes.sql` | UDT-008 | Usuario.MustChangePassword + IX_Usuario_Activo_Rol |
| V009 | `V009__activate_permisos_overrides.sql` | UDT-009 | Migración shape `PermisosJson` `{grant, deny}` |
| **V010** | **`V010__audit_infrastructure.sql`** | **UDT-010** | **Infra de auditoría + Temporal Tables. Ver nota abajo.** |
## Convenciones
- **SQL puro** ejecutado manualmente (no hay runner automático; decisión arquitectónica).
- **Idempotentes**: cada migración usa `IF NOT EXISTS` / `MERGE` / `IF NOT EXISTS (SELECT 1 FROM sys....)`. Re-ejecutarlas es seguro.
- **Boilerplate** inicial: `SET QUOTED_IDENTIFIER ON; SET ANSI_NULLS ON; SET NOCOUNT ON; GO`.
- **Naming**: `PK_*`, `UQ_*`, `FK_*`, `DF_*`, `CK_*`, `IX_*`.
- **Se aplican a AMBAS bases**: `SIGCM2` (dev) y `SIGCM2_Test` (integration tests). El orden debe ser idéntico.
## Cómo aplicar migraciones
### En dev (manual)
```bash
# Con sqlcmd:
sqlcmd -S TECNICA3 -d SIGCM2 -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
sqlcmd -S TECNICA3 -d SIGCM2_Test -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
```
O desde SSMS: abrir el archivo, conectar a cada base, F5.
### En integration tests
`tests/SIGCM2.TestSupport/SqlTestFixture.cs` aplica automáticamente el schema necesario en `SIGCM2_Test` al inicializar el fixture (`EnsureV008SchemaAsync`, `EnsureV009SchemaAsync`, `EnsureV010SchemaAsync`…). **NO** hace falta correr el script manualmente.
### En producción (roadmap futuro)
1. Backup completo de la base.
2. Revisar notas específicas de la migración (ver abajo).
3. Ventana de mantenimiento si la migración lo requiere.
4. `sqlcmd` + script + verify que los `PRINT` salieron esperados.
5. Smoke test post-migración.
## ⚠️ Notas especiales por migración
### V010 — Infraestructura de Auditoría
**Riesgos específicos**:
- Activa `SYSTEM_VERSIONING` en `Usuario`, `Rol`, `Permiso`, `RolPermiso`. Tablas con datos. `ALTER TABLE ADD PERIOD FOR SYSTEM_TIME` toma **Sch-M lock** (schema modification). En dev con pocos usuarios el lock es milisegundos; en prod con conexiones activas puede generar espera.
- Crea filegroups `AUDIT_HOT` y `AUDIT_COLD` con archivos físicos `<DB>_AUDIT_HOT.ndf` y `<DB>_AUDIT_COLD.ndf` en el default data path del server.
- Crea 2 partition functions + schemes mensuales (boundaries 2026-01..2027-02). El job `AuditPartitionManagerJob` (B11) extiende la ventana mes a mes.
**Para aplicar en prod**:
1. **Backup completo previo** (no negociable).
2. **Ventana de mantenimiento ≥ 10 min** (los ALTER de SYSTEM_VERSIONING son rápidos pero pueden caer en timeouts si hay transacciones largas).
3. Ejecutar el script + verificar todos los `PRINT` "created/applied".
4. Smoke test post: `SELECT TOP 1 * FROM dbo.AuditEvent` (vacío OK); `SELECT temporal_type FROM sys.tables WHERE name = 'Usuario'` (debe devolver `2` = system-versioned).
5. Si algo falla → `V010_ROLLBACK.sql` (pierde toda la historia) o restore de backup.
**Catálogo de entidades auditables** (source of truth): `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md`. Cada UDT nueva que introduzca entidades de negocio debe agregar esas tablas al catálogo y activar `SYSTEM_VERSIONING` en su migración.
## Recursos
- Design autoritativo: `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md`
- Decisión persistida (engram): `sig-cm2/audit-architecture`
- SDD artifacts UDT-010 (engram): `sdd/udt-010-auditoria-trazabilidad/{explore,proposal,spec,design,tasks,apply-progress}`

View File

@@ -0,0 +1,183 @@
-- V010_ROLLBACK.sql
-- Reversa de V010__audit_infrastructure.sql.
--
-- ⚠️ ADVERTENCIA: ejecutar este script ELIMINA toda la historia auditada.
-- - dbo.AuditEvent y dbo.SecurityEvent se dropean (junto con datos).
-- - History tables (Usuario_History, Rol_History, Permiso_History, RolPermiso_History) se dropean.
-- - Particionamiento, filegroups y archivos físicos se desmontan.
--
-- Uso intended: ROLLBACK de emergencia en entornos NO-productivos.
-- En prod futuro, este script NO se ejecuta: si hace falta revertir, se hace
-- restore de backup previo a V010.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 1. Apagar SYSTEM_VERSIONING + remover columnas PERIOD en las 4 tablas
-- ═══════════════════════════════════════════════════════════════════════
-- Usuario
IF 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 = OFF);
PRINT 'Usuario: SYSTEM_VERSIONING OFF.';
END
GO
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Usuario'))
BEGIN
ALTER TABLE dbo.Usuario DROP PERIOD FOR SYSTEM_TIME;
PRINT 'Usuario: PERIOD FOR SYSTEM_TIME dropped.';
END
GO
IF COL_LENGTH('dbo.Usuario', 'ValidFrom') IS NOT NULL
BEGIN
ALTER TABLE dbo.Usuario DROP CONSTRAINT IF EXISTS DF_Usuario_ValidFrom;
ALTER TABLE dbo.Usuario DROP CONSTRAINT IF EXISTS DF_Usuario_ValidTo;
ALTER TABLE dbo.Usuario DROP COLUMN ValidFrom, ValidTo;
PRINT 'Usuario: ValidFrom/ValidTo dropped.';
END
GO
IF OBJECT_ID(N'dbo.Usuario_History', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.Usuario_History;
PRINT 'Usuario_History dropped.';
END
GO
-- Rol
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Rol') AND temporal_type = 2)
ALTER TABLE dbo.Rol SET (SYSTEM_VERSIONING = OFF);
GO
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Rol'))
ALTER TABLE dbo.Rol DROP PERIOD FOR SYSTEM_TIME;
GO
IF COL_LENGTH('dbo.Rol', 'ValidFrom') IS NOT NULL
BEGIN
ALTER TABLE dbo.Rol DROP CONSTRAINT IF EXISTS DF_Rol_ValidFrom;
ALTER TABLE dbo.Rol DROP CONSTRAINT IF EXISTS DF_Rol_ValidTo;
ALTER TABLE dbo.Rol DROP COLUMN ValidFrom, ValidTo;
END
GO
IF OBJECT_ID(N'dbo.Rol_History', N'U') IS NOT NULL DROP TABLE dbo.Rol_History;
GO
-- Permiso
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Permiso') AND temporal_type = 2)
ALTER TABLE dbo.Permiso SET (SYSTEM_VERSIONING = OFF);
GO
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Permiso'))
ALTER TABLE dbo.Permiso DROP PERIOD FOR SYSTEM_TIME;
GO
IF COL_LENGTH('dbo.Permiso', 'ValidFrom') IS NOT NULL
BEGIN
ALTER TABLE dbo.Permiso DROP CONSTRAINT IF EXISTS DF_Permiso_ValidFrom;
ALTER TABLE dbo.Permiso DROP CONSTRAINT IF EXISTS DF_Permiso_ValidTo;
ALTER TABLE dbo.Permiso DROP COLUMN ValidFrom, ValidTo;
END
GO
IF OBJECT_ID(N'dbo.Permiso_History', N'U') IS NOT NULL DROP TABLE dbo.Permiso_History;
GO
-- RolPermiso
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.RolPermiso') AND temporal_type = 2)
ALTER TABLE dbo.RolPermiso SET (SYSTEM_VERSIONING = OFF);
GO
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.RolPermiso'))
ALTER TABLE dbo.RolPermiso DROP PERIOD FOR SYSTEM_TIME;
GO
IF COL_LENGTH('dbo.RolPermiso', 'ValidFrom') IS NOT NULL
BEGIN
ALTER TABLE dbo.RolPermiso DROP CONSTRAINT IF EXISTS DF_RolPermiso_ValidFrom;
ALTER TABLE dbo.RolPermiso DROP CONSTRAINT IF EXISTS DF_RolPermiso_ValidTo;
ALTER TABLE dbo.RolPermiso DROP COLUMN ValidFrom, ValidTo;
END
GO
IF OBJECT_ID(N'dbo.RolPermiso_History', N'U') IS NOT NULL DROP TABLE dbo.RolPermiso_History;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 2. Drop AuditEvent + SecurityEvent
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.AuditEvent', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.AuditEvent;
PRINT 'Table dbo.AuditEvent dropped.';
END
GO
IF OBJECT_ID(N'dbo.SecurityEvent', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.SecurityEvent;
PRINT 'Table dbo.SecurityEvent dropped.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 3. Drop partition schemes + functions
-- ═══════════════════════════════════════════════════════════════════════
IF EXISTS (SELECT 1 FROM sys.partition_schemes WHERE name = 'ps_AuditEvent_Monthly')
DROP PARTITION SCHEME ps_AuditEvent_Monthly;
GO
IF EXISTS (SELECT 1 FROM sys.partition_functions WHERE name = 'pf_AuditEvent_Monthly')
DROP PARTITION FUNCTION pf_AuditEvent_Monthly;
GO
IF EXISTS (SELECT 1 FROM sys.partition_schemes WHERE name = 'ps_SecurityEvent_Monthly')
DROP PARTITION SCHEME ps_SecurityEvent_Monthly;
GO
IF EXISTS (SELECT 1 FROM sys.partition_functions WHERE name = 'pf_SecurityEvent_Monthly')
DROP PARTITION FUNCTION pf_SecurityEvent_Monthly;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 4. Remover archivos físicos y filegroups
-- ═══════════════════════════════════════════════════════════════════════
DECLARE @dbName NVARCHAR(128) = DB_NAME();
DECLARE @hotLogical NVARCHAR(128) = @dbName + N'_AUDIT_HOT';
DECLARE @coldLogical NVARCHAR(128) = @dbName + N'_AUDIT_COLD';
DECLARE @sql NVARCHAR(MAX);
IF EXISTS (SELECT 1 FROM sys.database_files WHERE name = @hotLogical)
BEGIN
SET @sql = N'ALTER DATABASE CURRENT REMOVE FILE [' + @hotLogical + N'];';
EXEC sp_executesql @sql;
PRINT 'File ' + @hotLogical + ' removed.';
END
IF EXISTS (SELECT 1 FROM sys.database_files WHERE name = @coldLogical)
BEGIN
SET @sql = N'ALTER DATABASE CURRENT REMOVE FILE [' + @coldLogical + N'];';
EXEC sp_executesql @sql;
PRINT 'File ' + @coldLogical + ' removed.';
END
IF EXISTS (SELECT 1 FROM sys.filegroups WHERE name = 'AUDIT_HOT')
EXEC sp_executesql N'ALTER DATABASE CURRENT REMOVE FILEGROUP AUDIT_HOT;';
IF EXISTS (SELECT 1 FROM sys.filegroups WHERE name = 'AUDIT_COLD')
EXEC sp_executesql N'ALTER DATABASE CURRENT REMOVE FILEGROUP AUDIT_COLD;';
GO
PRINT '';
PRINT 'V010 rolled back. Audit infrastructure removed. All audit history is permanently LOST.';
GO

View File

@@ -0,0 +1,434 @@
-- 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

View File

@@ -0,0 +1,149 @@
using Dapper;
using FluentAssertions;
using Microsoft.Data.SqlClient;
using Xunit;
namespace SIGCM2.Api.Tests.Audit;
/// UDT-010 Batch 1 — V010 migration integration smoke tests.
/// Validates:
/// REQ-AUD-1: SYSTEM_VERSIONING active on catalog entities (smoke: Usuario + Usuario_History query).
/// REQ-AUD-2: dbo.AuditEvent exists, accepts valid INSERT, CHECK constraints reject invalid data.
/// REQ-SEC-1: dbo.SecurityEvent exists, CK_SecurityEvent_Result rejects invalid Result.
public sealed class V010MigrationTests : IClassFixture<SIGCM2.TestSupport.TestWebAppFactory>
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
public V010MigrationTests(SIGCM2.TestSupport.TestWebAppFactory _)
{
// Depend on the factory so SqlTestFixture.InitializeAsync runs (validates V010 applied + resets DB).
}
[Fact]
public async Task AuditEvent_Insert_WithValidData_Persists()
{
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
var correlationId = Guid.NewGuid();
var insertedId = await conn.ExecuteScalarAsync<long>("""
INSERT INTO dbo.AuditEvent (ActorUserId, Action, TargetType, TargetId, CorrelationId, Metadata)
VALUES (@ActorUserId, @Action, @TargetType, @TargetId, @CorrelationId, @Metadata);
SELECT CAST(SCOPE_IDENTITY() AS BIGINT);
""",
new
{
ActorUserId = 1,
Action = "usuario.create",
TargetType = "Usuario",
TargetId = "42",
CorrelationId = correlationId,
Metadata = """{"after":{"username":"juan"}}"""
});
insertedId.Should().BeGreaterThan(0);
var roundtrip = await conn.QuerySingleAsync<(string Action, string TargetType, string TargetId, Guid? CorrelationId)>(
"SELECT Action, TargetType, TargetId, CorrelationId FROM dbo.AuditEvent WHERE Id = @Id",
new { Id = insertedId });
roundtrip.Action.Should().Be("usuario.create");
roundtrip.TargetType.Should().Be("Usuario");
roundtrip.TargetId.Should().Be("42");
roundtrip.CorrelationId.Should().Be(correlationId);
}
[Fact]
public async Task AuditEvent_Insert_WithInvalidActionFormat_FailsCheckConstraint()
{
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
var act = async () => await conn.ExecuteAsync("""
INSERT INTO dbo.AuditEvent (ActorUserId, Action, TargetType, TargetId)
VALUES (1, 'invalid_no_dot', 'Usuario', '1');
""");
await act.Should().ThrowAsync<SqlException>()
.Where(e => e.Message.Contains("CK_AuditEvent_Action"));
}
[Fact]
public async Task AuditEvent_Insert_WithNonJsonMetadata_FailsCheckConstraint()
{
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
var act = async () => await conn.ExecuteAsync("""
INSERT INTO dbo.AuditEvent (ActorUserId, Action, TargetType, TargetId, Metadata)
VALUES (1, 'usuario.create', 'Usuario', '1', 'not-json');
""");
await act.Should().ThrowAsync<SqlException>()
.Where(e => e.Message.Contains("CK_AuditEvent_Metadata"));
}
[Fact]
public async Task SecurityEvent_Insert_WithInvalidResult_FailsCheckConstraint()
{
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
var act = async () => await conn.ExecuteAsync("""
INSERT INTO dbo.SecurityEvent (ActorUserId, Action, Result)
VALUES (1, 'login', 'neutral');
""");
await act.Should().ThrowAsync<SqlException>()
.Where(e => e.Message.Contains("CK_SecurityEvent_Result"));
}
[Fact]
public async Task Usuario_SystemVersioning_IsActive_And_TemporalQueryReturnsHistory()
{
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
// Seed: insert a scratch user and wait long enough for ValidFrom to be in the past
var username = "b1spike_" + Guid.NewGuid().ToString("N")[..8];
var newId = await conn.ExecuteScalarAsync<int>("""
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson)
VALUES (@u, 'hash', 'Temp', 'User', 'admin', '{"grant":[],"deny":[]}');
SELECT CAST(SCOPE_IDENTITY() AS INT);
""", new { u = username });
// Capture a timestamp *after* creation but *before* update
await Task.Delay(50);
var snapshotTime = DateTime.UtcNow;
await Task.Delay(50);
// Update the email
await conn.ExecuteAsync(
"UPDATE dbo.Usuario SET Email = @e WHERE Id = @id",
new { e = "new@example.com", id = newId });
// Temporal query: state at snapshotTime should NOT yet have the new email
var historicalEmail = await conn.ExecuteScalarAsync<string?>("""
SELECT Email FROM dbo.Usuario
FOR SYSTEM_TIME AS OF @ts
WHERE Id = @id
""", new { ts = snapshotTime, id = newId });
historicalEmail.Should().BeNull("the historical snapshot predates the email update");
// Current state HAS the new email
var currentEmail = await conn.ExecuteScalarAsync<string?>(
"SELECT Email FROM dbo.Usuario WHERE Id = @id",
new { id = newId });
currentEmail.Should().Be("new@example.com");
// Usuario_History must have at least one row for this user
var historyCount = await conn.ExecuteScalarAsync<int>(
"SELECT COUNT(*) FROM dbo.Usuario_History WHERE Id = @id",
new { id = newId });
historyCount.Should().BeGreaterThan(0);
// Cleanup: Respawn will reset between test runs, but this test could run alone — best-effort delete.
await conn.ExecuteAsync("DELETE FROM dbo.Usuario WHERE Id = @id", new { id = newId });
}
}

View File

@@ -32,16 +32,25 @@ public sealed class SqlTestFixture : IAsyncLifetime
// V009: update PermisosJson DEFAULT constraint and migrate legacy rows // V009: update PermisosJson DEFAULT constraint and migrate legacy rows
await EnsureV009SchemaAsync(); await EnsureV009SchemaAsync();
// V010 (UDT-010): verify audit infrastructure + temporal tables are active.
// Applied manually via: sqlcmd ... -i database/migrations/V010__audit_infrastructure.sql
await EnsureV010SchemaAsync();
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{ {
DbAdapter = DbAdapter.SqlServer, DbAdapter = DbAdapter.SqlServer,
// Rol is a lookup table seeded by migration V003 — never wipe or Usuario FK breaks. // Rol is a lookup table seeded by migration V003 — never wipe or Usuario FK breaks.
// Permiso and RolPermiso are seeded by V005/V006 — never wipe or integration tests lose the permission catalog. // Permiso and RolPermiso are seeded by V005/V006 — never wipe or integration tests lose the permission catalog.
// *_History tables: UDT-010 system-versioned — Respawn cannot DELETE them directly (engine rejects).
TablesToIgnore = TablesToIgnore =
[ [
new Respawn.Graph.Table("dbo", "Rol"), new Respawn.Graph.Table("dbo", "Rol"),
new Respawn.Graph.Table("dbo", "Permiso"), new Respawn.Graph.Table("dbo", "Permiso"),
new Respawn.Graph.Table("dbo", "RolPermiso"), new Respawn.Graph.Table("dbo", "RolPermiso"),
new Respawn.Graph.Table("dbo", "Usuario_History"),
new Respawn.Graph.Table("dbo", "Rol_History"),
new Respawn.Graph.Table("dbo", "Permiso_History"),
new Respawn.Graph.Table("dbo", "RolPermiso_History"),
] ]
}); });
@@ -232,6 +241,29 @@ public sealed class SqlTestFixture : IAsyncLifetime
await _connection.ExecuteAsync(sql); await _connection.ExecuteAsync(sql);
} }
/// <summary>
/// UDT-010 (V010): verifies that the audit infrastructure is present.
/// Does NOT re-apply the migration (the ALTER DATABASE ADD FILEGROUP/FILE + partition
/// function/scheme creation requires the full script). If missing, fails with a clear
/// message pointing to the migration script.
/// </summary>
private async Task EnsureV010SchemaAsync()
{
const string check = """
SELECT
CAST(CASE WHEN OBJECT_ID('dbo.AuditEvent','U') IS NULL THEN 0 ELSE 1 END AS BIT) AS HasAuditEvent,
CAST(CASE WHEN OBJECT_ID('dbo.SecurityEvent','U') IS NULL THEN 0 ELSE 1 END AS BIT) AS HasSecurityEvent,
CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Usuario') AND temporal_type = 2) THEN 1 ELSE 0 END AS BIT) AS UsuarioVersioned
""";
var result = await _connection.QuerySingleAsync<(bool HasAuditEvent, bool HasSecurityEvent, bool UsuarioVersioned)>(check);
if (!result.HasAuditEvent || !result.HasSecurityEvent || !result.UsuarioVersioned)
{
throw new InvalidOperationException(
"V010 audit infrastructure is not applied in the test database. " +
"Run: sqlcmd -S <server> -d SIGCM2_Test -U <user> -P <pass> -i database/migrations/V010__audit_infrastructure.sql");
}
}
/// <summary> /// <summary>
/// Applies V009 schema changes idempotently to the test database. /// Applies V009 schema changes idempotently to the test database.
/// Mirrors V009__activate_permisos_overrides.sql. /// Mirrors V009__activate_permisos_overrides.sql.