UDT-010: Infraestructura de Auditoría y Trazabilidad — Closes #6 #14
63
src/api/SIGCM2.Api/Controllers/AuditController.cs
Normal file
63
src/api/SIGCM2.Api/Controllers/AuditController.cs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SIGCM2.Api.Authorization;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
|
||||||
|
namespace SIGCM2.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// UDT-010: Read-only endpoint for audit events. Requires administracion:auditoria:ver.
|
||||||
|
/// Cursor-based DESC pagination with 4 filter axes (actor/target/from/to).
|
||||||
|
/// Rich UI (drilldown, export CSV, timeline) is deferred to ADM-004.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/audit")]
|
||||||
|
public sealed class AuditController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IAuditEventRepository _repo;
|
||||||
|
|
||||||
|
public AuditController(IAuditEventRepository repo)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lists audit events with optional filters. Cursor-based DESC pagination.</summary>
|
||||||
|
[HttpGet("events")]
|
||||||
|
[RequirePermission("administracion:auditoria:ver")]
|
||||||
|
[ProducesResponseType(typeof(AuditEventPageResponse), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
public async Task<IActionResult> GetEvents(
|
||||||
|
[FromQuery] int? actorUserId = null,
|
||||||
|
[FromQuery] string? targetType = null,
|
||||||
|
[FromQuery] string? targetId = null,
|
||||||
|
[FromQuery] DateTime? from = null,
|
||||||
|
[FromQuery] DateTime? to = null,
|
||||||
|
[FromQuery] string? cursor = null,
|
||||||
|
[FromQuery] int limit = 50,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (limit < 1 || limit > 100)
|
||||||
|
return BadRequest(new { error = "limit must be between 1 and 100" });
|
||||||
|
|
||||||
|
if (from is not null && to is not null && from > to)
|
||||||
|
return BadRequest(new { error = "from must be <= to" });
|
||||||
|
|
||||||
|
var filter = new AuditEventFilter(
|
||||||
|
ActorUserId: actorUserId,
|
||||||
|
TargetType: targetType,
|
||||||
|
TargetId: targetId,
|
||||||
|
From: from,
|
||||||
|
To: to,
|
||||||
|
Cursor: cursor,
|
||||||
|
Limit: limit);
|
||||||
|
|
||||||
|
var result = await _repo.QueryAsync(filter, ct);
|
||||||
|
return Ok(new AuditEventPageResponse(result.Items, result.NextCursor));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>UDT-010: Paginated response wrapper for GET /api/v1/audit/events.</summary>
|
||||||
|
public sealed record AuditEventPageResponse(
|
||||||
|
IReadOnlyList<AuditEventDto> Items,
|
||||||
|
string? NextCursor);
|
||||||
126
src/api/SIGCM2.Api/HealthChecks/AuditHealthCheck.cs
Normal file
126
src/api/SIGCM2.Api/HealthChecks/AuditHealthCheck.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Serilog;
|
using Serilog;
|
||||||
using Scalar.AspNetCore;
|
using Scalar.AspNetCore;
|
||||||
using SIGCM2.Api.Authorization;
|
using SIGCM2.Api.Authorization;
|
||||||
|
using SIGCM2.Api.HealthChecks;
|
||||||
using SIGCM2.Api.Middleware;
|
using SIGCM2.Api.Middleware;
|
||||||
using SIGCM2.Application;
|
using SIGCM2.Application;
|
||||||
using SIGCM2.Infrastructure;
|
using SIGCM2.Infrastructure;
|
||||||
@@ -38,6 +39,10 @@ builder.Services.AddControllers(opts =>
|
|||||||
// OpenAPI / Scalar
|
// OpenAPI / Scalar
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddOpenApi();
|
||||||
|
|
||||||
|
// UDT-010: Audit infrastructure health check
|
||||||
|
builder.Services.AddHealthChecks()
|
||||||
|
.AddCheck<AuditHealthCheck>("audit", tags: new[] { "audit" });
|
||||||
|
|
||||||
// CORS
|
// CORS
|
||||||
var allowedOrigins = builder.Configuration
|
var allowedOrigins = builder.Configuration
|
||||||
.GetSection("Cors:AllowedOrigins")
|
.GetSection("Cors:AllowedOrigins")
|
||||||
@@ -76,6 +81,12 @@ app.UseMiddleware<AuditActorMiddleware>();
|
|||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
|
// UDT-010: /health/audit returns the audit check status (public but sparse data).
|
||||||
|
app.MapHealthChecks("/health/audit", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
|
||||||
|
{
|
||||||
|
Predicate = r => r.Tags.Contains("audit"),
|
||||||
|
});
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
// Exposed for WebApplicationFactory in integration tests
|
// Exposed for WebApplicationFactory in integration tests
|
||||||
|
|||||||
126
tests/SIGCM2.Api.Tests/Audit/AuditControllerTests.cs
Normal file
126
tests/SIGCM2.Api.Tests/Audit/AuditControllerTests.cs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Dapper;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SIGCM2.Api.Controllers;
|
||||||
|
using SIGCM2.Application.Abstractions.Security;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
using SIGCM2.TestSupport;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SIGCM2.Api.Tests.Audit;
|
||||||
|
|
||||||
|
/// UDT-010 Batch 10 — AuditController integration tests.
|
||||||
|
[Collection("ApiIntegration")]
|
||||||
|
public sealed class AuditControllerTests : IClassFixture<TestWebAppFactory>
|
||||||
|
{
|
||||||
|
private const string ConnectionString =
|
||||||
|
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||||
|
|
||||||
|
private readonly TestWebAppFactory _factory;
|
||||||
|
|
||||||
|
public AuditControllerTests(TestWebAppFactory factory)
|
||||||
|
{
|
||||||
|
_factory = factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(HttpClient client, int adminId)> AuthedAdminClientAsync()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(ConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
await conn.ExecuteAsync("DELETE FROM dbo.AuditEvent;");
|
||||||
|
|
||||||
|
var adminId = await conn.QuerySingleAsync<int>("SELECT Id FROM dbo.Usuario WHERE Username = 'admin'");
|
||||||
|
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
var jwt = _factory.Services.GetRequiredService<IJwtService>();
|
||||||
|
var token = jwt.GenerateAccessToken(new Usuario(
|
||||||
|
id: adminId, username: "admin", passwordHash: "x",
|
||||||
|
nombre: "Admin", apellido: "Sys", email: null,
|
||||||
|
rol: "admin", permisosJson: """{"grant":[],"deny":[]}""", activo: true));
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
return (client, adminId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetEvents_WithoutPermission_Returns403()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
var jwt = _factory.Services.GetRequiredService<IJwtService>();
|
||||||
|
// Use a role without administracion:auditoria:ver (cajero only has ventas:contado:*)
|
||||||
|
var operadorToken = jwt.GenerateAccessToken(new Usuario(
|
||||||
|
id: 9999, username: "opx", passwordHash: "x",
|
||||||
|
nombre: "X", apellido: "Y", email: null,
|
||||||
|
rol: "cajero", permisosJson: """{"grant":[],"deny":[]}""", activo: true));
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", operadorToken);
|
||||||
|
|
||||||
|
var response = await client.GetAsync("/api/v1/audit/events");
|
||||||
|
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetEvents_WithoutAuth_Returns401()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
var response = await client.GetAsync("/api/v1/audit/events");
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetEvents_AuthenticatedAdmin_ReturnsAuditEvents()
|
||||||
|
{
|
||||||
|
var (client, adminId) = await AuthedAdminClientAsync();
|
||||||
|
|
||||||
|
// Seed 3 events directly
|
||||||
|
await using var conn = new SqlConnection(ConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
for (var i = 0; i < 3; i++)
|
||||||
|
{
|
||||||
|
await conn.ExecuteAsync("""
|
||||||
|
INSERT INTO dbo.AuditEvent (OccurredAt, ActorUserId, Action, TargetType, TargetId)
|
||||||
|
VALUES (@O, @A, @Ac, 'Usuario', @T);
|
||||||
|
""", new
|
||||||
|
{
|
||||||
|
O = DateTime.UtcNow.AddSeconds(-i),
|
||||||
|
A = adminId,
|
||||||
|
Ac = $"test.seed{i}",
|
||||||
|
T = i.ToString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await client.GetAsync("/api/v1/audit/events?targetType=Usuario");
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<AuditEventPageResponse>();
|
||||||
|
body.Should().NotBeNull();
|
||||||
|
body!.Items.Should().HaveCount(3);
|
||||||
|
body.Items.Should().OnlyContain(e => e.TargetType == "Usuario");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetEvents_InvalidLimit_Returns400()
|
||||||
|
{
|
||||||
|
var (client, _) = await AuthedAdminClientAsync();
|
||||||
|
|
||||||
|
var response = await client.GetAsync("/api/v1/audit/events?limit=0");
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||||
|
|
||||||
|
var response2 = await client.GetAsync("/api/v1/audit/events?limit=101");
|
||||||
|
response2.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetEvents_FromGreaterThanTo_Returns400()
|
||||||
|
{
|
||||||
|
var (client, _) = await AuthedAdminClientAsync();
|
||||||
|
|
||||||
|
var response = await client.GetAsync(
|
||||||
|
"/api/v1/audit/events?from=2026-05-01T00:00:00Z&to=2026-04-01T00:00:00Z");
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
tests/SIGCM2.Api.Tests/Audit/AuditHealthCheckTests.cs
Normal file
30
tests/SIGCM2.Api.Tests/Audit/AuditHealthCheckTests.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
using System.Net;
|
||||||
|
using FluentAssertions;
|
||||||
|
using SIGCM2.TestSupport;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SIGCM2.Api.Tests.Audit;
|
||||||
|
|
||||||
|
/// UDT-010 Batch 10 — /health/audit integration smoke.
|
||||||
|
[Collection("ApiIntegration")]
|
||||||
|
public sealed class AuditHealthCheckTests : IClassFixture<TestWebAppFactory>
|
||||||
|
{
|
||||||
|
private readonly TestWebAppFactory _factory;
|
||||||
|
|
||||||
|
public AuditHealthCheckTests(TestWebAppFactory factory)
|
||||||
|
{
|
||||||
|
_factory = factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HealthAudit_WithInfraApplied_ReturnsHealthy()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var response = await client.GetAsync("/health/audit");
|
||||||
|
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
body.Should().Contain("Healthy");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user