diff --git a/src/api/SIGCM2.Api/Controllers/AuditController.cs b/src/api/SIGCM2.Api/Controllers/AuditController.cs
new file mode 100644
index 0000000..0bee30f
--- /dev/null
+++ b/src/api/SIGCM2.Api/Controllers/AuditController.cs
@@ -0,0 +1,63 @@
+using Microsoft.AspNetCore.Mvc;
+using SIGCM2.Api.Authorization;
+using SIGCM2.Application.Audit;
+
+namespace SIGCM2.Api.Controllers;
+
+///
+/// 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.
+///
+[ApiController]
+[Route("api/v1/audit")]
+public sealed class AuditController : ControllerBase
+{
+ private readonly IAuditEventRepository _repo;
+
+ public AuditController(IAuditEventRepository repo)
+ {
+ _repo = repo;
+ }
+
+ /// Lists audit events with optional filters. Cursor-based DESC pagination.
+ [HttpGet("events")]
+ [RequirePermission("administracion:auditoria:ver")]
+ [ProducesResponseType(typeof(AuditEventPageResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task 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));
+ }
+}
+
+/// UDT-010: Paginated response wrapper for GET /api/v1/audit/events.
+public sealed record AuditEventPageResponse(
+ IReadOnlyList Items,
+ string? NextCursor);
diff --git a/src/api/SIGCM2.Api/HealthChecks/AuditHealthCheck.cs b/src/api/SIGCM2.Api/HealthChecks/AuditHealthCheck.cs
new file mode 100644
index 0000000..36d781e
--- /dev/null
+++ b/src/api/SIGCM2.Api/HealthChecks/AuditHealthCheck.cs
@@ -0,0 +1,126 @@
+using Dapper;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using SIGCM2.Infrastructure.Persistence;
+
+namespace SIGCM2.Api.HealthChecks;
+
+///
+/// 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.
+///
+public sealed class AuditHealthCheck : IHealthCheck
+{
+ private readonly SqlConnectionFactory _factory;
+
+ public AuditHealthCheck(SqlConnectionFactory factory)
+ {
+ _factory = factory;
+ }
+
+ public async Task 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("""
+ 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("""
+ 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(
+ "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
+ {
+ ["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);
+ }
+ }
+}
diff --git a/src/api/SIGCM2.Api/Program.cs b/src/api/SIGCM2.Api/Program.cs
index cc45135..69fdac1 100644
--- a/src/api/SIGCM2.Api/Program.cs
+++ b/src/api/SIGCM2.Api/Program.cs
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Serilog;
using Scalar.AspNetCore;
using SIGCM2.Api.Authorization;
+using SIGCM2.Api.HealthChecks;
using SIGCM2.Api.Middleware;
using SIGCM2.Application;
using SIGCM2.Infrastructure;
@@ -38,6 +39,10 @@ builder.Services.AddControllers(opts =>
// OpenAPI / Scalar
builder.Services.AddOpenApi();
+// UDT-010: Audit infrastructure health check
+builder.Services.AddHealthChecks()
+ .AddCheck("audit", tags: new[] { "audit" });
+
// CORS
var allowedOrigins = builder.Configuration
.GetSection("Cors:AllowedOrigins")
@@ -76,6 +81,12 @@ app.UseMiddleware();
app.UseAuthorization();
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();
// Exposed for WebApplicationFactory in integration tests
diff --git a/tests/SIGCM2.Api.Tests/Audit/AuditControllerTests.cs b/tests/SIGCM2.Api.Tests/Audit/AuditControllerTests.cs
new file mode 100644
index 0000000..ee7ddd3
--- /dev/null
+++ b/tests/SIGCM2.Api.Tests/Audit/AuditControllerTests.cs
@@ -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
+{
+ 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("SELECT Id FROM dbo.Usuario WHERE Username = 'admin'");
+
+ var client = _factory.CreateClient();
+ var jwt = _factory.Services.GetRequiredService();
+ 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();
+ // 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();
+ 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);
+ }
+}
diff --git a/tests/SIGCM2.Api.Tests/Audit/AuditHealthCheckTests.cs b/tests/SIGCM2.Api.Tests/Audit/AuditHealthCheckTests.cs
new file mode 100644
index 0000000..1802d0b
--- /dev/null
+++ b/tests/SIGCM2.Api.Tests/Audit/AuditHealthCheckTests.cs
@@ -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
+{
+ 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");
+ }
+}