Files
SIG-CM2.0/src/api/SIGCM2.Infrastructure/Audit/SecurityEventRepository.cs
dmolinari 300badda73 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}
2026-04-16 13:38:05 -03:00

59 lines
1.8 KiB
C#

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);
}
}