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:
15
src/api/SIGCM2.Application/Audit/AuditEventDto.cs
Normal file
15
src/api/SIGCM2.Application/Audit/AuditEventDto.cs
Normal 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);
|
||||
13
src/api/SIGCM2.Application/Audit/AuditEventFilter.cs
Normal file
13
src/api/SIGCM2.Application/Audit/AuditEventFilter.cs
Normal 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);
|
||||
22
src/api/SIGCM2.Application/Audit/AuditOptions.cs
Normal file
22
src/api/SIGCM2.Application/Audit/AuditOptions.cs
Normal 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",
|
||||
];
|
||||
}
|
||||
14
src/api/SIGCM2.Application/Audit/IAuditContext.cs
Normal file
14
src/api/SIGCM2.Application/Audit/IAuditContext.cs
Normal 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; }
|
||||
}
|
||||
16
src/api/SIGCM2.Application/Audit/IAuditLogger.cs
Normal file
16
src/api/SIGCM2.Application/Audit/IAuditLogger.cs
Normal 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);
|
||||
}
|
||||
20
src/api/SIGCM2.Application/Audit/ISecurityEventLogger.cs
Normal file
20
src/api/SIGCM2.Application/Audit/ISecurityEventLogger.cs
Normal 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);
|
||||
}
|
||||
@@ -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.")
|
||||
{ }
|
||||
}
|
||||
179
tests/SIGCM2.Application.Tests/Audit/AuditAbstractionsTests.cs
Normal file
179
tests/SIGCM2.Application.Tests/Audit/AuditAbstractionsTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user