diff --git a/src/api/SIGCM2.Infrastructure/Audit/AuditLogger.cs b/src/api/SIGCM2.Infrastructure/Audit/AuditLogger.cs new file mode 100644 index 0000000..6376ec1 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Audit/AuditLogger.cs @@ -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 _options; + + public AuditLogger( + IAuditContext context, + IAuditEventRepository repo, + IOptions 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); + } +} diff --git a/src/api/SIGCM2.Infrastructure/Audit/SecurityEventLogger.cs b/src/api/SIGCM2.Infrastructure/Audit/SecurityEventLogger.cs new file mode 100644 index 0000000..3b3a94e --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Audit/SecurityEventLogger.cs @@ -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 _options; + + public SecurityEventLogger( + ISecurityEventRepository repo, + IAuditContext context, + IOptions 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); + } +} diff --git a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs index 0a15373..e9fe1b3 100644 --- a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs +++ b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs @@ -74,6 +74,8 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // Dispatcher services.AddScoped(); diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditLoggerTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditLoggerTests.cs new file mode 100644 index 0000000..6dc1615 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditLoggerTests.cs @@ -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(); + repo ??= Substitute.For(); + 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(); + 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(); + var logger = Build(context, repo); + + await logger.LogAsync("usuario.create", "Usuario", "99", + metadata: new { username = "juan" }); + + await repo.Received(1).InsertAsync( + Arg.Any(), + 42, + 7, + "usuario.create", + "Usuario", + "99", + correlationId, + "10.0.0.5", + "ua/1.0", + Arg.Is(m => m != null && m.Contains("\"username\"") && m.Contains("juan")), + Arg.Any()); + } + + [Fact] + public async Task LogAsync_WithoutActorUserId_ThrowsAuditContextMissingException() + { + var context = Substitute.For(); + context.ActorUserId.Returns((int?)null); + context.CorrelationId.Returns(Guid.NewGuid()); + var repo = Substitute.For(); + var logger = Build(context, repo); + + var act = async () => await logger.LogAsync("usuario.create", "Usuario", "1"); + + await act.Should().ThrowAsync(); + await repo.DidNotReceive().InsertAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task LogAsync_SanitizesMetadata_StripsBlacklistedKeys() + { + var context = Substitute.For(); + context.ActorUserId.Returns(1); + var repo = Substitute.For(); + 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(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Is(m => m != null && !m.Contains("\"password\"") && m.Contains("\"email\"")), + Arg.Any()); + } + + [Fact] + public async Task LogAsync_NullMetadata_PassesNullToRepo() + { + var context = Substitute.For(); + context.ActorUserId.Returns(1); + var repo = Substitute.For(); + var logger = Build(context, repo); + + await logger.LogAsync("usuario.deactivate", "Usuario", "1"); + + await repo.Received(1).InsertAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + "usuario.deactivate", "Usuario", "1", + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Is(m => m == null), + Arg.Any()); + } + + [Fact] + public async Task LogAsync_RepositoryThrows_ExceptionBubblesUp() + { + var context = Substitute.For(); + context.ActorUserId.Returns(1); + var repo = Substitute.For(); + repo.InsertAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(_ => 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() + .WithMessage("simulated db failure"); + } + + [Fact] + public async Task LogAsync_UsesCustomSanitizedKeys_FromOptions() + { + var context = Substitute.For(); + context.ActorUserId.Returns(1); + var repo = Substitute.For(); + 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(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Is(m => m != null && !m.Contains("\"internalId\"") && m.Contains("\"visible\"")), + Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Audit/SecurityEventLoggerTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/SecurityEventLoggerTests.cs new file mode 100644 index 0000000..45ca6fb --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/SecurityEventLoggerTests.cs @@ -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(); + context ??= Substitute.For(); + options ??= new AuditOptions(); + return new SecurityEventLogger(repo, context, Options.Create(options)); + } + + [Fact] + public async Task LogAsync_LoginSuccess_PassesActorAndIpFromContext() + { + var repo = Substitute.For(); + var context = Substitute.For(); + 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(), + 42, + Arg.Is(s => s == null), // attemptedUsername + Arg.Is(g => g == null), // sessionId + "login", "success", + Arg.Is(s => s == null), // failureReason + "1.2.3.4", + "ua", + Arg.Is(s => s == null), // metadata + Arg.Any()); + } + + [Fact] + public async Task LogAsync_LoginFailure_SupportsNullActorAndAttemptedUsername() + { + var repo = Substitute.For(); + var logger = Build(repo); + + await logger.LogAsync("login", "failure", + actorUserId: null, + attemptedUsername: "juan", + failureReason: "invalid_password"); + + await repo.Received(1).InsertAsync( + Arg.Any(), + Arg.Is(i => i == null), + "juan", + Arg.Is(g => g == null), + "login", "failure", + "invalid_password", + Arg.Any(), Arg.Any(), + Arg.Is(s => s == null), + Arg.Any()); + } + + [Fact] + public async Task LogAsync_SanitizesMetadata() + { + var repo = Substitute.For(); + 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(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), + Arg.Is(m => m != null && !m.Contains("\"token\"") && m.Contains("\"ip\"")), + Arg.Any()); + } + + [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(); + var logger = Build(repo); + + var act = async () => await logger.LogAsync("permission.denied", "failure"); + + await act.Should().NotThrowAsync(); + } +}