feat(infra): audit + security event repositories (UDT-010 B5)

Introduces persistence layer for audit and security events per design #D-6:

SIGCM2.Application/Audit/:
- IAuditEventRepository: InsertAsync + QueryAsync with cursor pagination
- ISecurityEventRepository: InsertAsync only (no query — SecurityEvent is
  queried only from an admin dashboard deferred to ADM-004)
- AuditEventQueryResult: (Items, NextCursor) record

SIGCM2.Infrastructure/Audit/:
- AuditEventCursor (public): base64(OccurredAt:O|Id) opaque cursor for
  DESC pagination. TryDecode is fail-open — malformed cursor returns null
  and the query starts from the top.
- AuditEventRepository: Dapper INSERT via OUTPUT INSERTED.Id + dynamic
  WHERE composition with parameterized filters (zero SQL injection risk).
  LEFT JOIN to dbo.Usuario to populate ActorUsername in AuditEventDto.
  Pagination fetches Limit+1 rows to detect "more pages"; emits cursor
  from the Nth row when overflow observed.
- SecurityEventRepository: straight INSERT for login/logout/refresh/
  permission.denied events.

DI: AddScoped for both repos in AddInfrastructure.

Integration tests (Strict TDD): 13 total, all against SIGCM2_Test.
- AuditEventRepositoryTests (10): insert-roundtrip, filter-by-actor,
  filter-by-target, filter-by-date-range, cursor pagination across 3 pages
  (no overlap/no gap), malformed-cursor fail-open, LEFT JOIN Usuario
  populates username, cursor encode/decode roundtrip, cursor malformed
  variants.
- SecurityEventRepositoryTests (3): insert success, insert failure with
  null ActorUserId + AttemptedUsername, CK_SecurityEvent_Result rejection.

Suite: 368/368 Application.Tests + 141/141 Api.Tests = 509/509 passing.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-2,7 #REQ-SEC-1,
design#D-6, tasks#B5}
This commit is contained in:
2026-04-16 13:38:05 -03:00
parent 0b4af4c332
commit 300badda73
8 changed files with 619 additions and 0 deletions

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".
services.Configure<AuditOptions>(configuration.GetSection(AuditOptions.SectionName));
services.AddScoped<IAuditContext, SIGCM2.Infrastructure.Audit.AuditContext>();
services.AddScoped<IAuditEventRepository, SIGCM2.Infrastructure.Audit.AuditEventRepository>();
services.AddScoped<ISecurityEventRepository, SIGCM2.Infrastructure.Audit.SecurityEventRepository>();
// Dispatcher
services.AddScoped<IDispatcher, Dispatcher>();