From 68f96b90c7ab6135f10ab93c1015744430dd7d68 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 16 Apr 2026 13:23:11 -0300 Subject: [PATCH] feat(application): audit abstractions (UDT-010 B2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the contract layer for audit logging per design #D-8: SIGCM2.Application/Audit/: - IAuditContext — request-scoped accessor for ActorUserId/ActorRoleId/ Ip/UserAgent/CorrelationId. Populated by CorrelationIdMiddleware + AuditActorMiddleware (B4). - IAuditLogger.LogAsync(action, targetType, targetId, metadata?, ct) — domain-level audit emitter. Enlists in ambient TransactionScope (fail-closed per #REQ-AUD-4). - ISecurityEventLogger.LogAsync(action, result, actorUserId?, attemptedUsername?, sessionId?, failureReason?, metadata?, ct) — security-events emitter separate from IAuditLogger (different retention, no transaction scope, captures login/logout/refresh/permission.denied). - AuditOptions — bindable POCO with SanitizedKeys[] defaults (used by JsonSanitizer in B3). - AuditEventDto — read projection for GET /api/v1/audit/events (B10). - AuditEventFilter — query filter record with default Limit=50. SIGCM2.Domain/Exceptions/: - AuditContextMissingException : DomainException — fail-closed sentinel thrown when IAuditLogger is called without ActorUserId in a user-scoped command (#REQ-AUD-4). Tests (Strict TDD — shape contract, RED → GREEN): - tests/SIGCM2.Application.Tests/Audit/AuditAbstractionsTests.cs: 11 tests covering nullability, signatures, default options, record equality. Suite: 336/336 Application.Tests (prev 325 + 11 new). 130/130 Api.Tests. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-3/4/5, design#D-8, tasks#B2} --- .../SIGCM2.Application/Audit/AuditEventDto.cs | 15 ++ .../Audit/AuditEventFilter.cs | 13 ++ .../SIGCM2.Application/Audit/AuditOptions.cs | 22 +++ .../SIGCM2.Application/Audit/IAuditContext.cs | 14 ++ .../SIGCM2.Application/Audit/IAuditLogger.cs | 16 ++ .../Audit/ISecurityEventLogger.cs | 20 ++ .../AuditContextMissingException.cs | 11 ++ .../Audit/AuditAbstractionsTests.cs | 179 ++++++++++++++++++ 8 files changed, 290 insertions(+) create mode 100644 src/api/SIGCM2.Application/Audit/AuditEventDto.cs create mode 100644 src/api/SIGCM2.Application/Audit/AuditEventFilter.cs create mode 100644 src/api/SIGCM2.Application/Audit/AuditOptions.cs create mode 100644 src/api/SIGCM2.Application/Audit/IAuditContext.cs create mode 100644 src/api/SIGCM2.Application/Audit/IAuditLogger.cs create mode 100644 src/api/SIGCM2.Application/Audit/ISecurityEventLogger.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/AuditContextMissingException.cs create mode 100644 tests/SIGCM2.Application.Tests/Audit/AuditAbstractionsTests.cs diff --git a/src/api/SIGCM2.Application/Audit/AuditEventDto.cs b/src/api/SIGCM2.Application/Audit/AuditEventDto.cs new file mode 100644 index 0000000..1d5bfb9 --- /dev/null +++ b/src/api/SIGCM2.Application/Audit/AuditEventDto.cs @@ -0,0 +1,15 @@ +namespace SIGCM2.Application.Audit; + +/// Read projection of dbo.AuditEvent for GET /api/v1/audit/events. +/// ActorUsername is resolved by join against dbo.Usuario at query time (denormalized in the DTO). +public sealed record AuditEventDto( + long Id, + DateTime OccurredAt, + int? ActorUserId, + string? ActorUsername, + string Action, + string TargetType, + string TargetId, + Guid? CorrelationId, + string? IpAddress, + string? Metadata); diff --git a/src/api/SIGCM2.Application/Audit/AuditEventFilter.cs b/src/api/SIGCM2.Application/Audit/AuditEventFilter.cs new file mode 100644 index 0000000..c80a97c --- /dev/null +++ b/src/api/SIGCM2.Application/Audit/AuditEventFilter.cs @@ -0,0 +1,13 @@ +namespace SIGCM2.Application.Audit; + +/// Filter criteria for GET /api/v1/audit/events. +/// Cursor is opaque to the caller (base64-encoded {OccurredAt, Id} tuple for stable DESC pagination). +/// Limit is clamped between 1 and 100 at the validation layer; default 50 is the sane middle. +public sealed record AuditEventFilter( + int? ActorUserId, + string? TargetType, + string? TargetId, + DateTime? From, + DateTime? To, + string? Cursor, + int Limit = 50); diff --git a/src/api/SIGCM2.Application/Audit/AuditOptions.cs b/src/api/SIGCM2.Application/Audit/AuditOptions.cs new file mode 100644 index 0000000..de19e3d --- /dev/null +++ b/src/api/SIGCM2.Application/Audit/AuditOptions.cs @@ -0,0 +1,22 @@ +namespace SIGCM2.Application.Audit; + +/// Bound from appsettings section "Audit". Extensible via configuration. +public sealed class AuditOptions +{ + public const string SectionName = "Audit"; + + public string[] SanitizedKeys { get; set; } = + [ + "password", + "passwordHash", + "token", + "refreshToken", + "accessToken", + "cvv", + "card", + "cardNumber", + "secret", + "apiKey", + "privateKey", + ]; +} diff --git a/src/api/SIGCM2.Application/Audit/IAuditContext.cs b/src/api/SIGCM2.Application/Audit/IAuditContext.cs new file mode 100644 index 0000000..a5011d6 --- /dev/null +++ b/src/api/SIGCM2.Application/Audit/IAuditContext.cs @@ -0,0 +1,14 @@ +namespace SIGCM2.Application.Audit; + +/// Request-scoped context populated by the audit middleware pipeline: +/// CorrelationIdMiddleware (pre-auth) sets CorrelationId/Ip/UserAgent; +/// AuditActorMiddleware (post-auth) fills ActorUserId/ActorRoleId from JWT claims. +/// Consumed by IAuditLogger to enrich every AuditEvent emitted within the request. +public interface IAuditContext +{ + int? ActorUserId { get; } + int? ActorRoleId { get; } + string? Ip { get; } + string? UserAgent { get; } + Guid CorrelationId { get; } +} diff --git a/src/api/SIGCM2.Application/Audit/IAuditLogger.cs b/src/api/SIGCM2.Application/Audit/IAuditLogger.cs new file mode 100644 index 0000000..2790bd4 --- /dev/null +++ b/src/api/SIGCM2.Application/Audit/IAuditLogger.cs @@ -0,0 +1,16 @@ +namespace SIGCM2.Application.Audit; + +/// Emits dbo.AuditEvent rows for domain-level actions (create/update/delete of business entities). +/// LogAsync enlists in the ambient TransactionScope — if the INSERT fails, the caller's command rolls back. +/// Metadata is sanitized (see AuditOptions.SanitizedKeys) before persisting. +/// If IAuditContext.ActorUserId is null while called from a user-scoped command, an +/// AuditContextMissingException is thrown — fail-closed by design. +public interface IAuditLogger +{ + Task LogAsync( + string action, + string targetType, + string targetId, + object? metadata = null, + CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/Audit/ISecurityEventLogger.cs b/src/api/SIGCM2.Application/Audit/ISecurityEventLogger.cs new file mode 100644 index 0000000..c1f56e7 --- /dev/null +++ b/src/api/SIGCM2.Application/Audit/ISecurityEventLogger.cs @@ -0,0 +1,20 @@ +namespace SIGCM2.Application.Audit; + +/// Emits dbo.SecurityEvent rows for auth/authorization events (login, logout, refresh, permission.denied). +/// Separate from IAuditLogger because: +/// - Volume is ~100× higher than AuditEvent (retention 5y vs 10y). +/// - Invoked from infrastructure layers (auth handlers, middlewares) where no ambient +/// business transaction exists — not enlisted in TransactionScope. +/// - Schema includes Result/FailureReason/AttemptedUsername/SessionId specific to security events. +public interface ISecurityEventLogger +{ + Task LogAsync( + string action, + string result, + int? actorUserId = null, + string? attemptedUsername = null, + Guid? sessionId = null, + string? failureReason = null, + object? metadata = null, + CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Domain/Exceptions/AuditContextMissingException.cs b/src/api/SIGCM2.Domain/Exceptions/AuditContextMissingException.cs new file mode 100644 index 0000000..d33140a --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/AuditContextMissingException.cs @@ -0,0 +1,11 @@ +namespace SIGCM2.Domain.Exceptions; + +/// Thrown when IAuditLogger.LogAsync runs without ActorUserId in the audit context while the action +/// requires user attribution (i.e. not a system job). Fail-closed: better to break the command than +/// to persist an audit event with a missing actor. +public sealed class AuditContextMissingException : DomainException +{ + public AuditContextMissingException() + : base("Audit context is missing ActorUserId for a user-scoped action. Ensure AuditActorMiddleware ran and the request is authenticated.") + { } +} diff --git a/tests/SIGCM2.Application.Tests/Audit/AuditAbstractionsTests.cs b/tests/SIGCM2.Application.Tests/Audit/AuditAbstractionsTests.cs new file mode 100644 index 0000000..8870165 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Audit/AuditAbstractionsTests.cs @@ -0,0 +1,179 @@ +using FluentAssertions; +using NSubstitute; +using SIGCM2.Application.Audit; +using SIGCM2.Domain.Exceptions; +using Xunit; + +namespace SIGCM2.Application.Tests.Audit; + +/// UDT-010 Batch 2 — shape contract tests for audit abstractions. +/// These tests fail to compile if the interface/DTO/exception shape drifts from #D-8. +public sealed class AuditAbstractionsTests +{ + [Fact] + public void IAuditContext_ExposesExpectedReadOnlyProperties() + { + var ctx = Substitute.For(); + ctx.ActorUserId.Returns(42); + ctx.ActorRoleId.Returns(7); + ctx.Ip.Returns("127.0.0.1"); + ctx.UserAgent.Returns("test-agent/1.0"); + var corrId = Guid.NewGuid(); + ctx.CorrelationId.Returns(corrId); + + ctx.ActorUserId.Should().Be(42); + ctx.ActorRoleId.Should().Be(7); + ctx.Ip.Should().Be("127.0.0.1"); + ctx.UserAgent.Should().Be("test-agent/1.0"); + ctx.CorrelationId.Should().Be(corrId); + } + + [Fact] + public void IAuditContext_AllowsNullActorAndConnectionMetadata() + { + var ctx = Substitute.For(); + ctx.ActorUserId.Returns((int?)null); + ctx.ActorRoleId.Returns((int?)null); + ctx.Ip.Returns((string?)null); + ctx.UserAgent.Returns((string?)null); + + ctx.ActorUserId.Should().BeNull(); + ctx.ActorRoleId.Should().BeNull(); + ctx.Ip.Should().BeNull(); + ctx.UserAgent.Should().BeNull(); + } + + [Fact] + public async Task IAuditLogger_LogAsync_ExposesExpectedSignature() + { + var logger = Substitute.For(); + + await logger.LogAsync( + action: "usuario.create", + targetType: "Usuario", + targetId: "42", + metadata: new { username = "juan" }, + ct: CancellationToken.None); + + await logger.Received(1).LogAsync( + "usuario.create", + "Usuario", + "42", + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task IAuditLogger_LogAsync_AllowsNullMetadata() + { + var logger = Substitute.For(); + await logger.LogAsync("usuario.deactivate", "Usuario", "42"); + + await logger.Received(1).LogAsync( + "usuario.deactivate", + "Usuario", + "42", + null, + Arg.Any()); + } + + [Fact] + public async Task ISecurityEventLogger_LogAsync_ExposesFullSignatureForLoginFailure() + { + var logger = Substitute.For(); + var sessionId = Guid.NewGuid(); + + await logger.LogAsync( + action: "login", + result: "failure", + actorUserId: null, + attemptedUsername: "juan", + sessionId: sessionId, + failureReason: "invalid_password", + metadata: new { ip = "1.2.3.4" }, + ct: CancellationToken.None); + + await logger.Received(1).LogAsync( + "login", "failure", null, "juan", sessionId, "invalid_password", + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ISecurityEventLogger_LogAsync_MinimalSuccessCall() + { + var logger = Substitute.For(); + await logger.LogAsync("logout", "success", actorUserId: 42); + + await logger.Received(1).LogAsync( + "logout", "success", 42, null, null, null, null, Arg.Any()); + } + + [Fact] + public void AuditContextMissingException_IsDomainException_WithFixedMessage() + { + var ex = new AuditContextMissingException(); + + ex.Should().BeAssignableTo(); + ex.Message.Should().NotBeNullOrWhiteSpace(); + ex.Message.Should().Contain("ActorUserId"); + } + + [Fact] + public void AuditOptions_HasSanitizedKeysDefault() + { + var opts = new AuditOptions(); + + opts.SanitizedKeys.Should().NotBeNull(); + opts.SanitizedKeys.Should().Contain(new[] { "password", "passwordHash", "token", "refreshToken" }); + } + + [Fact] + public void AuditEventDto_IsReadonlyRecord() + { + var dto = new AuditEventDto( + Id: 1, + OccurredAt: DateTime.UtcNow, + ActorUserId: 42, + ActorUsername: "admin", + Action: "usuario.create", + TargetType: "Usuario", + TargetId: "99", + CorrelationId: Guid.NewGuid(), + IpAddress: "1.2.3.4", + Metadata: """{"after":{"username":"juan"}}"""); + + dto.Id.Should().Be(1); + dto.Action.Should().Be("usuario.create"); + dto.TargetType.Should().Be("Usuario"); + } + + [Fact] + public void AuditEventFilter_DefaultLimitIsReasonable() + { + var f = new AuditEventFilter( + ActorUserId: null, + TargetType: null, + TargetId: null, + From: null, + To: null, + Cursor: null); + + f.Limit.Should().BeInRange(1, 100); + } + + [Fact] + public void AuditEventFilter_AcceptsExplicitLimitWithinBounds() + { + var f = new AuditEventFilter( + ActorUserId: 42, + TargetType: "Usuario", + TargetId: "99", + From: DateTime.UtcNow.AddDays(-7), + To: DateTime.UtcNow, + Cursor: "opaque-cursor", + Limit: 100); + + f.Limit.Should().Be(100); + f.ActorUserId.Should().Be(42); + } +}