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
8 changed files with 390 additions and 0 deletions
Showing only changes of commit 9eac044752 - Show all commits

View File

@@ -15,6 +15,7 @@
<PackageVersion Include="Scalar.AspNetCore" Version="2.5.6" /> <PackageVersion Include="Scalar.AspNetCore" Version="2.5.6" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.9.0" /> <PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.9.0" />
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.9.0" /> <PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.9.0" />
<PackageVersion Include="Quartz.Extensions.Hosting" Version="3.13.1" />
</ItemGroup> </ItemGroup>
<!-- Test dependencies --> <!-- Test dependencies -->
<ItemGroup> <ItemGroup>

View File

@@ -6,6 +6,7 @@ using SIGCM2.Api.HealthChecks;
using SIGCM2.Api.Middleware; using SIGCM2.Api.Middleware;
using SIGCM2.Application; using SIGCM2.Application;
using SIGCM2.Infrastructure; using SIGCM2.Infrastructure;
using SIGCM2.Infrastructure.Audit.Jobs;
using SIGCM2.Api.Filters; using SIGCM2.Api.Filters;
// Bootstrap logger — before DI is built // Bootstrap logger — before DI is built
@@ -25,6 +26,11 @@ builder.Host.UseSerilog((ctx, lc) => lc
builder.Services.AddApplication(); builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration); 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 // Authorization — handler lives in Api layer; DO NOT move to Infrastructure DI
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
builder.Services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler>(); builder.Services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler>();

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="System.IdentityModel.Tokens.Jwt" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" /> <PackageReference Include="Microsoft.IdentityModel.Tokens" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Quartz.Extensions.Hosting" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -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<IJobExecutionContext>();
ctx.CancellationToken.Returns(CancellationToken.None);
return ctx;
}
[Fact]
public async Task PartitionManager_ExtendsFunctionForward_Idempotent()
{
var job = new AuditPartitionManagerJob(_factory, NullLogger<AuditPartitionManagerJob>.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<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 });
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<AuditRetentionEnforcerJob>.Instance);
await job.Execute(MockContext());
var auditCount = await _connection.ExecuteScalarAsync<int>("SELECT COUNT(*) FROM dbo.AuditEvent;");
var securityCount = await _connection.ExecuteScalarAsync<int>("SELECT COUNT(*) FROM dbo.SecurityEvent;");
auditCount.Should().Be(1);
securityCount.Should().Be(1);
}
[Fact]
public async Task IntegrityCheck_AllOk_DoesNotEmitSecurityEvent()
{
var security = Substitute.For<ISecurityEventLogger>();
var job = new AuditIntegrityCheckJob(_factory, security, NullLogger<AuditIntegrityCheckJob>.Instance);
// Ensure partition manager has run first so next-3-months exist
await new AuditPartitionManagerJob(_factory, NullLogger<AuditPartitionManagerJob>.Instance).Execute(MockContext());
await job.Execute(MockContext());
await security.DidNotReceive().LogAsync(
action: "system.integrity_alert",
result: Arg.Any<string>(),
actorUserId: Arg.Any<int?>(),
attemptedUsername: Arg.Any<string?>(),
sessionId: Arg.Any<Guid?>(),
failureReason: Arg.Any<string?>(),
metadata: Arg.Any<object?>(),
ct: Arg.Any<CancellationToken>());
}
}