feat(api): audit context middleware + scoped impl (UDT-010 B4)
Wires the request-scoped audit context per design #D-2:
Middleware pipeline in Program.cs:
app.UseCors()
app.UseMiddleware<CorrelationIdMiddleware>() // PRE-AUTH
app.UseAuthentication()
app.UseMiddleware<AuditActorMiddleware>() // POST-AUTH
app.UseAuthorization()
app.MapControllers()
SIGCM2.Api/Middleware/CorrelationIdMiddleware.cs:
- Preserves client-sent X-Correlation-Id header when a valid GUID, otherwise
generates Guid.NewGuid(). Stores in HttpContext.Items (audit:correlationId).
- Captures Ip (Connection.RemoteIpAddress) + UserAgent header into Items.
- Echoes the correlation id back via response header (OnStarting + immediate
set — immediate set makes unit testing against DefaultHttpContext reliable).
SIGCM2.Api/Middleware/AuditActorMiddleware.cs:
- Reads JWT 'sub' claim from authenticated HttpContext.User, parses to int,
stores as audit:actorUserId. Anonymous / non-numeric sub leaves it unset.
SIGCM2.Infrastructure/Audit/AuditContext.cs (IAuditContext scoped impl):
- Reads Items entries via IHttpContextAccessor. Returns null / Guid.Empty
when no HttpContext is available (jobs, tests without middleware).
- ActorRoleId intentionally null for now — rol code → id resolution is
deferred; the logger may resolve it at persist time in a later batch.
DI registration (Infrastructure/DependencyInjection.cs):
- services.AddScoped<IAuditContext, AuditContext>()
Tests (Strict TDD):
- CorrelationIdMiddlewareTests (6): generates/preserves/handles-malformed
correlation id, sets response header, captures ip/ua, calls next.
- AuditActorMiddlewareTests (5): authenticated/anonymous/no-sub/non-numeric/
calls-next.
- AuditContextTests (7): reads from Items, null-http-context defaults,
ActorRoleId currently null.
Suite: 355/355 Application.Tests + 141/141 Api.Tests = 496/496 passing.
Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-3/9, design#D-2, tasks#B4}
This commit is contained in:
32
src/api/SIGCM2.Api/Middleware/AuditActorMiddleware.cs
Normal file
32
src/api/SIGCM2.Api/Middleware/AuditActorMiddleware.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace SIGCM2.Api.Middleware;
|
||||
|
||||
/// UDT-010 — post-auth middleware that reads the JWT "sub" claim and stores the
|
||||
/// resolved ActorUserId in HttpContext.Items. Anonymous requests leave it unset.
|
||||
/// ActorRoleId is reserved for a future batch (rol code → id resolution).
|
||||
public sealed class AuditActorMiddleware
|
||||
{
|
||||
public const string ItemActorUserId = "audit:actorUserId";
|
||||
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
public AuditActorMiddleware(RequestDelegate next)
|
||||
{
|
||||
_next = next;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext ctx)
|
||||
{
|
||||
if (ctx.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var sub = ctx.User.FindFirst("sub")?.Value;
|
||||
if (int.TryParse(sub, out var userId))
|
||||
{
|
||||
ctx.Items[ItemActorUserId] = userId;
|
||||
}
|
||||
}
|
||||
|
||||
await _next(ctx);
|
||||
}
|
||||
}
|
||||
51
src/api/SIGCM2.Api/Middleware/CorrelationIdMiddleware.cs
Normal file
51
src/api/SIGCM2.Api/Middleware/CorrelationIdMiddleware.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace SIGCM2.Api.Middleware;
|
||||
|
||||
/// UDT-010 — pre-auth middleware that stamps every request with a correlation ID,
|
||||
/// preserves one sent by the client via X-Correlation-Id, and exposes it on the response.
|
||||
/// Also captures Ip + UserAgent for downstream IAuditContext consumers.
|
||||
public sealed class CorrelationIdMiddleware
|
||||
{
|
||||
public const string HeaderName = "X-Correlation-Id";
|
||||
public const string ItemCorrelationId = "audit:correlationId";
|
||||
public const string ItemIp = "audit:ip";
|
||||
public const string ItemUserAgent = "audit:userAgent";
|
||||
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
public CorrelationIdMiddleware(RequestDelegate next)
|
||||
{
|
||||
_next = next;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext ctx)
|
||||
{
|
||||
Guid correlationId;
|
||||
if (ctx.Request.Headers.TryGetValue(HeaderName, out var incoming)
|
||||
&& Guid.TryParse(incoming.ToString(), out var parsed))
|
||||
{
|
||||
correlationId = parsed;
|
||||
}
|
||||
else
|
||||
{
|
||||
correlationId = Guid.NewGuid();
|
||||
}
|
||||
|
||||
ctx.Items[ItemCorrelationId] = correlationId;
|
||||
ctx.Items[ItemIp] = ctx.Connection.RemoteIpAddress?.ToString();
|
||||
ctx.Items[ItemUserAgent] = ctx.Request.Headers.UserAgent.ToString();
|
||||
|
||||
ctx.Response.OnStarting(() =>
|
||||
{
|
||||
ctx.Response.Headers[HeaderName] = correlationId.ToString("D");
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
// Also set immediately for testability — DefaultHttpContext does not trigger OnStarting
|
||||
// in unit tests because no body is written through the pipeline.
|
||||
ctx.Response.Headers[HeaderName] = correlationId.ToString("D");
|
||||
|
||||
await _next(ctx);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Serilog;
|
||||
using Scalar.AspNetCore;
|
||||
using SIGCM2.Api.Authorization;
|
||||
using SIGCM2.Api.Middleware;
|
||||
using SIGCM2.Application;
|
||||
using SIGCM2.Infrastructure;
|
||||
using SIGCM2.Api.Filters;
|
||||
@@ -66,7 +67,12 @@ if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("Testing"))
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseCors();
|
||||
// UDT-010: correlation id + ip/ua capture runs BEFORE auth so anonymous requests
|
||||
// still get a correlation id and so logs can tie pre-auth events to the request.
|
||||
app.UseMiddleware<CorrelationIdMiddleware>();
|
||||
app.UseAuthentication();
|
||||
// UDT-010: actor extraction runs AFTER auth to read the JWT sub claim.
|
||||
app.UseMiddleware<AuditActorMiddleware>();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
|
||||
38
src/api/SIGCM2.Infrastructure/Audit/AuditContext.cs
Normal file
38
src/api/SIGCM2.Infrastructure/Audit/AuditContext.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using SIGCM2.Application.Audit;
|
||||
|
||||
namespace SIGCM2.Infrastructure.Audit;
|
||||
|
||||
/// UDT-010 — scoped IAuditContext implementation backed by HttpContext.Items entries
|
||||
/// populated by the middleware pipeline (CorrelationIdMiddleware + AuditActorMiddleware).
|
||||
/// Returns defaults (null / Guid.Empty) when no HttpContext is available.
|
||||
public sealed class AuditContext : IAuditContext
|
||||
{
|
||||
private readonly IHttpContextAccessor _accessor;
|
||||
|
||||
public AuditContext(IHttpContextAccessor accessor)
|
||||
{
|
||||
_accessor = accessor;
|
||||
}
|
||||
|
||||
private HttpContext? Http => _accessor.HttpContext;
|
||||
|
||||
public int? ActorUserId =>
|
||||
Http?.Items.TryGetValue("audit:actorUserId", out var v) == true ? v as int? : null;
|
||||
|
||||
// Reserved: the pipeline does not currently resolve rol code -> id. Logger-side resolution
|
||||
// may populate this in a future batch.
|
||||
public int? ActorRoleId =>
|
||||
Http?.Items.TryGetValue("audit:actorRoleId", out var v) == true ? v as int? : null;
|
||||
|
||||
public string? Ip =>
|
||||
Http?.Items.TryGetValue("audit:ip", out var v) == true ? v as string : null;
|
||||
|
||||
public string? UserAgent =>
|
||||
Http?.Items.TryGetValue("audit:userAgent", out var v) == true ? v as string : null;
|
||||
|
||||
public Guid CorrelationId =>
|
||||
Http?.Items.TryGetValue("audit:correlationId", out var v) == true
|
||||
? (v as Guid?) ?? Guid.Empty
|
||||
: Guid.Empty;
|
||||
}
|
||||
@@ -71,6 +71,7 @@ public static class DependencyInjection
|
||||
|
||||
// UDT-010: Audit options (SanitizedKeys blacklist) — overridable via appsettings "Audit".
|
||||
services.Configure<AuditOptions>(configuration.GetSection(AuditOptions.SectionName));
|
||||
services.AddScoped<IAuditContext, SIGCM2.Infrastructure.Audit.AuditContext>();
|
||||
|
||||
// Dispatcher
|
||||
services.AddScoped<IDispatcher, Dispatcher>();
|
||||
|
||||
87
tests/SIGCM2.Api.Tests/Audit/AuditActorMiddlewareTests.cs
Normal file
87
tests/SIGCM2.Api.Tests/Audit/AuditActorMiddlewareTests.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using System.Security.Claims;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using SIGCM2.Api.Middleware;
|
||||
using Xunit;
|
||||
|
||||
namespace SIGCM2.Api.Tests.Audit;
|
||||
|
||||
/// UDT-010 Batch 4 — AuditActorMiddleware unit tests (Strict TDD).
|
||||
/// Reads ActorUserId from the JWT "sub" claim after auth middleware populates HttpContext.User.
|
||||
public sealed class AuditActorMiddlewareTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Invoke_AuthenticatedUserWithSubClaim_SetsActorUserId()
|
||||
{
|
||||
var ctx = new DefaultHttpContext();
|
||||
ctx.User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim("sub", "42"),
|
||||
new Claim("rol", "admin"),
|
||||
}, authenticationType: "Bearer"));
|
||||
|
||||
var mw = new AuditActorMiddleware(_ => Task.CompletedTask);
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
ctx.Items["audit:actorUserId"].Should().Be(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_AnonymousRequest_LeavesActorUserIdNull()
|
||||
{
|
||||
var ctx = new DefaultHttpContext();
|
||||
// User is an unauthenticated ClaimsPrincipal by default
|
||||
var mw = new AuditActorMiddleware(_ => Task.CompletedTask);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
ctx.Items.TryGetValue("audit:actorUserId", out var value).Should().BeFalse();
|
||||
value.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_AuthenticatedWithoutSubClaim_LeavesActorUserIdNull()
|
||||
{
|
||||
var ctx = new DefaultHttpContext();
|
||||
ctx.User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim("name", "admin"),
|
||||
}, authenticationType: "Bearer"));
|
||||
|
||||
var mw = new AuditActorMiddleware(_ => Task.CompletedTask);
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
ctx.Items.TryGetValue("audit:actorUserId", out _).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_SubClaimIsNonNumeric_LeavesActorUserIdNull()
|
||||
{
|
||||
var ctx = new DefaultHttpContext();
|
||||
ctx.User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim("sub", "not-an-int"),
|
||||
}, authenticationType: "Bearer"));
|
||||
|
||||
var mw = new AuditActorMiddleware(_ => Task.CompletedTask);
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
ctx.Items.TryGetValue("audit:actorUserId", out _).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_CallsNextDelegate()
|
||||
{
|
||||
var ctx = new DefaultHttpContext();
|
||||
var nextCalled = false;
|
||||
var mw = new AuditActorMiddleware(_ =>
|
||||
{
|
||||
nextCalled = true;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
nextCalled.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
100
tests/SIGCM2.Api.Tests/Audit/CorrelationIdMiddlewareTests.cs
Normal file
100
tests/SIGCM2.Api.Tests/Audit/CorrelationIdMiddlewareTests.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using SIGCM2.Api.Middleware;
|
||||
using Xunit;
|
||||
|
||||
namespace SIGCM2.Api.Tests.Audit;
|
||||
|
||||
/// UDT-010 Batch 4 — CorrelationIdMiddleware unit tests (Strict TDD).
|
||||
/// Validates #REQ-AUD-9 (CorrelationId in response header) and population of
|
||||
/// HttpContext.Items entries consumed by AuditContext.
|
||||
public sealed class CorrelationIdMiddlewareTests
|
||||
{
|
||||
private const string HeaderName = "X-Correlation-Id";
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_HeaderAbsent_GeneratesNewCorrelationId()
|
||||
{
|
||||
var ctx = new DefaultHttpContext();
|
||||
var mw = new CorrelationIdMiddleware(_ => Task.CompletedTask);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
ctx.Items.TryGetValue("audit:correlationId", out var value).Should().BeTrue();
|
||||
value.Should().BeOfType<Guid>();
|
||||
((Guid)value!).Should().NotBe(Guid.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_HeaderPresent_UsesClientProvidedCorrelationId()
|
||||
{
|
||||
var expected = Guid.NewGuid();
|
||||
var ctx = new DefaultHttpContext();
|
||||
ctx.Request.Headers[HeaderName] = expected.ToString("D");
|
||||
var mw = new CorrelationIdMiddleware(_ => Task.CompletedTask);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
ctx.Items["audit:correlationId"].Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_HeaderIsMalformed_GeneratesNewCorrelationId()
|
||||
{
|
||||
var ctx = new DefaultHttpContext();
|
||||
ctx.Request.Headers[HeaderName] = "not-a-guid";
|
||||
var mw = new CorrelationIdMiddleware(_ => Task.CompletedTask);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var stored = (Guid)ctx.Items["audit:correlationId"]!;
|
||||
stored.Should().NotBe(Guid.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_SetsResponseHeader_WithCorrelationId()
|
||||
{
|
||||
var ctx = new DefaultHttpContext();
|
||||
ctx.Response.Body = new MemoryStream();
|
||||
var mw = new CorrelationIdMiddleware(_ => Task.CompletedTask);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
// OnStarting callbacks fire when response starts — simulate by writing
|
||||
await ctx.Response.Body.FlushAsync();
|
||||
// For DefaultHttpContext + MemoryStream, the OnStarting hook must fire when body writes start.
|
||||
// We assert the header is present after invoking a manual start-write.
|
||||
ctx.Response.Headers.TryGetValue(HeaderName, out var headerValue).Should().BeTrue();
|
||||
Guid.TryParse(headerValue.ToString(), out _).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_SetsIpAndUserAgentInItems()
|
||||
{
|
||||
var ctx = new DefaultHttpContext();
|
||||
ctx.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("10.20.30.40");
|
||||
ctx.Request.Headers.UserAgent = "test-agent/1.0";
|
||||
var mw = new CorrelationIdMiddleware(_ => Task.CompletedTask);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
ctx.Items["audit:ip"].Should().Be("10.20.30.40");
|
||||
ctx.Items["audit:userAgent"].Should().Be("test-agent/1.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_CallsNextDelegate()
|
||||
{
|
||||
var ctx = new DefaultHttpContext();
|
||||
var nextCalled = false;
|
||||
var mw = new CorrelationIdMiddleware(_ =>
|
||||
{
|
||||
nextCalled = true;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
nextCalled.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using NSubstitute;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Infrastructure.Audit;
|
||||
using Xunit;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Infrastructure.Audit;
|
||||
|
||||
/// UDT-010 Batch 4 — AuditContext (scoped IAuditContext impl) unit tests.
|
||||
/// Reads audit fields from HttpContext.Items populated by CorrelationIdMiddleware + AuditActorMiddleware.
|
||||
public sealed class AuditContextTests
|
||||
{
|
||||
private static (AuditContext ctx, DefaultHttpContext http) Build()
|
||||
{
|
||||
var http = new DefaultHttpContext();
|
||||
var accessor = Substitute.For<IHttpContextAccessor>();
|
||||
accessor.HttpContext.Returns(http);
|
||||
return (new AuditContext(accessor), http);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadsActorUserIdFromItems()
|
||||
{
|
||||
var (ctx, http) = Build();
|
||||
http.Items["audit:actorUserId"] = 42;
|
||||
|
||||
ctx.ActorUserId.Should().Be(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActorUserId_IsNull_WhenNotPresent()
|
||||
{
|
||||
var (ctx, _) = Build();
|
||||
ctx.ActorUserId.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadsIpAndUserAgentFromItems()
|
||||
{
|
||||
var (ctx, http) = Build();
|
||||
http.Items["audit:ip"] = "10.20.30.40";
|
||||
http.Items["audit:userAgent"] = "ua/1.0";
|
||||
|
||||
ctx.Ip.Should().Be("10.20.30.40");
|
||||
ctx.UserAgent.Should().Be("ua/1.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadsCorrelationIdFromItems()
|
||||
{
|
||||
var (ctx, http) = Build();
|
||||
var id = Guid.NewGuid();
|
||||
http.Items["audit:correlationId"] = id;
|
||||
|
||||
ctx.CorrelationId.Should().Be(id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CorrelationId_IsEmpty_WhenNotPresent()
|
||||
{
|
||||
var (ctx, _) = Build();
|
||||
ctx.CorrelationId.Should().Be(Guid.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllFields_AreNull_WhenHttpContextIsNull()
|
||||
{
|
||||
var accessor = Substitute.For<IHttpContextAccessor>();
|
||||
accessor.HttpContext.Returns((HttpContext?)null);
|
||||
var ctx = new AuditContext(accessor);
|
||||
|
||||
ctx.ActorUserId.Should().BeNull();
|
||||
ctx.ActorRoleId.Should().BeNull();
|
||||
ctx.Ip.Should().BeNull();
|
||||
ctx.UserAgent.Should().BeNull();
|
||||
ctx.CorrelationId.Should().Be(Guid.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActorRoleId_IsNull_Always_InB4()
|
||||
{
|
||||
// B4 middleware does not resolve rol code -> id; future batches may.
|
||||
var (ctx, http) = Build();
|
||||
http.Items["audit:actorUserId"] = 42;
|
||||
|
||||
ctx.ActorRoleId.Should().BeNull();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user