feat(jobs): 3 audit maintenance jobs (Quartz.NET, UDT-010 B11)

Agrega Quartz.Extensions.Hosting 3.13.1 al catálogo central.

SIGCM2.Infrastructure/Audit/Jobs/:
- AuditPartitionManagerJob — mensual (cron '0 0 2 1 * ?', UTC). Extiende
  pf_AuditEvent_Monthly y pf_SecurityEvent_Monthly con SPLIT RANGE para el
  mes+2 (mantiene +1 de buffer). Idempotente: verifica existencia antes.
- AuditRetentionEnforcerJob — anual (cron '0 0 3 1 1 ?', UTC). DELETE rows
  > 10 años en AuditEvent y > 5 años en SecurityEvent. Temporal history se
  purga solo vía HISTORY_RETENTION_PERIOD del engine.
- AuditIntegrityCheckJob — semanal domingos (cron '0 0 1 ? * SUN', UTC).
  Valida SYSTEM_VERSIONING=ON + partitions próximos 3 meses. Emite
  SecurityEvent 'system.integrity_alert' failure via ISecurityEventLogger
  cuando detecta inconsistencias.

AuditMaintenanceRegistration.cs:
- services.AddAuditMaintenance(configuration) wraps AddQuartz + AddQuartzHostedService
  con los 3 triggers crónicos.

Program.cs:
- builder.Services.AddAuditMaintenance(configuration) wired ONLY en entornos
  productivos — skipeado en 'Testing' para que los integration tests no
  disparen los triggers cron durante el ciclo de vida del TestWebAppFactory.

Row-based DELETE en RetentionEnforcerJob es la opción conservadora para la
primera generación — cuando los volúmenes lo justifiquen (>200M filas), se
upgradea a SWITCH OUT + DROP para partition-level drop. Documentado en
comentario de la clase.

Tests (Strict TDD, integration):
- AuditJobsTests (3): PartitionManager crea target boundary + idempotencia,
  RetentionEnforcer purga > threshold (10y audit, 5y security), IntegrityCheck
  all-OK no emite alert.

Suite: 381/381 Application.Tests + 147/147 Api.Tests = 528/528 passing.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-6 #REQ-SEC-5, design, tasks#B11}
This commit is contained in:
2026-04-16 17:10:43 -03:00
parent b526df2125
commit 9eac044752
8 changed files with 390 additions and 0 deletions

View File

@@ -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;
/// <summary>
/// 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).
/// </summary>
[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<AuditIntegrityCheckJob> _logger;
public AuditIntegrityCheckJob(
SqlConnectionFactory factory,
ISecurityEventLogger security,
ILogger<AuditIntegrityCheckJob> logger)
{
_factory = factory;
_security = security;
_logger = logger;
}
public async Task Execute(IJobExecutionContext context)
{
var ct = context.CancellationToken;
var failures = new List<string>();
await using var conn = _factory.CreateConnection();
await conn.OpenAsync(ct);
// 1. SYSTEM_VERSIONING still ON
var missing = (await conn.QueryAsync<string>("""
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<DateTime>("""
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");
}
}
}

View File

@@ -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<AuditPartitionManagerJob>(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<AuditRetentionEnforcerJob>(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<AuditIntegrityCheckJob>(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;
}
}

View File

@@ -0,0 +1,64 @@
using Dapper;
using Microsoft.Extensions.Logging;
using Quartz;
using SIGCM2.Infrastructure.Persistence;
namespace SIGCM2.Infrastructure.Audit.Jobs;
/// <summary>
/// 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.
/// </summary>
[DisallowConcurrentExecution]
public sealed class AuditPartitionManagerJob : IJob
{
public const string CronSchedule = "0 0 2 1 * ?";
private readonly SqlConnectionFactory _factory;
private readonly ILogger<AuditPartitionManagerJob> _logger;
public AuditPartitionManagerJob(SqlConnectionFactory factory, ILogger<AuditPartitionManagerJob> 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<int>("""
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);
}
}

View File

@@ -0,0 +1,57 @@
using Dapper;
using Microsoft.Extensions.Logging;
using Quartz;
using SIGCM2.Infrastructure.Persistence;
namespace SIGCM2.Infrastructure.Audit.Jobs;
/// <summary>
/// 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.
/// </summary>
[DisallowConcurrentExecution]
public sealed class AuditRetentionEnforcerJob : IJob
{
public const string CronSchedule = "0 0 3 1 1 ?";
private readonly SqlConnectionFactory _factory;
private readonly ILogger<AuditRetentionEnforcerJob> _logger;
public AuditRetentionEnforcerJob(SqlConnectionFactory factory, ILogger<AuditRetentionEnforcerJob> 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);
}
}

View File

@@ -14,6 +14,7 @@
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Quartz.Extensions.Hosting" />
</ItemGroup>
<ItemGroup>