diff --git a/Directory.Packages.props b/Directory.Packages.props index 332e9a2..d3074f0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -15,6 +15,7 @@ + diff --git a/src/api/SIGCM2.Api/Program.cs b/src/api/SIGCM2.Api/Program.cs index 69fdac1..92ab9f8 100644 --- a/src/api/SIGCM2.Api/Program.cs +++ b/src/api/SIGCM2.Api/Program.cs @@ -6,6 +6,7 @@ using SIGCM2.Api.HealthChecks; using SIGCM2.Api.Middleware; using SIGCM2.Application; using SIGCM2.Infrastructure; +using SIGCM2.Infrastructure.Audit.Jobs; using SIGCM2.Api.Filters; // Bootstrap logger — before DI is built @@ -25,6 +26,11 @@ builder.Host.UseSerilog((ctx, lc) => lc builder.Services.AddApplication(); builder.Services.AddInfrastructure(builder.Configuration); +// UDT-010: Quartz.NET + 3 audit maintenance jobs (partition, retention, integrity). +// Disabled in Testing environment to keep integration tests deterministic. +if (!builder.Environment.IsEnvironment("Testing")) + builder.Services.AddAuditMaintenance(builder.Configuration); + // Authorization — handler lives in Api layer; DO NOT move to Infrastructure DI builder.Services.AddAuthorization(); builder.Services.AddScoped(); diff --git a/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditIntegrityCheckJob.cs b/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditIntegrityCheckJob.cs new file mode 100644 index 0000000..f6b2c3e --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditIntegrityCheckJob.cs @@ -0,0 +1,95 @@ +using Dapper; +using Microsoft.Extensions.Logging; +using Quartz; +using SIGCM2.Application.Audit; +using SIGCM2.Infrastructure.Persistence; + +namespace SIGCM2.Infrastructure.Audit.Jobs; + +/// +/// UDT-010 (#REQ-AUD-6) — weekly integrity verification: +/// - Validates SYSTEM_VERSIONING is ON in all cataloged tables. +/// - Validates partitions exist for the next 3 months on both event tables. +/// - Emits a SecurityEvent 'system.integrity_alert' with Result=failure when any +/// check fails; logs success otherwise. +/// - Intended schedule: cron '0 0 1 ? * SUN' (every Sunday at 01:00 UTC). +/// +[DisallowConcurrentExecution] +public sealed class AuditIntegrityCheckJob : IJob +{ + public const string CronSchedule = "0 0 1 ? * SUN"; + + private readonly SqlConnectionFactory _factory; + private readonly ISecurityEventLogger _security; + private readonly ILogger _logger; + + public AuditIntegrityCheckJob( + SqlConnectionFactory factory, + ISecurityEventLogger security, + ILogger logger) + { + _factory = factory; + _security = security; + _logger = logger; + } + + public async Task Execute(IJobExecutionContext context) + { + var ct = context.CancellationToken; + var failures = new List(); + + await using var conn = _factory.CreateConnection(); + await conn.OpenAsync(ct); + + // 1. SYSTEM_VERSIONING still ON + var missing = (await conn.QueryAsync(""" + SELECT name FROM sys.tables + WHERE name IN ('Usuario','Rol','Permiso','RolPermiso') AND temporal_type <> 2; + """)).ToList(); + if (missing.Any()) + failures.Add($"system_versioning_missing:{string.Join(',', missing)}"); + + // 2. Next 3 months have partitions in both event tables + var now = DateTime.UtcNow; + var required = new[] + { + new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(1), + new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(2), + new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(3), + }; + + foreach (var pf in new[] { "pf_AuditEvent_Monthly", "pf_SecurityEvent_Monthly" }) + { + var existingBoundaries = (await conn.QueryAsync(""" + SELECT CAST(prv.value AS DATETIME2(3)) + FROM sys.partition_functions pf + JOIN sys.partition_range_values prv ON prv.function_id = pf.function_id + WHERE pf.name = @Name; + """, new { Name = pf })).ToHashSet(); + + foreach (var req in required) + { + if (!existingBoundaries.Contains(req)) + failures.Add($"partition_missing:{pf}:{req:yyyy-MM-dd}"); + } + } + + if (failures.Any()) + { + _logger.LogError("AuditIntegrityCheckJob detected {Count} integrity failures: {Failures}", + failures.Count, string.Join(" | ", failures)); + + await _security.LogAsync( + action: "system.integrity_alert", + result: "failure", + actorUserId: null, + failureReason: "integrity_check_failed", + metadata: new { failures, checkedAt = now }, + ct: ct); + } + else + { + _logger.LogInformation("AuditIntegrityCheckJob completed — all checks passed"); + } + } +} diff --git a/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditMaintenanceRegistration.cs b/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditMaintenanceRegistration.cs new file mode 100644 index 0000000..17c182c --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditMaintenanceRegistration.cs @@ -0,0 +1,43 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Quartz; + +namespace SIGCM2.Infrastructure.Audit.Jobs; + +/// UDT-010 (#REQ-AUD-6) — DI extension to register Quartz + the 3 audit maintenance jobs. +/// Call from Program.cs: builder.Services.AddAuditMaintenance(builder.Configuration). +public static class AuditMaintenanceRegistration +{ + public static IServiceCollection AddAuditMaintenance( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddQuartz(q => + { + var partitionKey = new JobKey(nameof(AuditPartitionManagerJob)); + q.AddJob(j => j.WithIdentity(partitionKey)); + q.AddTrigger(t => t + .ForJob(partitionKey) + .WithIdentity($"{nameof(AuditPartitionManagerJob)}-trigger") + .WithCronSchedule(AuditPartitionManagerJob.CronSchedule, x => x.InTimeZone(TimeZoneInfo.Utc))); + + var retentionKey = new JobKey(nameof(AuditRetentionEnforcerJob)); + q.AddJob(j => j.WithIdentity(retentionKey)); + q.AddTrigger(t => t + .ForJob(retentionKey) + .WithIdentity($"{nameof(AuditRetentionEnforcerJob)}-trigger") + .WithCronSchedule(AuditRetentionEnforcerJob.CronSchedule, x => x.InTimeZone(TimeZoneInfo.Utc))); + + var integrityKey = new JobKey(nameof(AuditIntegrityCheckJob)); + q.AddJob(j => j.WithIdentity(integrityKey)); + q.AddTrigger(t => t + .ForJob(integrityKey) + .WithIdentity($"{nameof(AuditIntegrityCheckJob)}-trigger") + .WithCronSchedule(AuditIntegrityCheckJob.CronSchedule, x => x.InTimeZone(TimeZoneInfo.Utc))); + }); + + services.AddQuartzHostedService(opts => opts.WaitForJobsToComplete = true); + + return services; + } +} diff --git a/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditPartitionManagerJob.cs b/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditPartitionManagerJob.cs new file mode 100644 index 0000000..e980787 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditPartitionManagerJob.cs @@ -0,0 +1,64 @@ +using Dapper; +using Microsoft.Extensions.Logging; +using Quartz; +using SIGCM2.Infrastructure.Persistence; + +namespace SIGCM2.Infrastructure.Audit.Jobs; + +/// +/// UDT-010 (#REQ-AUD-6) — monthly maintenance: +/// - Extends the forward boundary of pf_AuditEvent_Monthly and pf_SecurityEvent_Monthly +/// so the next month always has a partition ready (SPLIT RANGE). +/// - Intended schedule: cron '0 0 2 1 * ?' (day 1 each month at 02:00 UTC). +/// - Idempotent: only splits if the target boundary does not yet exist. +/// +[DisallowConcurrentExecution] +public sealed class AuditPartitionManagerJob : IJob +{ + public const string CronSchedule = "0 0 2 1 * ?"; + + private readonly SqlConnectionFactory _factory; + private readonly ILogger _logger; + + public AuditPartitionManagerJob(SqlConnectionFactory factory, ILogger logger) + { + _factory = factory; + _logger = logger; + } + + public async Task Execute(IJobExecutionContext context) + { + var ct = context.CancellationToken; + await using var conn = _factory.CreateConnection(); + await conn.OpenAsync(ct); + + // Target: boundary for "next month + 1" (so the next month is always pre-created and we + // keep at least one boundary ahead after the rotation). + var now = DateTime.UtcNow; + var target = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(2); + + var affected = 0; + foreach (var pf in new[] { "pf_AuditEvent_Monthly", "pf_SecurityEvent_Monthly" }) + { + var exists = await conn.ExecuteScalarAsync(""" + SELECT COUNT(*) + FROM sys.partition_functions pf + JOIN sys.partition_range_values prv ON prv.function_id = pf.function_id + WHERE pf.name = @Name AND CAST(prv.value AS DATETIME2(3)) = @Target; + """, new { Name = pf, Target = target }); + + if (exists == 0) + { + // Parameterized partition function name would require dynamic SQL; whitelisted above. + var sql = $"ALTER PARTITION FUNCTION {pf}() SPLIT RANGE (@Target);"; + await conn.ExecuteAsync(sql, new { Target = target }); + affected++; + _logger.LogInformation("Partition boundary {Boundary:yyyy-MM-dd} added to {Function}", target, pf); + } + } + + _logger.LogInformation( + "AuditPartitionManagerJob completed — {Affected} partition function(s) extended (target: {Target:yyyy-MM-dd})", + affected, target); + } +} diff --git a/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditRetentionEnforcerJob.cs b/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditRetentionEnforcerJob.cs new file mode 100644 index 0000000..054c06a --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditRetentionEnforcerJob.cs @@ -0,0 +1,57 @@ +using Dapper; +using Microsoft.Extensions.Logging; +using Quartz; +using SIGCM2.Infrastructure.Persistence; + +namespace SIGCM2.Infrastructure.Audit.Jobs; + +/// +/// UDT-010 (#REQ-AUD-6, #REQ-SEC-5) — annual retention enforcement: +/// - Purges rows from dbo.AuditEvent older than 10 years. +/// - Purges rows from dbo.SecurityEvent older than 5 years. +/// - Temporal history tables are purged automatically by the engine via +/// HISTORY_RETENTION_PERIOD = 10 YEARS configured in V010. +/// - Intended schedule: cron '0 0 3 1 1 ?' (Jan 1 at 03:00 UTC). +/// +/// Row-based DELETE is the conservative choice for the first generation of this +/// job — avoids requiring filegroup switching logic. When volumes warrant, the +/// job can be upgraded to SWITCH OUT + DROP for partition-level drop. +/// +[DisallowConcurrentExecution] +public sealed class AuditRetentionEnforcerJob : IJob +{ + public const string CronSchedule = "0 0 3 1 1 ?"; + + private readonly SqlConnectionFactory _factory; + private readonly ILogger _logger; + + public AuditRetentionEnforcerJob(SqlConnectionFactory factory, ILogger logger) + { + _factory = factory; + _logger = logger; + } + + public async Task Execute(IJobExecutionContext context) + { + var ct = context.CancellationToken; + await using var conn = _factory.CreateConnection(); + await conn.OpenAsync(ct); + + var now = DateTime.UtcNow; + var auditCutoff = now.AddYears(-10); + var securityCutoff = now.AddYears(-5); + + var auditDeleted = await conn.ExecuteAsync( + "DELETE FROM dbo.AuditEvent WHERE OccurredAt < @Cutoff;", + new { Cutoff = auditCutoff }); + + var securityDeleted = await conn.ExecuteAsync( + "DELETE FROM dbo.SecurityEvent WHERE OccurredAt < @Cutoff;", + new { Cutoff = securityCutoff }); + + _logger.LogInformation( + "AuditRetentionEnforcerJob completed — AuditEvent purged {AuditDeleted} rows (< {AuditCutoff:yyyy-MM-dd}), " + + "SecurityEvent purged {SecurityDeleted} rows (< {SecurityCutoff:yyyy-MM-dd})", + auditDeleted, auditCutoff, securityDeleted, securityCutoff); + } +} diff --git a/src/api/SIGCM2.Infrastructure/SIGCM2.Infrastructure.csproj b/src/api/SIGCM2.Infrastructure/SIGCM2.Infrastructure.csproj index cf325e1..53083e9 100644 --- a/src/api/SIGCM2.Infrastructure/SIGCM2.Infrastructure.csproj +++ b/src/api/SIGCM2.Infrastructure/SIGCM2.Infrastructure.csproj @@ -14,6 +14,7 @@ + diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditJobsTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditJobsTests.cs new file mode 100644 index 0000000..1753434 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditJobsTests.cs @@ -0,0 +1,123 @@ +using Dapper; +using FluentAssertions; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Quartz; +using SIGCM2.Application.Audit; +using SIGCM2.Infrastructure.Audit.Jobs; +using SIGCM2.Infrastructure.Persistence; +using Xunit; + +namespace SIGCM2.Application.Tests.Infrastructure.Audit; + +/// UDT-010 Batch 11 — audit maintenance jobs integration tests. +/// Executes each IJob directly (no Quartz scheduler needed) against SIGCM2_Test. +[Collection("Database")] +public sealed class AuditJobsTests : IAsyncLifetime +{ + private const string ConnectionString = + "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; + + private SqlConnection _connection = null!; + private SqlConnectionFactory _factory = null!; + + public async Task InitializeAsync() + { + _connection = new SqlConnection(ConnectionString); + await _connection.OpenAsync(); + await _connection.ExecuteAsync("DELETE FROM dbo.AuditEvent; DELETE FROM dbo.SecurityEvent;"); + _factory = new SqlConnectionFactory(ConnectionString); + } + + public async Task DisposeAsync() + { + await _connection.ExecuteAsync("DELETE FROM dbo.AuditEvent; DELETE FROM dbo.SecurityEvent;"); + await _connection.CloseAsync(); + await _connection.DisposeAsync(); + } + + private static IJobExecutionContext MockContext() + { + var ctx = Substitute.For(); + ctx.CancellationToken.Returns(CancellationToken.None); + return ctx; + } + + [Fact] + public async Task PartitionManager_ExtendsFunctionForward_Idempotent() + { + var job = new AuditPartitionManagerJob(_factory, NullLogger.Instance); + + // First run: ensure the target boundary exists + await job.Execute(MockContext()); + + // Compute target as the job does + var now = DateTime.UtcNow; + var target = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(2); + + foreach (var pf in new[] { "pf_AuditEvent_Monthly", "pf_SecurityEvent_Monthly" }) + { + var count = await _connection.ExecuteScalarAsync(""" + SELECT COUNT(*) + FROM sys.partition_functions pf + JOIN sys.partition_range_values prv ON prv.function_id = pf.function_id + WHERE pf.name = @Name AND CAST(prv.value AS DATETIME2(3)) = @Target; + """, new { Name = pf, Target = target }); + count.Should().Be(1, $"{pf} should have boundary {target:yyyy-MM-dd}"); + } + + // Second run must not throw (idempotent) + await job.Execute(MockContext()); + } + + [Fact] + public async Task RetentionEnforcer_PurgesAuditEventOlderThan10Years_AndSecurityOlderThan5Years() + { + await _connection.ExecuteAsync(""" + INSERT INTO dbo.AuditEvent (OccurredAt, ActorUserId, Action, TargetType, TargetId) + VALUES + (@Ancient, 1, 'x.y', 'T', '1'), -- should be purged + (@Recent, 1, 'x.y', 'T', '2'); -- should stay + INSERT INTO dbo.SecurityEvent (OccurredAt, ActorUserId, Action, Result) + VALUES + (@Ancient5, 1, 'login', 'success'), -- should be purged + (@Recent, 1, 'login', 'success'); -- should stay + """, new + { + Ancient = DateTime.UtcNow.AddYears(-11), + Recent = DateTime.UtcNow.AddDays(-1), + Ancient5 = DateTime.UtcNow.AddYears(-6), + }); + + var job = new AuditRetentionEnforcerJob(_factory, NullLogger.Instance); + await job.Execute(MockContext()); + + var auditCount = await _connection.ExecuteScalarAsync("SELECT COUNT(*) FROM dbo.AuditEvent;"); + var securityCount = await _connection.ExecuteScalarAsync("SELECT COUNT(*) FROM dbo.SecurityEvent;"); + auditCount.Should().Be(1); + securityCount.Should().Be(1); + } + + [Fact] + public async Task IntegrityCheck_AllOk_DoesNotEmitSecurityEvent() + { + var security = Substitute.For(); + var job = new AuditIntegrityCheckJob(_factory, security, NullLogger.Instance); + + // Ensure partition manager has run first so next-3-months exist + await new AuditPartitionManagerJob(_factory, NullLogger.Instance).Execute(MockContext()); + + await job.Execute(MockContext()); + + await security.DidNotReceive().LogAsync( + action: "system.integrity_alert", + result: Arg.Any(), + actorUserId: Arg.Any(), + attemptedUsername: Arg.Any(), + sessionId: Arg.Any(), + failureReason: Arg.Any(), + metadata: Arg.Any(), + ct: Arg.Any()); + } +}