diff --git a/src/api/SIGCM2.Api/Middleware/AuditActorMiddleware.cs b/src/api/SIGCM2.Api/Middleware/AuditActorMiddleware.cs new file mode 100644 index 0000000..13d416f --- /dev/null +++ b/src/api/SIGCM2.Api/Middleware/AuditActorMiddleware.cs @@ -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); + } +} diff --git a/src/api/SIGCM2.Api/Middleware/CorrelationIdMiddleware.cs b/src/api/SIGCM2.Api/Middleware/CorrelationIdMiddleware.cs new file mode 100644 index 0000000..eb799a3 --- /dev/null +++ b/src/api/SIGCM2.Api/Middleware/CorrelationIdMiddleware.cs @@ -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); + } +} diff --git a/src/api/SIGCM2.Api/Program.cs b/src/api/SIGCM2.Api/Program.cs index 313fe60..cc45135 100644 --- a/src/api/SIGCM2.Api/Program.cs +++ b/src/api/SIGCM2.Api/Program.cs @@ -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(); app.UseAuthentication(); +// UDT-010: actor extraction runs AFTER auth to read the JWT sub claim. +app.UseMiddleware(); app.UseAuthorization(); app.MapControllers(); diff --git a/src/api/SIGCM2.Infrastructure/Audit/AuditContext.cs b/src/api/SIGCM2.Infrastructure/Audit/AuditContext.cs new file mode 100644 index 0000000..81ad0f2 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Audit/AuditContext.cs @@ -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; +} diff --git a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs index 1efd8a9..946833d 100644 --- a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs +++ b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs @@ -71,6 +71,7 @@ public static class DependencyInjection // UDT-010: Audit options (SanitizedKeys blacklist) — overridable via appsettings "Audit". services.Configure(configuration.GetSection(AuditOptions.SectionName)); + services.AddScoped(); // Dispatcher services.AddScoped(); diff --git a/tests/SIGCM2.Api.Tests/Audit/AuditActorMiddlewareTests.cs b/tests/SIGCM2.Api.Tests/Audit/AuditActorMiddlewareTests.cs new file mode 100644 index 0000000..d8732be --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Audit/AuditActorMiddlewareTests.cs @@ -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(); + } +} diff --git a/tests/SIGCM2.Api.Tests/Audit/CorrelationIdMiddlewareTests.cs b/tests/SIGCM2.Api.Tests/Audit/CorrelationIdMiddlewareTests.cs new file mode 100644 index 0000000..847eac4 --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Audit/CorrelationIdMiddlewareTests.cs @@ -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)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(); + } +} diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditContextTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditContextTests.cs new file mode 100644 index 0000000..d285783 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditContextTests.cs @@ -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(); + 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(); + 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(); + } +}