feat(application): audit abstractions (UDT-010 B2)

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}
This commit is contained in:
2026-04-16 13:23:11 -03:00
parent c95bc7fe01
commit 68f96b90c7
8 changed files with 290 additions and 0 deletions

View File

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

View File

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

View File

@@ -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",
];
}

View File

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

View File

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

View File

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

View File

@@ -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.")
{ }
}

View File

@@ -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<IAuditContext>();
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<IAuditContext>();
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<IAuditLogger>();
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<object>(),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task IAuditLogger_LogAsync_AllowsNullMetadata()
{
var logger = Substitute.For<IAuditLogger>();
await logger.LogAsync("usuario.deactivate", "Usuario", "42");
await logger.Received(1).LogAsync(
"usuario.deactivate",
"Usuario",
"42",
null,
Arg.Any<CancellationToken>());
}
[Fact]
public async Task ISecurityEventLogger_LogAsync_ExposesFullSignatureForLoginFailure()
{
var logger = Substitute.For<ISecurityEventLogger>();
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<object>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task ISecurityEventLogger_LogAsync_MinimalSuccessCall()
{
var logger = Substitute.For<ISecurityEventLogger>();
await logger.LogAsync("logout", "success", actorUserId: 42);
await logger.Received(1).LogAsync(
"logout", "success", 42, null, null, null, null, Arg.Any<CancellationToken>());
}
[Fact]
public void AuditContextMissingException_IsDomainException_WithFixedMessage()
{
var ex = new AuditContextMissingException();
ex.Should().BeAssignableTo<DomainException>();
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);
}
}