feat(infra): AuditLogger + SecurityEventLogger impl (UDT-010 B6)

Composes the audit emission layer per design #D-8:

SIGCM2.Infrastructure/Audit/AuditLogger.cs (IAuditLogger):
- Enriches from IAuditContext (ActorUserId/ActorRoleId/Ip/UserAgent/CorrelationId).
- Sanitizes metadata via JsonSanitizer + AuditOptions.SanitizedKeys.
- Persists via IAuditEventRepository.InsertAsync.
- Fail-closed: throws AuditContextMissingException when ActorUserId is null.
- Translates Guid.Empty correlation id to null (DB column is nullable; Empty
  indicates 'no middleware ran').
- Uses System.DateTime.UtcNow for occurredAt.

SIGCM2.Infrastructure/Audit/SecurityEventLogger.cs (ISecurityEventLogger):
- NOT fail-closed: null ActorUserId is valid (login failures, anonymous
  permission.denied events).
- Ip/UserAgent pulled from IAuditContext; metadata sanitized the same way.
- Persists via ISecurityEventRepository.

DI: AddScoped for both loggers in AddInfrastructure.

Tests (Strict TDD, mocks for IAuditContext/IAuditEventRepository/
ISecurityEventRepository):
- AuditLoggerTests (6): happy path with full context, fail-closed null actor,
  metadata sanitization, null metadata pass-through, repo-throws-bubbles-up
  (critical for TransactionScope rollback), custom SanitizedKeys from options.
- SecurityEventLoggerTests (4): login.success with context, login.failure
  with null actor + attemptedUsername, metadata sanitization,
  permission.denied with both actor and attemptedUsername null.

Two initial failures were fixed by replacing 'null' literal arguments in
NSubstitute Received(...) assertions with Arg.Is<T?>(x => x == null) —
NSubstitute does not always match null literals when mixed with Arg.Any<T>().

Suite: 378/378 Application.Tests + 141/141 Api.Tests = 519/519 passing.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-4 #REQ-SEC-2/3, design#D-8, tasks#B6}
This commit is contained in:
2026-04-16 13:41:10 -03:00
parent 300badda73
commit a3d6214d09
5 changed files with 366 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
using Microsoft.Extensions.Options;
using SIGCM2.Application.Audit;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Infrastructure.Audit;
/// UDT-010 — IAuditLogger implementation. Enriches from IAuditContext, sanitizes metadata,
/// persists via IAuditEventRepository. Fail-closed: throws AuditContextMissingException
/// when ActorUserId is null (system-emitted events should use a different path).
public sealed class AuditLogger : IAuditLogger
{
private readonly IAuditContext _context;
private readonly IAuditEventRepository _repo;
private readonly IOptions<AuditOptions> _options;
public AuditLogger(
IAuditContext context,
IAuditEventRepository repo,
IOptions<AuditOptions> options)
{
_context = context;
_repo = repo;
_options = options;
}
public async Task LogAsync(
string action,
string targetType,
string targetId,
object? metadata = null,
CancellationToken ct = default)
{
if (_context.ActorUserId is null)
throw new AuditContextMissingException();
var sanitized = metadata is null
? null
: JsonSanitizer.Sanitize(metadata, _options.Value.SanitizedKeys);
var correlationId = _context.CorrelationId == Guid.Empty
? (Guid?)null
: _context.CorrelationId;
await _repo.InsertAsync(
occurredAt: DateTime.UtcNow,
actorUserId: _context.ActorUserId,
actorRoleId: _context.ActorRoleId,
action: action,
targetType: targetType,
targetId: targetId,
correlationId: correlationId,
ipAddress: _context.Ip,
userAgent: _context.UserAgent,
metadata: sanitized,
ct: ct);
}
}

View File

@@ -0,0 +1,52 @@
using Microsoft.Extensions.Options;
using SIGCM2.Application.Audit;
namespace SIGCM2.Infrastructure.Audit;
/// UDT-010 — ISecurityEventLogger implementation. Unlike AuditLogger this is NOT
/// fail-closed on missing actor: login failures have no ActorUserId by design.
/// Ip/UserAgent are pulled from IAuditContext when available (null in pre-auth paths).
public sealed class SecurityEventLogger : ISecurityEventLogger
{
private readonly ISecurityEventRepository _repo;
private readonly IAuditContext _context;
private readonly IOptions<AuditOptions> _options;
public SecurityEventLogger(
ISecurityEventRepository repo,
IAuditContext context,
IOptions<AuditOptions> options)
{
_repo = repo;
_context = context;
_options = options;
}
public async Task LogAsync(
string action,
string result,
int? actorUserId = null,
string? attemptedUsername = null,
Guid? sessionId = null,
string? failureReason = null,
object? metadata = null,
CancellationToken ct = default)
{
var sanitized = metadata is null
? null
: JsonSanitizer.Sanitize(metadata, _options.Value.SanitizedKeys);
await _repo.InsertAsync(
occurredAt: DateTime.UtcNow,
actorUserId: actorUserId,
attemptedUsername: attemptedUsername,
sessionId: sessionId,
action: action,
result: result,
failureReason: failureReason,
ipAddress: _context.Ip,
userAgent: _context.UserAgent,
metadata: sanitized,
ct: ct);
}
}

View File

@@ -74,6 +74,8 @@ public static class DependencyInjection
services.AddScoped<IAuditContext, SIGCM2.Infrastructure.Audit.AuditContext>();
services.AddScoped<IAuditEventRepository, SIGCM2.Infrastructure.Audit.AuditEventRepository>();
services.AddScoped<ISecurityEventRepository, SIGCM2.Infrastructure.Audit.SecurityEventRepository>();
services.AddScoped<IAuditLogger, SIGCM2.Infrastructure.Audit.AuditLogger>();
services.AddScoped<ISecurityEventLogger, SIGCM2.Infrastructure.Audit.SecurityEventLogger>();
// Dispatcher
services.AddScoped<IDispatcher, Dispatcher>();