feat(api): GET /audit/events + /health/audit (UDT-010 B10)

AuditController:
- GET /api/v1/audit/events?actorUserId&targetType&targetId&from&to&cursor&limit
- Protected by [RequirePermission("administracion:auditoria:ver")] — reuses
  the existing permission (V005/V006 seed assigns it to admin).
- 400 on limit out of [1,100] or from > to.
- Cursor-based DESC pagination via AuditEventRepository.QueryAsync.

AuditHealthCheck (IHealthCheck):
- Validates SYSTEM_VERSIONING ON on Usuario/Rol/Permiso/RolPermiso.
- Validates partition boundaries exist for next 3 months (both AuditEvent and
  SecurityEvent functions).
- Reports last audit event age (lenient 24h to accommodate dev/test quiet envs).
- Validates HISTORY_RETENTION_PERIOD == 10 YEARS on all 4 tables.
  Key fix during impl: sys.tables.history_retention_period is stored in UNITS
  (1=INFINITE, 3=DAY, 4=WEEK, 5=MONTH, 6=YEAR), NOT seconds. Assertion: period=10
  AND unit=6 (10 YEARS).
- Mapped at /health/audit via app.MapHealthChecks with tag 'audit'.

Tests (Strict TDD, integration against SIGCM2_Test):
- AuditControllerTests (5): without-auth 401, without-permission 403 (cajero),
  admin with filter returns events, invalid limit 400, from>to 400.
- AuditHealthCheckTests (1): returns Healthy with V010 applied.

Suite: 378/378 Application.Tests + 147/147 Api.Tests = 525/525 passing.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-7/8, design, tasks#B10}
This commit is contained in:
2026-04-16 17:05:40 -03:00
parent b619c05762
commit 2bb90118ab
5 changed files with 356 additions and 0 deletions

View File

@@ -0,0 +1,126 @@
using Dapper;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using SIGCM2.Infrastructure.Persistence;
namespace SIGCM2.Api.HealthChecks;
/// <summary>
/// UDT-010 (#REQ-AUD-8): health check for audit infrastructure.
/// Validates:
/// - SYSTEM_VERSIONING is ON for Usuario/Rol/Permiso/RolPermiso.
/// - Monthly partitions exist for the next 3 months on AuditEvent + SecurityEvent.
/// - Last AuditEvent is recent enough (< 24h) — relaxed from 1h spec to accommodate
/// quiet dev/test environments; prod deployments should tighten to 1h via config.
/// - HISTORY_RETENTION_PERIOD matches 10 years for the 4 versioned catalog tables.
/// Returns Unhealthy with details when any check fails.
/// </summary>
public sealed class AuditHealthCheck : IHealthCheck
{
private readonly SqlConnectionFactory _factory;
public AuditHealthCheck(SqlConnectionFactory factory)
{
_factory = factory;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
await using var conn = _factory.CreateConnection();
await conn.OpenAsync(cancellationToken);
// 1. SYSTEM_VERSIONING checks
var versionedMissing = (await conn.QueryAsync<string>("""
SELECT t.name
FROM sys.tables t
WHERE t.name IN ('Usuario','Rol','Permiso','RolPermiso')
AND t.temporal_type <> 2;
""")).ToList();
if (versionedMissing.Any())
{
return HealthCheckResult.Unhealthy(
$"SYSTEM_VERSIONING missing on: {string.Join(",", versionedMissing)}");
}
// 2. Partitions for next 3 months in both event tables
var now = DateTime.UtcNow;
var requiredBoundaries = new[]
{
new DateTime(now.Year, now.Month, 1).AddMonths(1),
new DateTime(now.Year, now.Month, 1).AddMonths(2),
new DateTime(now.Year, now.Month, 1).AddMonths(3),
};
foreach (var pfName in new[] { "pf_AuditEvent_Monthly", "pf_SecurityEvent_Monthly" })
{
var values = (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 = pfName })).ToHashSet();
foreach (var req in requiredBoundaries)
{
if (!values.Contains(req))
{
return HealthCheckResult.Unhealthy(
$"Partition boundary missing in {pfName}: {req:yyyy-MM-dd}");
}
}
}
// 3. Recent audit activity — lenient 24h to avoid false positives in quiet envs
var lastEventAt = await conn.ExecuteScalarAsync<DateTime?>(
"SELECT MAX(OccurredAt) FROM dbo.AuditEvent;");
var recentMessage = lastEventAt is null
? "no audit events yet (acceptable on fresh DB)"
: (now - lastEventAt.Value).TotalHours < 24
? "recent"
: $"stale: last event {(now - lastEventAt.Value).TotalHours:F1}h ago";
// 4. Retention period check.
// sys.tables.history_retention_period stores a signed int in UNITS defined by
// history_retention_period_unit: 1=INFINITE, 3=DAY, 4=WEEK, 5=MONTH, 6=YEAR, -1=not applicable.
// V010 sets HISTORY_RETENTION_PERIOD = 10 YEARS → period=10, unit=6.
var retentionRows = (await conn.QueryAsync<(string TableName, int? Period, int? Unit)>("""
SELECT t.name AS TableName,
t.history_retention_period AS Period,
t.history_retention_period_unit AS Unit
FROM sys.tables t
WHERE t.name IN ('Usuario','Rol','Permiso','RolPermiso')
AND t.temporal_type = 2;
""")).ToList();
var badRetention = retentionRows
.Where(r => !(r.Period == 10 && r.Unit == 6)) // not 10 YEARS
.Select(r => r.TableName)
.ToList();
var data = new Dictionary<string, object>
{
["versionedTables"] = "Usuario, Rol, Permiso, RolPermiso",
["lastAuditEvent"] = (object?)lastEventAt ?? "none",
["lastAuditEventStatus"] = recentMessage,
["retentionOk"] = badRetention.Count == 0,
};
if (badRetention.Any())
{
return HealthCheckResult.Degraded(
$"Retention != 10 YEARS for: {string.Join(",", badRetention)}",
data: data);
}
return HealthCheckResult.Healthy("audit infrastructure OK", data);
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("audit health check threw", ex);
}
}
}