127 lines
5.2 KiB
C#
127 lines
5.2 KiB
C#
|
|
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);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|