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 619 additions and 0 deletions
Showing only changes of commit 300badda73 - Show all commits

View File

@@ -0,0 +1,27 @@
namespace SIGCM2.Application.Audit;
public sealed record AuditEventQueryResult(
IReadOnlyList<AuditEventDto> Items,
string? NextCursor);
/// Persists and queries AuditEvent rows. Insert participates in any ambient
/// TransactionScope (single connection string enlistment — validated by B0 spike).
public interface IAuditEventRepository
{
Task<long> InsertAsync(
DateTime occurredAt,
int? actorUserId,
int? actorRoleId,
string action,
string targetType,
string targetId,
Guid? correlationId,
string? ipAddress,
string? userAgent,
string? metadata,
CancellationToken ct = default);
Task<AuditEventQueryResult> QueryAsync(
AuditEventFilter filter,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,19 @@
namespace SIGCM2.Application.Audit;
/// Persists SecurityEvent rows. NOT enlisted in business TransactionScope — security
/// events are fire-and-forget writes from auth handlers and middleware.
public interface ISecurityEventRepository
{
Task<long> InsertAsync(
DateTime occurredAt,
int? actorUserId,
string? attemptedUsername,
Guid? sessionId,
string action,
string result,
string? failureReason,
string? ipAddress,
string? userAgent,
string? metadata,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,45 @@
using System.Text;
namespace SIGCM2.Infrastructure.Audit;
/// UDT-010 — opaque cursor for AuditEvent DESC pagination per design #D-6.
/// Format: base64url(`{OccurredAt:O}|{Id}`). Parse returns null on malformed input
/// (callers treat it as "start from the top" — fail-open on invalid cursor).
public static class AuditEventCursor
{
public static string Encode(DateTime occurredAt, long id)
{
var raw = $"{occurredAt:O}|{id}";
var bytes = Encoding.UTF8.GetBytes(raw);
return Convert.ToBase64String(bytes);
}
public static (DateTime OccurredAt, long Id)? TryDecode(string? cursor)
{
if (string.IsNullOrWhiteSpace(cursor))
return null;
try
{
var bytes = Convert.FromBase64String(cursor);
var raw = Encoding.UTF8.GetString(bytes);
var pipe = raw.IndexOf('|');
if (pipe <= 0 || pipe == raw.Length - 1)
return null;
var datePart = raw[..pipe];
var idPart = raw[(pipe + 1)..];
if (!DateTime.TryParse(datePart, null, System.Globalization.DateTimeStyles.RoundtripKind, out var occurredAt))
return null;
if (!long.TryParse(idPart, out var id))
return null;
return (occurredAt, id);
}
catch (FormatException)
{
return null;
}
}
}

View File

@@ -0,0 +1,139 @@
using Dapper;
using SIGCM2.Application.Audit;
using SIGCM2.Infrastructure.Persistence;
namespace SIGCM2.Infrastructure.Audit;
public sealed class AuditEventRepository : IAuditEventRepository
{
private readonly SqlConnectionFactory _factory;
public AuditEventRepository(SqlConnectionFactory factory)
{
_factory = factory;
}
public async Task<long> InsertAsync(
DateTime occurredAt,
int? actorUserId,
int? actorRoleId,
string action,
string targetType,
string targetId,
Guid? correlationId,
string? ipAddress,
string? userAgent,
string? metadata,
CancellationToken ct = default)
{
const string sql = """
INSERT INTO dbo.AuditEvent
(OccurredAt, ActorUserId, ActorRoleId, Action, TargetType, TargetId,
CorrelationId, IpAddress, UserAgent, Metadata)
OUTPUT INSERTED.Id
VALUES
(@OccurredAt, @ActorUserId, @ActorRoleId, @Action, @TargetType, @TargetId,
@CorrelationId, @IpAddress, @UserAgent, @Metadata);
""";
await using var conn = _factory.CreateConnection();
await conn.OpenAsync(ct);
var cmd = new CommandDefinition(sql, new
{
OccurredAt = occurredAt,
ActorUserId = actorUserId,
ActorRoleId = actorRoleId,
Action = action,
TargetType = targetType,
TargetId = targetId,
CorrelationId = correlationId,
IpAddress = ipAddress,
UserAgent = userAgent,
Metadata = metadata,
}, cancellationToken: ct);
return await conn.ExecuteScalarAsync<long>(cmd);
}
public async Task<AuditEventQueryResult> QueryAsync(AuditEventFilter filter, CancellationToken ct = default)
{
var limit = Math.Clamp(filter.Limit, 1, 100);
var wheres = new List<string>();
var parameters = new DynamicParameters();
if (filter.ActorUserId is not null)
{
wheres.Add("e.ActorUserId = @ActorUserId");
parameters.Add("ActorUserId", filter.ActorUserId.Value);
}
if (!string.IsNullOrWhiteSpace(filter.TargetType))
{
wheres.Add("e.TargetType = @TargetType");
parameters.Add("TargetType", filter.TargetType);
}
if (!string.IsNullOrWhiteSpace(filter.TargetId))
{
wheres.Add("e.TargetId = @TargetId");
parameters.Add("TargetId", filter.TargetId);
}
if (filter.From is not null)
{
wheres.Add("e.OccurredAt >= @FromDate");
parameters.Add("FromDate", filter.From.Value);
}
if (filter.To is not null)
{
wheres.Add("e.OccurredAt <= @ToDate");
parameters.Add("ToDate", filter.To.Value);
}
var cursor = AuditEventCursor.TryDecode(filter.Cursor);
if (cursor is not null)
{
// DESC pagination: rows strictly older than the cursor.
wheres.Add("(e.OccurredAt < @CursorOccurredAt OR (e.OccurredAt = @CursorOccurredAt AND e.Id < @CursorId))");
parameters.Add("CursorOccurredAt", cursor.Value.OccurredAt);
parameters.Add("CursorId", cursor.Value.Id);
}
parameters.Add("Limit", limit + 1); // fetch one extra to detect "more pages"
var whereClause = wheres.Count > 0 ? "WHERE " + string.Join(" AND ", wheres) : string.Empty;
var sql = $"""
SELECT TOP (@Limit)
e.Id,
e.OccurredAt,
e.ActorUserId,
u.Username AS ActorUsername,
e.Action,
e.TargetType,
e.TargetId,
e.CorrelationId,
e.IpAddress,
e.Metadata
FROM dbo.AuditEvent e
LEFT JOIN dbo.Usuario u ON u.Id = e.ActorUserId
{whereClause}
ORDER BY e.OccurredAt DESC, e.Id DESC;
""";
await using var conn = _factory.CreateConnection();
await conn.OpenAsync(ct);
var cmd = new CommandDefinition(sql, parameters, cancellationToken: ct);
var rows = (await conn.QueryAsync<AuditEventDto>(cmd)).ToList();
string? nextCursor = null;
if (rows.Count > limit)
{
// We fetched N+1; drop the overflow and emit the cursor from the Nth row.
var last = rows[limit - 1];
nextCursor = AuditEventCursor.Encode(last.OccurredAt, last.Id);
rows.RemoveAt(rows.Count - 1);
}
return new AuditEventQueryResult(rows, nextCursor);
}
}

View File

@@ -0,0 +1,58 @@
using Dapper;
using SIGCM2.Application.Audit;
using SIGCM2.Infrastructure.Persistence;
namespace SIGCM2.Infrastructure.Audit;
public sealed class SecurityEventRepository : ISecurityEventRepository
{
private readonly SqlConnectionFactory _factory;
public SecurityEventRepository(SqlConnectionFactory factory)
{
_factory = factory;
}
public async Task<long> InsertAsync(
DateTime occurredAt,
int? actorUserId,
string? attemptedUsername,
Guid? sessionId,
string action,
string result,
string? failureReason,
string? ipAddress,
string? userAgent,
string? metadata,
CancellationToken ct = default)
{
const string sql = """
INSERT INTO dbo.SecurityEvent
(OccurredAt, ActorUserId, AttemptedUsername, SessionId, Action, Result,
FailureReason, IpAddress, UserAgent, Metadata)
OUTPUT INSERTED.Id
VALUES
(@OccurredAt, @ActorUserId, @AttemptedUsername, @SessionId, @Action, @Result,
@FailureReason, @IpAddress, @UserAgent, @Metadata);
""";
await using var conn = _factory.CreateConnection();
await conn.OpenAsync(ct);
var cmd = new CommandDefinition(sql, new
{
OccurredAt = occurredAt,
ActorUserId = actorUserId,
AttemptedUsername = attemptedUsername,
SessionId = sessionId,
Action = action,
Result = result,
FailureReason = failureReason,
IpAddress = ipAddress,
UserAgent = userAgent,
Metadata = metadata,
}, cancellationToken: ct);
return await conn.ExecuteScalarAsync<long>(cmd);
}
}

View File

@@ -72,6 +72,8 @@ public static class DependencyInjection
// UDT-010: Audit options (SanitizedKeys blacklist) — overridable via appsettings "Audit". // UDT-010: Audit options (SanitizedKeys blacklist) — overridable via appsettings "Audit".
services.Configure<AuditOptions>(configuration.GetSection(AuditOptions.SectionName)); services.Configure<AuditOptions>(configuration.GetSection(AuditOptions.SectionName));
services.AddScoped<IAuditContext, SIGCM2.Infrastructure.Audit.AuditContext>(); services.AddScoped<IAuditContext, SIGCM2.Infrastructure.Audit.AuditContext>();
services.AddScoped<IAuditEventRepository, SIGCM2.Infrastructure.Audit.AuditEventRepository>();
services.AddScoped<ISecurityEventRepository, SIGCM2.Infrastructure.Audit.SecurityEventRepository>();
// Dispatcher // Dispatcher
services.AddScoped<IDispatcher, Dispatcher>(); services.AddScoped<IDispatcher, Dispatcher>();

View File

@@ -0,0 +1,228 @@
using Dapper;
using FluentAssertions;
using Microsoft.Data.SqlClient;
using SIGCM2.Application.Audit;
using SIGCM2.Infrastructure.Audit;
using SIGCM2.Infrastructure.Persistence;
using Xunit;
namespace SIGCM2.Application.Tests.Infrastructure.Audit;
/// UDT-010 Batch 5 — AuditEventRepository integration tests against SIGCM2_Test.
/// Validates insert + cursor-paginated DESC query with all filter permutations.
[Collection("Database")]
public sealed class AuditEventRepositoryTests : IAsyncLifetime
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private SqlConnection _connection = null!;
private AuditEventRepository _repo = null!;
public async Task InitializeAsync()
{
_connection = new SqlConnection(ConnectionString);
await _connection.OpenAsync();
// Clean slate for this test class: wipe prior audit events. Respawn does this too
// between test classes but inside a class tests share state.
await _connection.ExecuteAsync("DELETE FROM dbo.AuditEvent;");
var factory = new SqlConnectionFactory(ConnectionString);
_repo = new AuditEventRepository(factory);
}
public async Task DisposeAsync()
{
await _connection.ExecuteAsync("DELETE FROM dbo.AuditEvent;");
await _connection.CloseAsync();
await _connection.DisposeAsync();
}
[Fact]
public async Task InsertAsync_PersistsAllFields_AndReturnsId()
{
var occurredAt = DateTime.UtcNow;
var correlationId = Guid.NewGuid();
var id = await _repo.InsertAsync(
occurredAt: occurredAt,
actorUserId: 42,
actorRoleId: 7,
action: "usuario.create",
targetType: "Usuario",
targetId: "99",
correlationId: correlationId,
ipAddress: "1.2.3.4",
userAgent: "ua/1.0",
metadata: """{"after":{"username":"juan"}}""");
id.Should().BeGreaterThan(0);
var roundtrip = await _connection.QuerySingleAsync<(int? ActorUserId, int? ActorRoleId, string Action, Guid? CorrelationId, string? IpAddress)>(
"SELECT ActorUserId, ActorRoleId, Action, CorrelationId, IpAddress FROM dbo.AuditEvent WHERE Id = @Id",
new { Id = id });
roundtrip.ActorUserId.Should().Be(42);
roundtrip.ActorRoleId.Should().Be(7);
roundtrip.Action.Should().Be("usuario.create");
roundtrip.CorrelationId.Should().Be(correlationId);
roundtrip.IpAddress.Should().Be("1.2.3.4");
}
[Fact]
public async Task QueryAsync_NoFilters_ReturnsAllInDescendingOrder()
{
var t0 = DateTime.UtcNow.AddSeconds(-30);
await Seed(3, t0);
var result = await _repo.QueryAsync(new AuditEventFilter(null, null, null, null, null, null, 50));
result.Items.Should().HaveCount(3);
result.Items.Select(x => x.Action).Should().ContainInOrder("test.2", "test.1", "test.0");
result.NextCursor.Should().BeNull();
}
[Fact]
public async Task QueryAsync_FilterByActor_ReturnsOnlyMatching()
{
var t0 = DateTime.UtcNow.AddSeconds(-30);
await _repo.InsertAsync(t0, 1, null, "test.0", "Usuario", "1", null, null, null, null);
await _repo.InsertAsync(t0.AddSeconds(1), 2, null, "test.1", "Usuario", "2", null, null, null, null);
await _repo.InsertAsync(t0.AddSeconds(2), 1, null, "test.2", "Usuario", "3", null, null, null, null);
var result = await _repo.QueryAsync(new AuditEventFilter(ActorUserId: 1,
TargetType: null, TargetId: null, From: null, To: null, Cursor: null, Limit: 50));
result.Items.Should().HaveCount(2);
result.Items.Select(x => x.ActorUserId).Should().OnlyContain(a => a == 1);
}
[Fact]
public async Task QueryAsync_FilterByTarget_ReturnsOnlyMatching()
{
var t0 = DateTime.UtcNow.AddSeconds(-30);
await _repo.InsertAsync(t0, 1, null, "test.0", "Usuario", "42", null, null, null, null);
await _repo.InsertAsync(t0.AddSeconds(1), 1, null, "test.1", "Cliente", "99", null, null, null, null);
await _repo.InsertAsync(t0.AddSeconds(2), 1, null, "test.2", "Usuario", "42", null, null, null, null);
var result = await _repo.QueryAsync(new AuditEventFilter(null,
TargetType: "Usuario", TargetId: "42", From: null, To: null, Cursor: null, Limit: 50));
result.Items.Should().HaveCount(2);
result.Items.Should().OnlyContain(x => x.TargetType == "Usuario" && x.TargetId == "42");
}
[Fact]
public async Task QueryAsync_FilterByDateRange_RespectsFromAndTo()
{
var t0 = new DateTime(2026, 4, 10, 12, 0, 0, DateTimeKind.Utc);
await _repo.InsertAsync(t0, 1, null, "test.0", "X", "1", null, null, null, null);
await _repo.InsertAsync(t0.AddDays(3), 1, null, "test.1", "X", "2", null, null, null, null);
await _repo.InsertAsync(t0.AddDays(6), 1, null, "test.2", "X", "3", null, null, null, null);
var result = await _repo.QueryAsync(new AuditEventFilter(null, null, null,
From: t0.AddDays(1), To: t0.AddDays(5), Cursor: null, Limit: 50));
result.Items.Should().HaveCount(1);
result.Items[0].Action.Should().Be("test.1");
}
[Fact]
public async Task QueryAsync_Limit_EmitsCursor_WhenMoreRowsAvailable()
{
var t0 = DateTime.UtcNow.AddMinutes(-10);
await Seed(5, t0);
var page1 = await _repo.QueryAsync(new AuditEventFilter(null, null, null, null, null, null, Limit: 2));
page1.Items.Should().HaveCount(2);
page1.NextCursor.Should().NotBeNull();
var page2 = await _repo.QueryAsync(new AuditEventFilter(null, null, null, null, null, page1.NextCursor, Limit: 2));
page2.Items.Should().HaveCount(2);
page2.NextCursor.Should().NotBeNull();
var page3 = await _repo.QueryAsync(new AuditEventFilter(null, null, null, null, null, page2.NextCursor, Limit: 2));
page3.Items.Should().HaveCount(1);
page3.NextCursor.Should().BeNull();
// Across pages, all 5 events are visited exactly once (no overlap, no gap).
var allActions = page1.Items.Concat(page2.Items).Concat(page3.Items).Select(x => x.Action).ToList();
allActions.Should().HaveCount(5).And.OnlyHaveUniqueItems();
}
[Fact]
public async Task QueryAsync_MalformedCursor_TreatedAsNoCursor()
{
var t0 = DateTime.UtcNow.AddSeconds(-30);
await Seed(2, t0);
var result = await _repo.QueryAsync(new AuditEventFilter(null, null, null, null, null, "not-a-valid-cursor", 50));
result.Items.Should().HaveCount(2);
}
[Fact]
public async Task QueryAsync_JoinsUsuario_PopulatesActorUsername()
{
var adminId = await _connection.QuerySingleOrDefaultAsync<int?>("SELECT Id FROM dbo.Usuario WHERE Username = 'admin'");
if (adminId is null)
{
// Seed admin if not present (Respawn wiped it)
adminId = await _connection.ExecuteScalarAsync<int>("""
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson)
VALUES ('admin', 'hash', 'Admin', 'Sistema', 'admin', '{"grant":[],"deny":[]}');
SELECT CAST(SCOPE_IDENTITY() AS INT);
""");
}
await _repo.InsertAsync(DateTime.UtcNow, adminId, null, "usuario.create", "Usuario", "99", null, null, null, null);
var result = await _repo.QueryAsync(new AuditEventFilter(adminId, null, null, null, null, null, 50));
result.Items.Should().ContainSingle()
.Which.ActorUsername.Should().Be("admin");
}
[Fact]
public async Task AuditEventCursor_EncodeDecode_RoundTrips()
{
var now = DateTime.UtcNow;
var encoded = AuditEventCursor.Encode(now, 12345);
var decoded = AuditEventCursor.TryDecode(encoded);
decoded.Should().NotBeNull();
decoded!.Value.Id.Should().Be(12345);
// DateTime roundtrip via "O" format preserves ticks-level precision.
decoded.Value.OccurredAt.Should().BeCloseTo(now, TimeSpan.FromTicks(1));
}
[Fact]
public void AuditEventCursor_TryDecode_ReturnsNullForMalformed()
{
AuditEventCursor.TryDecode(null).Should().BeNull();
AuditEventCursor.TryDecode("").Should().BeNull();
AuditEventCursor.TryDecode("not-base64!!!").Should().BeNull();
AuditEventCursor.TryDecode(Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("no-pipe"))).Should().BeNull();
AuditEventCursor.TryDecode(Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("|123"))).Should().BeNull();
AuditEventCursor.TryDecode(Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("2026-04-16|"))).Should().BeNull();
}
private async Task Seed(int count, DateTime baseTime)
{
for (var i = 0; i < count; i++)
{
await _repo.InsertAsync(
occurredAt: baseTime.AddSeconds(i),
actorUserId: 1,
actorRoleId: null,
action: $"test.{i}",
targetType: "Usuario",
targetId: i.ToString(),
correlationId: null,
ipAddress: null,
userAgent: null,
metadata: null);
}
}
}

View File

@@ -0,0 +1,101 @@
using Dapper;
using FluentAssertions;
using Microsoft.Data.SqlClient;
using SIGCM2.Infrastructure.Audit;
using SIGCM2.Infrastructure.Persistence;
using Xunit;
namespace SIGCM2.Application.Tests.Infrastructure.Audit;
/// UDT-010 Batch 5 — SecurityEventRepository integration tests against SIGCM2_Test.
[Collection("Database")]
public sealed class SecurityEventRepositoryTests : IAsyncLifetime
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private SqlConnection _connection = null!;
private SecurityEventRepository _repo = null!;
public async Task InitializeAsync()
{
_connection = new SqlConnection(ConnectionString);
await _connection.OpenAsync();
await _connection.ExecuteAsync("DELETE FROM dbo.SecurityEvent;");
var factory = new SqlConnectionFactory(ConnectionString);
_repo = new SecurityEventRepository(factory);
}
public async Task DisposeAsync()
{
await _connection.ExecuteAsync("DELETE FROM dbo.SecurityEvent;");
await _connection.CloseAsync();
await _connection.DisposeAsync();
}
[Fact]
public async Task InsertAsync_LoginSuccess_PersistsAllFields()
{
var sessionId = Guid.NewGuid();
var occurredAt = DateTime.UtcNow;
var id = await _repo.InsertAsync(
occurredAt: occurredAt,
actorUserId: 42,
attemptedUsername: null,
sessionId: sessionId,
action: "login",
result: "success",
failureReason: null,
ipAddress: "1.2.3.4",
userAgent: "ua/1.0",
metadata: """{"route":"/login"}""");
id.Should().BeGreaterThan(0);
var row = await _connection.QuerySingleAsync<(int? ActorUserId, Guid? SessionId, string Action, string Result, string? FailureReason)>(
"SELECT ActorUserId, SessionId, Action, Result, FailureReason FROM dbo.SecurityEvent WHERE Id = @Id",
new { Id = id });
row.ActorUserId.Should().Be(42);
row.SessionId.Should().Be(sessionId);
row.Action.Should().Be("login");
row.Result.Should().Be("success");
row.FailureReason.Should().BeNull();
}
[Fact]
public async Task InsertAsync_LoginFailure_SupportsNullActorAndAttemptedUsername()
{
var id = await _repo.InsertAsync(
occurredAt: DateTime.UtcNow,
actorUserId: null,
attemptedUsername: "juan",
sessionId: null,
action: "login",
result: "failure",
failureReason: "invalid_password",
ipAddress: "1.2.3.4",
userAgent: null,
metadata: null);
id.Should().BeGreaterThan(0);
var row = await _connection.QuerySingleAsync<(int? ActorUserId, string? AttemptedUsername, string Result, string? FailureReason)>(
"SELECT ActorUserId, AttemptedUsername, Result, FailureReason FROM dbo.SecurityEvent WHERE Id = @Id",
new { Id = id });
row.ActorUserId.Should().BeNull();
row.AttemptedUsername.Should().Be("juan");
row.Result.Should().Be("failure");
row.FailureReason.Should().Be("invalid_password");
}
[Fact]
public async Task InsertAsync_InvalidResult_FailsCheckConstraint()
{
var act = async () => await _repo.InsertAsync(
DateTime.UtcNow, null, null, null, "login", "neutral", null, null, null, null);
await act.Should().ThrowAsync<SqlException>()
.Where(e => e.Message.Contains("CK_SecurityEvent_Result"));
}
}