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:
57
src/api/SIGCM2.Infrastructure/Audit/AuditLogger.cs
Normal file
57
src/api/SIGCM2.Infrastructure/Audit/AuditLogger.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/api/SIGCM2.Infrastructure/Audit/SecurityEventLogger.cs
Normal file
52
src/api/SIGCM2.Infrastructure/Audit/SecurityEventLogger.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,6 +74,8 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IAuditContext, SIGCM2.Infrastructure.Audit.AuditContext>();
|
services.AddScoped<IAuditContext, SIGCM2.Infrastructure.Audit.AuditContext>();
|
||||||
services.AddScoped<IAuditEventRepository, SIGCM2.Infrastructure.Audit.AuditEventRepository>();
|
services.AddScoped<IAuditEventRepository, SIGCM2.Infrastructure.Audit.AuditEventRepository>();
|
||||||
services.AddScoped<ISecurityEventRepository, SIGCM2.Infrastructure.Audit.SecurityEventRepository>();
|
services.AddScoped<ISecurityEventRepository, SIGCM2.Infrastructure.Audit.SecurityEventRepository>();
|
||||||
|
services.AddScoped<IAuditLogger, SIGCM2.Infrastructure.Audit.AuditLogger>();
|
||||||
|
services.AddScoped<ISecurityEventLogger, SIGCM2.Infrastructure.Audit.SecurityEventLogger>();
|
||||||
|
|
||||||
// Dispatcher
|
// Dispatcher
|
||||||
services.AddScoped<IDispatcher, Dispatcher>();
|
services.AddScoped<IDispatcher, Dispatcher>();
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NSubstitute;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
using SIGCM2.Infrastructure.Audit;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.Infrastructure.Audit;
|
||||||
|
|
||||||
|
/// UDT-010 Batch 6 — AuditLogger unit tests (Strict TDD).
|
||||||
|
/// Covers #REQ-AUD-3/4/5: enriches from IAuditContext, fail-closed on missing actor,
|
||||||
|
/// sanitizes metadata via JsonSanitizer + AuditOptions.
|
||||||
|
public sealed class AuditLoggerTests
|
||||||
|
{
|
||||||
|
private static AuditLogger Build(
|
||||||
|
IAuditContext? context = null,
|
||||||
|
IAuditEventRepository? repo = null,
|
||||||
|
AuditOptions? options = null)
|
||||||
|
{
|
||||||
|
context ??= Substitute.For<IAuditContext>();
|
||||||
|
repo ??= Substitute.For<IAuditEventRepository>();
|
||||||
|
options ??= new AuditOptions();
|
||||||
|
var optsWrapper = Options.Create(options);
|
||||||
|
return new AuditLogger(context, repo, optsWrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LogAsync_WithAllContext_PassesEnrichedValuesToRepo()
|
||||||
|
{
|
||||||
|
var context = Substitute.For<IAuditContext>();
|
||||||
|
var correlationId = Guid.NewGuid();
|
||||||
|
context.ActorUserId.Returns(42);
|
||||||
|
context.ActorRoleId.Returns(7);
|
||||||
|
context.Ip.Returns("10.0.0.5");
|
||||||
|
context.UserAgent.Returns("ua/1.0");
|
||||||
|
context.CorrelationId.Returns(correlationId);
|
||||||
|
|
||||||
|
var repo = Substitute.For<IAuditEventRepository>();
|
||||||
|
var logger = Build(context, repo);
|
||||||
|
|
||||||
|
await logger.LogAsync("usuario.create", "Usuario", "99",
|
||||||
|
metadata: new { username = "juan" });
|
||||||
|
|
||||||
|
await repo.Received(1).InsertAsync(
|
||||||
|
Arg.Any<DateTime>(),
|
||||||
|
42,
|
||||||
|
7,
|
||||||
|
"usuario.create",
|
||||||
|
"Usuario",
|
||||||
|
"99",
|
||||||
|
correlationId,
|
||||||
|
"10.0.0.5",
|
||||||
|
"ua/1.0",
|
||||||
|
Arg.Is<string?>(m => m != null && m.Contains("\"username\"") && m.Contains("juan")),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LogAsync_WithoutActorUserId_ThrowsAuditContextMissingException()
|
||||||
|
{
|
||||||
|
var context = Substitute.For<IAuditContext>();
|
||||||
|
context.ActorUserId.Returns((int?)null);
|
||||||
|
context.CorrelationId.Returns(Guid.NewGuid());
|
||||||
|
var repo = Substitute.For<IAuditEventRepository>();
|
||||||
|
var logger = Build(context, repo);
|
||||||
|
|
||||||
|
var act = async () => await logger.LogAsync("usuario.create", "Usuario", "1");
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<AuditContextMissingException>();
|
||||||
|
await repo.DidNotReceive().InsertAsync(
|
||||||
|
Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<int?>(),
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Any<Guid?>(), Arg.Any<string?>(), Arg.Any<string?>(),
|
||||||
|
Arg.Any<string?>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LogAsync_SanitizesMetadata_StripsBlacklistedKeys()
|
||||||
|
{
|
||||||
|
var context = Substitute.For<IAuditContext>();
|
||||||
|
context.ActorUserId.Returns(1);
|
||||||
|
var repo = Substitute.For<IAuditEventRepository>();
|
||||||
|
var logger = Build(context, repo);
|
||||||
|
|
||||||
|
await logger.LogAsync("usuario.update", "Usuario", "1",
|
||||||
|
metadata: new { password = "secret", email = "e@x" });
|
||||||
|
|
||||||
|
await repo.Received(1).InsertAsync(
|
||||||
|
Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<int?>(),
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Any<Guid?>(), Arg.Any<string?>(), Arg.Any<string?>(),
|
||||||
|
Arg.Is<string?>(m => m != null && !m.Contains("\"password\"") && m.Contains("\"email\"")),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LogAsync_NullMetadata_PassesNullToRepo()
|
||||||
|
{
|
||||||
|
var context = Substitute.For<IAuditContext>();
|
||||||
|
context.ActorUserId.Returns(1);
|
||||||
|
var repo = Substitute.For<IAuditEventRepository>();
|
||||||
|
var logger = Build(context, repo);
|
||||||
|
|
||||||
|
await logger.LogAsync("usuario.deactivate", "Usuario", "1");
|
||||||
|
|
||||||
|
await repo.Received(1).InsertAsync(
|
||||||
|
Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<int?>(),
|
||||||
|
"usuario.deactivate", "Usuario", "1",
|
||||||
|
Arg.Any<Guid?>(), Arg.Any<string?>(), Arg.Any<string?>(),
|
||||||
|
Arg.Is<string?>(m => m == null),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LogAsync_RepositoryThrows_ExceptionBubblesUp()
|
||||||
|
{
|
||||||
|
var context = Substitute.For<IAuditContext>();
|
||||||
|
context.ActorUserId.Returns(1);
|
||||||
|
var repo = Substitute.For<IAuditEventRepository>();
|
||||||
|
repo.InsertAsync(
|
||||||
|
Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<int?>(),
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Any<Guid?>(), Arg.Any<string?>(), Arg.Any<string?>(),
|
||||||
|
Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns<long>(_ => throw new InvalidOperationException("simulated db failure"));
|
||||||
|
|
||||||
|
var logger = Build(context, repo);
|
||||||
|
|
||||||
|
var act = async () => await logger.LogAsync("usuario.create", "Usuario", "1");
|
||||||
|
|
||||||
|
// Fail-closed: exception MUST bubble to caller (caller's TransactionScope will roll back).
|
||||||
|
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||||
|
.WithMessage("simulated db failure");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LogAsync_UsesCustomSanitizedKeys_FromOptions()
|
||||||
|
{
|
||||||
|
var context = Substitute.For<IAuditContext>();
|
||||||
|
context.ActorUserId.Returns(1);
|
||||||
|
var repo = Substitute.For<IAuditEventRepository>();
|
||||||
|
var logger = Build(context, repo, new AuditOptions { SanitizedKeys = new[] { "internalId" } });
|
||||||
|
|
||||||
|
await logger.LogAsync("x.y", "T", "1", metadata: new { internalId = "secret", visible = "ok" });
|
||||||
|
|
||||||
|
await repo.Received(1).InsertAsync(
|
||||||
|
Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<int?>(),
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Any<Guid?>(), Arg.Any<string?>(), Arg.Any<string?>(),
|
||||||
|
Arg.Is<string?>(m => m != null && !m.Contains("\"internalId\"") && m.Contains("\"visible\"")),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NSubstitute;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Infrastructure.Audit;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.Infrastructure.Audit;
|
||||||
|
|
||||||
|
/// UDT-010 Batch 6 — SecurityEventLogger unit tests.
|
||||||
|
/// NOT fail-closed: security events are fire-and-forget writes; actor may be null
|
||||||
|
/// for login failures.
|
||||||
|
public sealed class SecurityEventLoggerTests
|
||||||
|
{
|
||||||
|
private static SecurityEventLogger Build(
|
||||||
|
ISecurityEventRepository? repo = null,
|
||||||
|
IAuditContext? context = null,
|
||||||
|
AuditOptions? options = null)
|
||||||
|
{
|
||||||
|
repo ??= Substitute.For<ISecurityEventRepository>();
|
||||||
|
context ??= Substitute.For<IAuditContext>();
|
||||||
|
options ??= new AuditOptions();
|
||||||
|
return new SecurityEventLogger(repo, context, Options.Create(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LogAsync_LoginSuccess_PassesActorAndIpFromContext()
|
||||||
|
{
|
||||||
|
var repo = Substitute.For<ISecurityEventRepository>();
|
||||||
|
var context = Substitute.For<IAuditContext>();
|
||||||
|
context.Ip.Returns("1.2.3.4");
|
||||||
|
context.UserAgent.Returns("ua");
|
||||||
|
var logger = Build(repo, context);
|
||||||
|
|
||||||
|
await logger.LogAsync("login", "success", actorUserId: 42);
|
||||||
|
|
||||||
|
await repo.Received(1).InsertAsync(
|
||||||
|
Arg.Any<DateTime>(),
|
||||||
|
42,
|
||||||
|
Arg.Is<string?>(s => s == null), // attemptedUsername
|
||||||
|
Arg.Is<Guid?>(g => g == null), // sessionId
|
||||||
|
"login", "success",
|
||||||
|
Arg.Is<string?>(s => s == null), // failureReason
|
||||||
|
"1.2.3.4",
|
||||||
|
"ua",
|
||||||
|
Arg.Is<string?>(s => s == null), // metadata
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LogAsync_LoginFailure_SupportsNullActorAndAttemptedUsername()
|
||||||
|
{
|
||||||
|
var repo = Substitute.For<ISecurityEventRepository>();
|
||||||
|
var logger = Build(repo);
|
||||||
|
|
||||||
|
await logger.LogAsync("login", "failure",
|
||||||
|
actorUserId: null,
|
||||||
|
attemptedUsername: "juan",
|
||||||
|
failureReason: "invalid_password");
|
||||||
|
|
||||||
|
await repo.Received(1).InsertAsync(
|
||||||
|
Arg.Any<DateTime>(),
|
||||||
|
Arg.Is<int?>(i => i == null),
|
||||||
|
"juan",
|
||||||
|
Arg.Is<Guid?>(g => g == null),
|
||||||
|
"login", "failure",
|
||||||
|
"invalid_password",
|
||||||
|
Arg.Any<string?>(), Arg.Any<string?>(),
|
||||||
|
Arg.Is<string?>(s => s == null),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LogAsync_SanitizesMetadata()
|
||||||
|
{
|
||||||
|
var repo = Substitute.For<ISecurityEventRepository>();
|
||||||
|
var logger = Build(repo);
|
||||||
|
|
||||||
|
await logger.LogAsync("login", "failure", attemptedUsername: "x",
|
||||||
|
metadata: new { token = "leaked", ip = "1.1.1.1" });
|
||||||
|
|
||||||
|
await repo.Received(1).InsertAsync(
|
||||||
|
Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<string?>(), Arg.Any<Guid?>(),
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
||||||
|
Arg.Any<string?>(), Arg.Any<string?>(),
|
||||||
|
Arg.Is<string?>(m => m != null && !m.Contains("\"token\"") && m.Contains("\"ip\"")),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LogAsync_DoesNotThrow_WhenActorAndAttemptedUsernameAreBothNull()
|
||||||
|
{
|
||||||
|
// This is a legitimate use case (e.g. permission.denied emitted by middleware with an expired token).
|
||||||
|
var repo = Substitute.For<ISecurityEventRepository>();
|
||||||
|
var logger = Build(repo);
|
||||||
|
|
||||||
|
var act = async () => await logger.LogAsync("permission.denied", "failure");
|
||||||
|
|
||||||
|
await act.Should().NotThrowAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user