diff --git a/Directory.Packages.props b/Directory.Packages.props index a8143c6..c4ddb3e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,6 +10,7 @@ + diff --git a/TransactionProcessorACL.BusinessLogic/Common/AuditPipelineBehavior.cs b/TransactionProcessorACL.BusinessLogic/Common/AuditPipelineBehavior.cs new file mode 100644 index 0000000..87084bd --- /dev/null +++ b/TransactionProcessorACL.BusinessLogic/Common/AuditPipelineBehavior.cs @@ -0,0 +1,72 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Microsoft.AspNetCore.Http; + +namespace TransactionProcessorACL.Common; + +public sealed class AuditPipelineBehavior : IPipelineBehavior +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IRequestAuditRecorder _requestAuditRecorder; + + public AuditPipelineBehavior(IHttpContextAccessor httpContextAccessor, + IRequestAuditRecorder requestAuditRecorder) + { + _httpContextAccessor = httpContextAccessor; + _requestAuditRecorder = requestAuditRecorder; + } + + public async Task Handle(TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + HttpContext? httpContext = _httpContextAccessor.HttpContext; + if (httpContext is null) + { + return await next().ConfigureAwait(false); + } + + Boolean failed = false; + String? errorMessage = null; + + try + { + TResponse? response = await next(cancellationToken).ConfigureAwait(false); + return response; + } + catch (Exception ex) + { + failed = true; + errorMessage = ex.Message; + throw; + } + finally + { + await RecordAuditAsync(httpContext, request, failed, errorMessage, cancellationToken).ConfigureAwait(false); + } + } + + private async Task RecordAuditAsync(HttpContext httpContext, + TRequest request, + Boolean failed, + String? errorMessage, + CancellationToken cancellationToken) + { + RequestAuditEvent auditEvent = RequestAuditContextBuilder.Build( + httpContext, + request, + failed ? "Failed" : "Succeeded", + errorMessage); + + try + { + await _requestAuditRecorder.RecordAsync(auditEvent, cancellationToken).ConfigureAwait(false); + } + catch + { + // Audit must not interfere with the request path. + } + } +} diff --git a/TransactionProcessorACL.BusinessLogic/Common/IRequestAuditRecorder.cs b/TransactionProcessorACL.BusinessLogic/Common/IRequestAuditRecorder.cs new file mode 100644 index 0000000..dfea358 --- /dev/null +++ b/TransactionProcessorACL.BusinessLogic/Common/IRequestAuditRecorder.cs @@ -0,0 +1,9 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace TransactionProcessorACL.Common; + +public interface IRequestAuditRecorder +{ + Task RecordAsync(RequestAuditEvent auditEvent, CancellationToken cancellationToken); +} diff --git a/TransactionProcessorACL.BusinessLogic/Common/LoggingRequestAuditRecorder.cs b/TransactionProcessorACL.BusinessLogic/Common/LoggingRequestAuditRecorder.cs new file mode 100644 index 0000000..0522106 --- /dev/null +++ b/TransactionProcessorACL.BusinessLogic/Common/LoggingRequestAuditRecorder.cs @@ -0,0 +1,83 @@ +using KurrentDB.Client; +using Microsoft.AspNetCore.Http; +using Shared.Logger; +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace TransactionProcessorACL.Common; + +public sealed class LoggingRequestAuditRecorder : IRequestAuditRecorder +{ + public Task RecordAsync(RequestAuditEvent auditEvent, CancellationToken cancellationToken) + { + string payload = JsonSerializer.Serialize(auditEvent); + Logger.LogWarning($"AUDIT {payload}"); + return Task.CompletedTask; + } + + public class KurrentDbRequestAuditRecorder : IRequestAuditRecorder + { + private readonly KurrentDBClient KurrentDbClient; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly RequestAuditStreamOptions _streamOptions; + + public KurrentDbRequestAuditRecorder(KurrentDBClient KurrentDBClient, + IHttpContextAccessor httpContextAccessor, + RequestAuditStreamOptions streamOptions) { + this.KurrentDbClient = KurrentDBClient; + _httpContextAccessor = httpContextAccessor; + _streamOptions = streamOptions; + } + + public async Task RecordAsync(RequestAuditEvent auditEvent, CancellationToken cancellationToken) { + String requestId = GetRequestId(auditEvent); + String streamName = $"RequestAudit-{requestId}"; + + StreamMetadata streamMetadata = new( + maxCount: null, + maxAge: _streamOptions.RequestAuditStreamLifetime, + truncateBefore: null, + cacheControl: null, + acl: null, + customMetadata: null); + + await this.KurrentDbClient.SetStreamMetadataAsync( + streamName, + StreamState.Any, + streamMetadata, + cancellationToken: cancellationToken); + + List events = [CreateEventData(Guid.NewGuid(), "RequestAuditEvent", JsonSerializer.Serialize(auditEvent), "")]; + + await this.KurrentDbClient.AppendToStreamAsync(streamName, StreamState.Any, events, cancellationToken: cancellationToken); + } + + private String GetRequestId(RequestAuditEvent auditEvent) + { + HttpContext? httpContext = _httpContextAccessor.HttpContext; + if (httpContext is not null) + { + return RequestIdProvider.GetOrCreateRequestId(httpContext); + } + + return auditEvent.Context.RequestId; + } + + private static EventData CreateEventData(Guid eventId, + String eventType, + String data, + String metadata) + { + Byte[] eventData = Encoding.Default.GetBytes(data); + Byte[] eventMetadata = Encoding.Default.GetBytes(metadata); + + EventData @event = new(Uuid.FromGuid(eventId), eventType, eventData, eventMetadata); + + return @event; + } + } +} diff --git a/TransactionProcessorACL.BusinessLogic/Common/RequestAuditContext.cs b/TransactionProcessorACL.BusinessLogic/Common/RequestAuditContext.cs new file mode 100644 index 0000000..40d3b14 --- /dev/null +++ b/TransactionProcessorACL.BusinessLogic/Common/RequestAuditContext.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; + +namespace TransactionProcessorACL.Common; + +public sealed record RequestAuditContext( + string RequestId, + string TraceId, + DateTimeOffset TimestampUtc, + string Method, + string Route, + string SourceIp, + string UserAgent, + Guid? EstateId, + Guid? MerchantId, + string TransactionType, + string? TransactionNumber, + IReadOnlyDictionary BusinessContext); diff --git a/TransactionProcessorACL.BusinessLogic/Common/RequestAuditContextAccessor.cs b/TransactionProcessorACL.BusinessLogic/Common/RequestAuditContextAccessor.cs new file mode 100644 index 0000000..7014991 --- /dev/null +++ b/TransactionProcessorACL.BusinessLogic/Common/RequestAuditContextAccessor.cs @@ -0,0 +1,15 @@ +using System; +using System.Threading; + +namespace TransactionProcessorACL.Common; + +public sealed class RequestAuditContextAccessor +{ + private static readonly AsyncLocal CurrentContext = new(); + + public RequestAuditContext? Current + { + get => CurrentContext.Value; + set => CurrentContext.Value = value; + } +} diff --git a/TransactionProcessorACL.BusinessLogic/Common/RequestAuditContextBuilder.cs b/TransactionProcessorACL.BusinessLogic/Common/RequestAuditContextBuilder.cs new file mode 100644 index 0000000..c98c9f9 --- /dev/null +++ b/TransactionProcessorACL.BusinessLogic/Common/RequestAuditContextBuilder.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Reflection; +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using System.Text; +using TransactionProcessorACL.BusinessLogic.Requests; + +namespace TransactionProcessorACL.Common; + +public static class RequestAuditContextBuilder +{ + public static RequestAuditEvent Build(HttpContext httpContext, TRequest request, String outcome, String? errorMessage) + { + IReadOnlyDictionary businessContext = BuildBusinessContext(request); + (Guid? estateId, Guid? merchantId) = ExtractClaims(httpContext.User); + String requestType = request?.GetType().Name ?? typeof(TRequest).Name; + + RequestAuditContext context = new( + RequestId: RequestIdProvider.GetOrCreateRequestId(httpContext), + TraceId: httpContext.TraceIdentifier, + TimestampUtc: DateTimeOffset.UtcNow, + Method: httpContext.Request.Method, + Route: httpContext.Request.Path.Value ?? String.Empty, + SourceIp: httpContext.Connection.RemoteIpAddress?.ToString() ?? String.Empty, + UserAgent: httpContext.Request.Headers["User-Agent"].ToString(), + EstateId: estateId, + MerchantId: merchantId, + TransactionType: requestType, + TransactionNumber: TryGetTransactionNumber(request), + BusinessContext: businessContext); + + return new RequestAuditEvent(context, requestType, outcome, errorMessage, businessContext); + } + + private static (Guid? EstateId, Guid? MerchantId) ExtractClaims(ClaimsPrincipal principal) + { + return (TryGetGuidClaim(principal, "estateId"), TryGetGuidClaim(principal, "merchantId")); + } + + private static Guid? TryGetGuidClaim(ClaimsPrincipal principal, String claimType) + { + Claim? claim = principal.Claims.FirstOrDefault(c => String.Equals(c.Type, claimType, StringComparison.OrdinalIgnoreCase)); + return Guid.TryParse(claim?.Value, out Guid value) ? value : null; + } + + private static IReadOnlyDictionary BuildBusinessContext(TRequest request) + { + Dictionary businessContext = new(StringComparer.OrdinalIgnoreCase); + if (request is null) + { + return businessContext; + } + + foreach (PropertyInfo property in request.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + if (!property.CanRead || property.GetIndexParameters().Length > 0) + { + continue; + } + + if (LooksSensitive(property.Name)) + { + continue; + } + + object? value = property.GetValue(request); + if (value is null) + { + continue; + } + + if (TryAddDictionaryBusinessContext(businessContext, value)) + { + continue; + } + + if (TryFormatValue(value, out String formattedValue)) + { + businessContext[GetBusinessContextKey(property.Name, value)] = formattedValue; + } + } + + return businessContext; + } + + private static String GetBusinessContextKey(String propertyName, object value) + { + String key = ToSnakeCase(propertyName); + return value is DateTime or DateTimeOffset ? $"{key}_utc" : key; + } + + private static bool TryAddDictionaryBusinessContext(IDictionary businessContext, object value) + { + if (value is not IEnumerable> entries) + { + return false; + } + + foreach (KeyValuePair item in entries) + { + if (LooksSensitive(item.Key)) + { + continue; + } + + businessContext[item.Key] = item.Value; + } + + return true; + } + + private static String? TryGetTransactionNumber(TRequest request) + { + if (request is null) + { + return null; + } + + PropertyInfo? transactionNumberProperty = request.GetType().GetProperty( + "TransactionNumber", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.IgnoreCase); + + if (transactionNumberProperty is null || !transactionNumberProperty.CanRead) + { + return null; + } + + object? value = transactionNumberProperty.GetValue(request); + return value switch + { + null => null, + String stringValue => stringValue, + _ => value.ToString() + }; + } + + private static bool LooksSensitive(String key) + { + return key.Contains("password", StringComparison.OrdinalIgnoreCase) + || key.Contains("token", StringComparison.OrdinalIgnoreCase) + || key.Contains("secret", StringComparison.OrdinalIgnoreCase) + || key.Contains("authorization", StringComparison.OrdinalIgnoreCase); + } + + private static bool TryFormatValue(object value, out String formattedValue) + { + switch (value) + { + case String stringValue: + formattedValue = stringValue; + return true; + case Guid guidValue: + formattedValue = guidValue.ToString(); + return true; + case DateTime dateTimeValue: + formattedValue = dateTimeValue.ToUniversalTime().ToString("O"); + return true; + case DateTimeOffset dateTimeOffsetValue: + formattedValue = dateTimeOffsetValue.ToUniversalTime().ToString("O"); + return true; + case Enum enumValue: + formattedValue = enumValue.ToString(); + return true; + case Boolean booleanValue: + formattedValue = booleanValue.ToString(); + return true; + case IFormattable formattableValue: + formattedValue = formattableValue.ToString(null, CultureInfo.InvariantCulture) ?? String.Empty; + return true; + default: + formattedValue = String.Empty; + return false; + } + } + + private static String ToSnakeCase(String value) + { + if (String.IsNullOrWhiteSpace(value)) + { + return value; + } + + StringBuilder builder = new(); + for (Int32 index = 0; index < value.Length; index++) + { + Char character = value[index]; + if (Char.IsUpper(character) && builder.Length > 0) + { + builder.Append('_'); + } + + builder.Append(Char.ToLowerInvariant(character)); + } + + return builder.ToString(); + } +} diff --git a/TransactionProcessorACL.BusinessLogic/Common/RequestAuditEvent.cs b/TransactionProcessorACL.BusinessLogic/Common/RequestAuditEvent.cs new file mode 100644 index 0000000..2c5e8f4 --- /dev/null +++ b/TransactionProcessorACL.BusinessLogic/Common/RequestAuditEvent.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; + +namespace TransactionProcessorACL.Common; + +public sealed record RequestAuditEvent( + RequestAuditContext Context, + String RequestType, + String Outcome, + String? ErrorMessage, + IReadOnlyDictionary BusinessContext); diff --git a/TransactionProcessorACL.BusinessLogic/Common/RequestAuditStreamOptions.cs b/TransactionProcessorACL.BusinessLogic/Common/RequestAuditStreamOptions.cs new file mode 100644 index 0000000..a5559e8 --- /dev/null +++ b/TransactionProcessorACL.BusinessLogic/Common/RequestAuditStreamOptions.cs @@ -0,0 +1,10 @@ +using System; + +namespace TransactionProcessorACL.Common; + +public sealed record RequestAuditStreamOptions +{ + public Int32 RequestAuditStreamLifetimeDays { get; init; } = 7; + + public TimeSpan RequestAuditStreamLifetime => TimeSpan.FromDays(RequestAuditStreamLifetimeDays); +} diff --git a/TransactionProcessorACL.BusinessLogic/Common/RequestIdProvider.cs b/TransactionProcessorACL.BusinessLogic/Common/RequestIdProvider.cs new file mode 100644 index 0000000..89288e7 --- /dev/null +++ b/TransactionProcessorACL.BusinessLogic/Common/RequestIdProvider.cs @@ -0,0 +1,20 @@ +using System; +using Microsoft.AspNetCore.Http; + +namespace TransactionProcessorACL.Common; + +public static class RequestIdProvider +{ + private static readonly object RequestIdItemKey = new(); + + public static string GetOrCreateRequestId(HttpContext context) + { + if (context.Items.TryGetValue(RequestIdItemKey, out object? value) && value is string requestId && !string.IsNullOrWhiteSpace(requestId)) { + return requestId; + } + + string generatedRequestId = Guid.NewGuid().ToString("D"); + context.Items[RequestIdItemKey] = generatedRequestId; + return generatedRequestId; + } +} diff --git a/TransactionProcessorACL.BusinessLogic/RequestHandlers/ProcessLogonTransactionRequestHandler.cs b/TransactionProcessorACL.BusinessLogic/RequestHandlers/ProcessLogonTransactionRequestHandler.cs index 0c018da..a5337ed 100644 --- a/TransactionProcessorACL.BusinessLogic/RequestHandlers/ProcessLogonTransactionRequestHandler.cs +++ b/TransactionProcessorACL.BusinessLogic/RequestHandlers/ProcessLogonTransactionRequestHandler.cs @@ -1,4 +1,4 @@ -using SimpleResults; +using SimpleResults; namespace TransactionProcessorACL.BusinessLogic.RequestHandlers { @@ -76,4 +76,4 @@ public async Task> Handle(TransactionComm #endregion } -} \ No newline at end of file +} diff --git a/TransactionProcessorACL.BusinessLogic/Requests/TransactionCommands.cs b/TransactionProcessorACL.BusinessLogic/Requests/TransactionCommands.cs index 6ac5238..8b4ecff 100644 --- a/TransactionProcessorACL.BusinessLogic/Requests/TransactionCommands.cs +++ b/TransactionProcessorACL.BusinessLogic/Requests/TransactionCommands.cs @@ -36,4 +36,4 @@ public record ProcessReconciliationCommand(Guid EstateId, Int32 TransactionCount, Decimal TransactionValue) : IRequest>; -} \ No newline at end of file +} diff --git a/TransactionProcessorACL.BusinessLogic/TransactionProcessorACL.BusinessLogic.csproj b/TransactionProcessorACL.BusinessLogic/TransactionProcessorACL.BusinessLogic.csproj index fd5b0a9..372b7a8 100644 --- a/TransactionProcessorACL.BusinessLogic/TransactionProcessorACL.BusinessLogic.csproj +++ b/TransactionProcessorACL.BusinessLogic/TransactionProcessorACL.BusinessLogic.csproj @@ -6,12 +6,17 @@ + + + + + diff --git a/TransactionProcessorACL.Testing/TestData.cs b/TransactionProcessorACL.Testing/TestData.cs index 79fcf3b..1b8b855 100644 --- a/TransactionProcessorACL.Testing/TestData.cs +++ b/TransactionProcessorACL.Testing/TestData.cs @@ -7,6 +7,7 @@ namespace TransactionProcessorACL.Testing using Models; using TransactionProcessor.DataTransferObjects; using TransactionProcessorACL.BusinessLogic.Requests; + using TransactionProcessorACL.Common; using TransactionProcessorACL.DataTransferObjects; using GetVoucherResponse = TransactionProcessor.DataTransferObjects.GetVoucherResponse; using RedeemVoucherResponse = TransactionProcessor.DataTransferObjects.RedeemVoucherResponse; @@ -53,6 +54,20 @@ public class TestData /// public static Guid MerchantId = Guid.Parse("1C8354B7-B97A-46EA-9AD1-C43F33F7E3C3"); + public static RequestAuditContext RequestAuditContext = new RequestAuditContext( + RequestId: "req-123", + TraceId: "trace-123", + TimestampUtc: DateTimeOffset.UtcNow, + Method: "POST", + Route: "/api/saletransactions", + SourceIp: "203.0.113.9", + UserAgent: "TestClient/1.0", + EstateId: EstateId, + MerchantId: MerchantId, + TransactionType: "SALE", + TransactionNumber: TransactionNumber, + BusinessContext: new Dictionary()); + public static Int32 ScheduleYear = 2026; /// @@ -178,7 +193,7 @@ public class TestData ["AppSettings:ClientSecret"] = "secret", ["SecurityConfiguration:Authority"] = "https://127.0.0.1", ["SecurityConfiguration:ApiName"] = "ApiName", - ["EventStoreSettings:ConnectionString"] = "https://127.0.0.1:2113" + ["EventStoreSettings:ConnectionString"] = "esdb://127.0.0.1:2113" }; public static IReadOnlyDictionary DefaultAppSettingsSkipVersionCheck => @@ -362,4 +377,4 @@ public static TransactionProcessor.DataTransferObjects.Responses.Merchant.Mercha return new TransactionProcessor.DataTransferObjects.Responses.Merchant.MerchantScheduleResponse { Year = ScheduleYear, Months = new List() { new TransactionProcessor.DataTransferObjects.Responses.Merchant.MerchantScheduleMonthResponse { ClosedDays = new List { 1, 2 }, Month = 1 }, new TransactionProcessor.DataTransferObjects.Responses.Merchant.MerchantScheduleMonthResponse { ClosedDays = new List { 25, 26 }, Month = 12 } } }; } } -} \ No newline at end of file +} diff --git a/TransactionProcessorACL.Tests/Audit/AuditPipelineBehaviorTests.cs b/TransactionProcessorACL.Tests/Audit/AuditPipelineBehaviorTests.cs new file mode 100644 index 0000000..81f7971 --- /dev/null +++ b/TransactionProcessorACL.Tests/Audit/AuditPipelineBehaviorTests.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Microsoft.AspNetCore.Http; +using Shouldly; +using SimpleResults; +using TransactionProcessorACL.BusinessLogic.Requests; +using TransactionProcessorACL.Common; +using TransactionProcessorACL.Models; +using Xunit; + +namespace TransactionProcessorACL.Tests.Audit; + +public class AuditPipelineBehaviorTests +{ + [Fact] + public async Task Handle_WhenPipelineRuns_RecordsSingleAuditEventWithBusinessContext() + { + var httpContext = new DefaultHttpContext(); + httpContext.TraceIdentifier = "trace-123"; + httpContext.Request.Method = HttpMethods.Post; + httpContext.Request.Path = "/api/saletransactions"; + httpContext.Request.Headers["User-Agent"] = "TestClient/1.0"; + httpContext.Connection.RemoteIpAddress = IPAddress.Parse("203.0.113.9"); + httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("estateId", "1C8354B7-B97A-46EA-9AD1-C43F33F7E3C3"), + new Claim("merchantId", "2C8354B7-B97A-46EA-9AD1-C43F33F7E3C4"), + }, "Bearer")); + + var accessor = new HttpContextAccessor { HttpContext = httpContext }; + var recorder = new CapturingAuditRecorder(); + var behavior = new AuditPipelineBehavior>(accessor, recorder); + var request = new TransactionCommands.ProcessSaleTransactionCommand( + Guid.Parse("1C8354B7-B97A-46EA-9AD1-C43F33F7E3C3"), + Guid.Parse("2C8354B7-B97A-46EA-9AD1-C43F33F7E3C4"), + new System.DateTime(2026, 6, 30, 10, 15, 0), + "TX-0001", + "device-01", + Guid.Parse("5C8354B7-B97A-46EA-9AD1-C43F33F7E3C7"), + "customer@example.com", + Guid.Parse("3C8354B7-B97A-46EA-9AD1-C43F33F7E3C5"), + Guid.Parse("4C8354B7-B97A-46EA-9AD1-C43F33F7E3C6"), + new Dictionary { ["amount"] = "1000.00" }); + + Result response = await behavior.Handle(request, cancellationToken => Task.FromResult(Result.Success(new ProcessSaleTransactionResponse())), CancellationToken.None); + + response.IsSuccess.ShouldBeTrue(); + recorder.CallCount.ShouldBe(1); + recorder.LastEvent.ShouldNotBeNull(); + Guid.TryParse(recorder.LastEvent!.Context.RequestId, out _).ShouldBeTrue(); + recorder.LastEvent.Context.EstateId.ShouldBe(Guid.Parse("1C8354B7-B97A-46EA-9AD1-C43F33F7E3C3")); + recorder.LastEvent.Context.MerchantId.ShouldBe(Guid.Parse("2C8354B7-B97A-46EA-9AD1-C43F33F7E3C4")); + recorder.LastEvent.RequestType.ShouldBe(nameof(TransactionCommands.ProcessSaleTransactionCommand)); + recorder.LastEvent.Context.BusinessContext["amount"].ShouldBe("1000.00"); + } + + [Fact] + public async Task Handle_WhenVersionCheckRuns_WithClaims_RecordsEstateAndMerchantIds() + { + var httpContext = new DefaultHttpContext(); + httpContext.TraceIdentifier = "trace-456"; + httpContext.Request.Method = HttpMethods.Post; + httpContext.Request.Path = "/api/versioncheck"; + httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("estateId", "1C8354B7-B97A-46EA-9AD1-C43F33F7E3C3"), + new Claim("merchantId", "2C8354B7-B97A-46EA-9AD1-C43F33F7E3C4"), + }, "Bearer")); + + var accessor = new HttpContextAccessor { HttpContext = httpContext }; + var recorder = new CapturingAuditRecorder(); + var behavior = new AuditPipelineBehavior(accessor, recorder); + var request = new VersionCheckCommands.VersionCheckCommand("9.9.9"); + + Result response = await behavior.Handle(request, cancellationToken => Task.FromResult(Result.Success()), CancellationToken.None); + + response.IsSuccess.ShouldBeTrue(); + recorder.CallCount.ShouldBe(1); + recorder.LastEvent.ShouldNotBeNull(); + recorder.LastEvent!.Context.EstateId.ShouldBe(Guid.Parse("1C8354B7-B97A-46EA-9AD1-C43F33F7E3C3")); + recorder.LastEvent.Context.MerchantId.ShouldBe(Guid.Parse("2C8354B7-B97A-46EA-9AD1-C43F33F7E3C4")); + recorder.LastEvent.RequestType.ShouldBe(nameof(VersionCheckCommands.VersionCheckCommand)); + recorder.LastEvent.Context.BusinessContext["version_number"].ShouldBe("9.9.9"); + } + + private sealed class CapturingAuditRecorder : IRequestAuditRecorder + { + public int CallCount { get; private set; } + + public RequestAuditEvent? LastEvent { get; private set; } + + public Task RecordAsync(RequestAuditEvent auditEvent, CancellationToken cancellationToken) + { + CallCount++; + LastEvent = auditEvent; + return Task.CompletedTask; + } + } +} diff --git a/TransactionProcessorACL.Tests/Audit/CorrelationHeaderDelegatingHandlerTests.cs b/TransactionProcessorACL.Tests/Audit/CorrelationHeaderDelegatingHandlerTests.cs new file mode 100644 index 0000000..4d43653 --- /dev/null +++ b/TransactionProcessorACL.Tests/Audit/CorrelationHeaderDelegatingHandlerTests.cs @@ -0,0 +1,67 @@ +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Shouldly; +using TransactionProcessorACL.Common; +using Xunit; + +namespace TransactionProcessorACL.Tests.Audit; + +public class CorrelationHeaderDelegatingHandlerTests +{ + [Fact] + public async Task SendAsync_WhenRequestContextExists_AddsRequestIdHeader() + { + var accessor = new RequestAuditContextAccessor(); + accessor.Current = new RequestAuditContext( + RequestId: "req-123", + TraceId: "trace-123", + TimestampUtc: System.DateTimeOffset.UtcNow, + Method: "POST", + Route: "/api/saletransactions", + SourceIp: "203.0.113.9", + UserAgent: "TestClient/1.0", + EstateId: null, + MerchantId: null, + TransactionType: "SALE", + TransactionNumber: "TX-0001", + BusinessContext: new System.Collections.Generic.Dictionary()); + + HttpRequestMessage? capturedRequest = null; + var innerHandler = new CapturingHandler(request => + { + capturedRequest = request; + return new HttpResponseMessage(HttpStatusCode.OK); + }); + + var handler = new CorrelationHeaderDelegatingHandler(accessor) + { + InnerHandler = innerHandler + }; + + using var invoker = new HttpMessageInvoker(handler); + + HttpResponseMessage response = await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://example.test"), CancellationToken.None); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + capturedRequest.ShouldNotBeNull(); + capturedRequest!.Headers.Contains("X-Request-Id").ShouldBeTrue(); + capturedRequest.Headers.GetValues("X-Request-Id").ShouldContain("req-123"); + } + + private sealed class CapturingHandler : HttpMessageHandler + { + private readonly System.Func _callback; + + public CapturingHandler(System.Func callback) + { + _callback = callback; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(_callback(request)); + } + } +} diff --git a/TransactionProcessorACL.Tests/Audit/RequestAuditContextFactoryTests.cs b/TransactionProcessorACL.Tests/Audit/RequestAuditContextFactoryTests.cs new file mode 100644 index 0000000..ac35a2f --- /dev/null +++ b/TransactionProcessorACL.Tests/Audit/RequestAuditContextFactoryTests.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Shouldly; +using TransactionProcessorACL.DataTransferObjects; +using TransactionProcessorACL.Common; +using Xunit; + +namespace TransactionProcessorACL.Tests.Audit; + +public class RequestAuditContextFactoryTests +{ + [Fact] + public void CreateForTransaction_WhenRequestHasCorrelationHeader_CapturesRequestAndBusinessContext() + { + var context = new DefaultHttpContext(); + context.TraceIdentifier = "trace-123"; + context.Request.Method = HttpMethods.Post; + context.Request.Path = "/api/saletransactions"; + context.Request.Headers["User-Agent"] = "TestClient/1.0"; + context.Connection.RemoteIpAddress = IPAddress.Parse("203.0.113.9"); + + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("estateId", "1C8354B7-B97A-46EA-9AD1-C43F33F7E3C3"), + new Claim("merchantId", "2C8354B7-B97A-46EA-9AD1-C43F33F7E3C4"), + })); + + var request = new SaleTransactionRequestMessage + { + ApplicationVersion = "9.9.9", + TransactionNumber = "TX-0001", + DeviceIdentifier = "device-01", + TransactionDateTime = new System.DateTime(2026, 6, 30, 10, 15, 0), + CustomerEmailAddress = "customer@example.com", + ContractId = System.Guid.Parse("3C8354B7-B97A-46EA-9AD1-C43F33F7E3C5"), + ProductId = System.Guid.Parse("4C8354B7-B97A-46EA-9AD1-C43F33F7E3C6"), + OperatorId = System.Guid.Parse("5C8354B7-B97A-46EA-9AD1-C43F33F7E3C7"), + AdditionalRequestMetadata = new Dictionary + { + ["amount"] = "1000.00" + } + }; + + RequestAuditContext auditContext = RequestAuditContextFactory.CreateForTransaction(context, user, request, "SALE"); + RequestAuditContext secondAuditContext = RequestAuditContextFactory.CreateForRequest(context); + + Guid.TryParse(auditContext.RequestId, out _).ShouldBeTrue(); + secondAuditContext.RequestId.ShouldBe(auditContext.RequestId); + auditContext.TraceId.ShouldBe("trace-123"); + auditContext.Method.ShouldBe("POST"); + auditContext.Route.ShouldBe("/api/saletransactions"); + auditContext.SourceIp.ShouldBe("203.0.113.9"); + auditContext.UserAgent.ShouldBe("TestClient/1.0"); + auditContext.EstateId.ShouldBe(System.Guid.Parse("1C8354B7-B97A-46EA-9AD1-C43F33F7E3C3")); + auditContext.MerchantId.ShouldBe(System.Guid.Parse("2C8354B7-B97A-46EA-9AD1-C43F33F7E3C4")); + auditContext.TransactionType.ShouldBe("SALE"); + auditContext.TransactionNumber.ShouldBe("TX-0001"); + auditContext.BusinessContext["application_version"].ShouldBe("9.9.9"); + auditContext.BusinessContext["customer_email_address"].ShouldBe("customer@example.com"); + auditContext.BusinessContext["contract_id"].ToUpperInvariant().ShouldBe("3C8354B7-B97A-46EA-9AD1-C43F33F7E3C5"); + auditContext.BusinessContext["product_id"].ToUpperInvariant().ShouldBe("4C8354B7-B97A-46EA-9AD1-C43F33F7E3C6"); + auditContext.BusinessContext["operator_id"].ToUpperInvariant().ShouldBe("5C8354B7-B97A-46EA-9AD1-C43F33F7E3C7"); + auditContext.BusinessContext["amount"].ShouldBe("1000.00"); + } +} diff --git a/TransactionProcessorACL.Tests/Audit/RequestAuditStreamOptionsTests.cs b/TransactionProcessorACL.Tests/Audit/RequestAuditStreamOptionsTests.cs new file mode 100644 index 0000000..fba284e --- /dev/null +++ b/TransactionProcessorACL.Tests/Audit/RequestAuditStreamOptionsTests.cs @@ -0,0 +1,30 @@ +using System; +using Shouldly; +using TransactionProcessorACL.Common; +using Xunit; + +namespace TransactionProcessorACL.Tests.Audit; + +public class RequestAuditStreamOptionsTests +{ + [Fact] + public void DefaultLifetime_IsSevenDays() + { + var options = new RequestAuditStreamOptions(); + + options.RequestAuditStreamLifetimeDays.ShouldBe(7); + options.RequestAuditStreamLifetime.ShouldBe(TimeSpan.FromDays(7)); + } + + [Fact] + public void CustomLifetime_ConvertsToTimeSpan() + { + var options = new RequestAuditStreamOptions + { + RequestAuditStreamLifetimeDays = 14 + }; + + options.RequestAuditStreamLifetimeDays.ShouldBe(14); + options.RequestAuditStreamLifetime.ShouldBe(TimeSpan.FromDays(14)); + } +} diff --git a/TransactionProcessorACL.Tests/Handlers/TransactionHandlersTests.cs b/TransactionProcessorACL.Tests/Handlers/TransactionHandlersTests.cs new file mode 100644 index 0000000..d3c5895 --- /dev/null +++ b/TransactionProcessorACL.Tests/Handlers/TransactionHandlersTests.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Moq; +using Shouldly; +using SimpleResults; +using TransactionProcessorACL.BusinessLogic.Requests; +using TransactionProcessorACL.DataTransferObjects; +using TransactionProcessorACL.Handlers; +using Xunit; + +namespace TransactionProcessorACL.Tests.Handlers; + +public class TransactionHandlersTests +{ + [Fact] + public async Task PerformSaleTransaction_PassesBusinessFieldsIntoCommand() + { + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("estateId", "1C8354B7-B97A-46EA-9AD1-C43F33F7E3C3"), + new Claim("merchantId", "2C8354B7-B97A-46EA-9AD1-C43F33F7E3C4"), + }, "Bearer")); + + var request = new SaleTransactionRequestMessage + { + TransactionNumber = "TX-0001", + DeviceIdentifier = "device-01", + TransactionDateTime = new System.DateTime(2026, 6, 30, 10, 15, 0), + CustomerEmailAddress = "customer@example.com", + ContractId = System.Guid.Parse("3C8354B7-B97A-46EA-9AD1-C43F33F7E3C5"), + ProductId = System.Guid.Parse("4C8354B7-B97A-46EA-9AD1-C43F33F7E3C6"), + OperatorId = System.Guid.Parse("5C8354B7-B97A-46EA-9AD1-C43F33F7E3C7"), + AdditionalRequestMetadata = new Dictionary + { + ["amount"] = "1000.00" + } + }; + + TransactionCommands.ProcessSaleTransactionCommand? capturedCommand = null; + + var mediator = new Mock(MockBehavior.Strict); + mediator + .Setup(m => m.Send(It.IsAny(), It.IsAny())) + .Callback((command, _) => capturedCommand = (TransactionCommands.ProcessSaleTransactionCommand)command) + .ReturnsAsync(Result.Success(new TransactionProcessorACL.Models.ProcessSaleTransactionResponse())); + + await TransactionHandlers.PerformSaleTransaction(mediator.Object, user, request, CancellationToken.None); + + capturedCommand.ShouldNotBeNull(); + capturedCommand!.EstateId.ShouldBe(System.Guid.Parse("1C8354B7-B97A-46EA-9AD1-C43F33F7E3C3")); + capturedCommand.MerchantId.ShouldBe(System.Guid.Parse("2C8354B7-B97A-46EA-9AD1-C43F33F7E3C4")); + capturedCommand.TransactionNumber.ShouldBe("TX-0001"); + capturedCommand.DeviceIdentifier.ShouldBe("device-01"); + capturedCommand.CustomerEmailAddress.ShouldBe("customer@example.com"); + capturedCommand.AdditionalRequestMetadata["amount"].ShouldBe("1000.00"); + mediator.Verify(m => m.Send(It.IsAny(), It.IsAny()), Times.Once); + } +} diff --git a/TransactionProcessorACL/Bootstrapper/ClientRegistry.cs b/TransactionProcessorACL/Bootstrapper/ClientRegistry.cs index 897669b..2dea17b 100644 --- a/TransactionProcessorACL/Bootstrapper/ClientRegistry.cs +++ b/TransactionProcessorACL/Bootstrapper/ClientRegistry.cs @@ -1,4 +1,4 @@ -namespace TransactionProcessorACL.Bootstrapper +namespace TransactionProcessorACL.Bootstrapper { using ClientProxyBase; using Lamar; @@ -8,7 +8,7 @@ using Shared.Serialisation; using System; using System.Diagnostics.CodeAnalysis; - using System.Net.Http; + using TransactionProcessorACL.Common; using TransactionProcessor.Client; /// @@ -23,10 +23,15 @@ public class ClientRegistry : ServiceRegistry /// /// Initializes a new instance of the class. /// - public ClientRegistry() { + public ClientRegistry() + { this.AddHttpContextAccessor(); - this.RegisterHttpClient(); - this.RegisterHttpClient(); + this.AddSingleton(); + this.AddTransient(); + this.RegisterHttpClient() + .AddHttpMessageHandler(); + this.RegisterHttpClient() + .AddHttpMessageHandler(); Func resolver(IServiceProvider container) => serviceName => ConfigurationReader.GetBaseServerUri(serviceName).OriginalString; this.AddSingleton>(resolver); } @@ -48,4 +53,4 @@ public SerialiserRegistry() this.AddSingleton(serialiserSettings); } } -} \ No newline at end of file +} diff --git a/TransactionProcessorACL/Bootstrapper/MediatorRegistry.cs b/TransactionProcessorACL/Bootstrapper/MediatorRegistry.cs index 2ae2a68..943cb59 100644 --- a/TransactionProcessorACL/Bootstrapper/MediatorRegistry.cs +++ b/TransactionProcessorACL/Bootstrapper/MediatorRegistry.cs @@ -1,5 +1,6 @@ -using System.Collections.Generic; +using Microsoft.Extensions.Configuration; using SimpleResults; +using System.Collections.Generic; namespace TransactionProcessorACL.Bootstrapper { @@ -10,6 +11,7 @@ namespace TransactionProcessorACL.Bootstrapper using Microsoft.Extensions.DependencyInjection; using Models; using System.Diagnostics.CodeAnalysis; + using TransactionProcessorACL.Common; /// /// @@ -25,9 +27,19 @@ public class MediatorRegistry : ServiceRegistry /// public MediatorRegistry() { + string eventStoreConnectionString = Startup.Configuration.GetValue("EventStoreSettings:ConnectionString"); + int requestAuditStreamLifetimeDays = Startup.Configuration.GetValue("AuditSettings:RequestAuditStreamLifetimeDays") ?? 7; + this.AddKurrentDBClient(eventStoreConnectionString); + + this.AddSingleton(new RequestAuditStreamOptions + { + RequestAuditStreamLifetimeDays = requestAuditStreamLifetimeDays + }); + this.AddSingleton(); + this.AddTransient(typeof(IPipelineBehavior<,>), typeof(AuditPipelineBehavior<,>)); this.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(VersionCheckRequestHandler).Assembly)); } #endregion } -} \ No newline at end of file +} diff --git a/TransactionProcessorACL/Common/CorrelationHeaderDelegatingHandler.cs b/TransactionProcessorACL/Common/CorrelationHeaderDelegatingHandler.cs new file mode 100644 index 0000000..76e8e38 --- /dev/null +++ b/TransactionProcessorACL/Common/CorrelationHeaderDelegatingHandler.cs @@ -0,0 +1,26 @@ +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace TransactionProcessorACL.Common; + +public sealed class CorrelationHeaderDelegatingHandler : DelegatingHandler +{ + private readonly RequestAuditContextAccessor _accessor; + + public CorrelationHeaderDelegatingHandler(RequestAuditContextAccessor accessor) + { + _accessor = accessor; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestAuditContext? context = _accessor.Current; + if (context is not null && !request.Headers.Contains("X-Request-Id")) + { + request.Headers.TryAddWithoutValidation("X-Request-Id", context.RequestId); + } + + return base.SendAsync(request, cancellationToken); + } +} diff --git a/TransactionProcessorACL/Common/RequestAuditContextFactory.cs b/TransactionProcessorACL/Common/RequestAuditContextFactory.cs new file mode 100644 index 0000000..02326ea --- /dev/null +++ b/TransactionProcessorACL/Common/RequestAuditContextFactory.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Reflection; +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using TransactionProcessorACL.DataTransferObjects; +using System.Text; + +namespace TransactionProcessorACL.Common; + +public static class RequestAuditContextFactory +{ + public static RequestAuditContext CreateForTransaction(HttpContext context, + ClaimsPrincipal user, + TransactionRequestMessage request, + string transactionType) + { + var requestId = RequestIdProvider.GetOrCreateRequestId(context); + var businessContext = BuildBusinessContext(request); + var claims = ExtractClaims(user); + + return new RequestAuditContext( + RequestId: requestId, + TraceId: context.TraceIdentifier, + TimestampUtc: DateTimeOffset.UtcNow, + Method: context.Request.Method, + Route: context.Request.Path.Value ?? string.Empty, + SourceIp: context.Connection.RemoteIpAddress?.ToString() ?? string.Empty, + UserAgent: context.Request.Headers["User-Agent"].ToString(), + EstateId: claims.EstateId, + MerchantId: claims.MerchantId, + TransactionType: transactionType, + TransactionNumber: request.TransactionNumber, + BusinessContext: businessContext); + } + + public static RequestAuditContext CreateForRequest(HttpContext context) + { + return new RequestAuditContext( + RequestId: RequestIdProvider.GetOrCreateRequestId(context), + TraceId: context.TraceIdentifier, + TimestampUtc: DateTimeOffset.UtcNow, + Method: context.Request.Method, + Route: context.Request.Path.Value ?? string.Empty, + SourceIp: context.Connection.RemoteIpAddress?.ToString() ?? string.Empty, + UserAgent: context.Request.Headers["User-Agent"].ToString(), + EstateId: null, + MerchantId: null, + TransactionType: string.Empty, + TransactionNumber: null, + BusinessContext: new Dictionary()); + } + + private static (Guid? EstateId, Guid? MerchantId) ExtractClaims(ClaimsPrincipal user) + { + Guid? estateId = TryGetGuidClaim(user, "estateId"); + Guid? merchantId = TryGetGuidClaim(user, "merchantId"); + return (estateId, merchantId); + } + + private static Guid? TryGetGuidClaim(ClaimsPrincipal user, string claimType) + { + Claim? claim = user.Claims.FirstOrDefault(c => c.Type == claimType); + return Guid.TryParse(claim?.Value, out Guid value) ? value : null; + } + + private static IReadOnlyDictionary BuildBusinessContext(TransactionRequestMessage request) + { + var businessContext = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (PropertyInfo property in request.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + if (!property.CanRead || property.GetIndexParameters().Length > 0) + { + continue; + } + + if (LooksSensitive(property.Name)) + { + continue; + } + + object? value = property.GetValue(request); + if (value is null) + { + if (property.PropertyType == typeof(string)) + { + businessContext[ToSnakeCase(property.Name)] = string.Empty; + } + + continue; + } + + if (TryAddDictionaryBusinessContext(businessContext, value)) + { + continue; + } + + if (TryFormatValue(value, out string formattedValue)) + { + businessContext[GetBusinessContextKey(property.Name, value)] = formattedValue; + } + } + + return businessContext; + } + + private static string GetBusinessContextKey(string propertyName, object value) + { + return value is DateTime or DateTimeOffset + ? $"{ToSnakeCase(propertyName)}_utc" + : ToSnakeCase(propertyName); + } + + private static bool TryAddDictionaryBusinessContext(IDictionary businessContext, object value) + { + if (value is not IEnumerable> entries) + { + return false; + } + + foreach (KeyValuePair item in entries) + { + if (LooksSensitive(item.Key)) + { + continue; + } + + businessContext[item.Key] = item.Value; + } + + return true; + } + + private static bool LooksSensitive(string key) + { + return key.Contains("password", StringComparison.OrdinalIgnoreCase) + || key.Contains("token", StringComparison.OrdinalIgnoreCase) + || key.Contains("secret", StringComparison.OrdinalIgnoreCase) + || key.Contains("authorization", StringComparison.OrdinalIgnoreCase); + } + + private static bool TryFormatValue(object value, out string formattedValue) + { + switch (value) + { + case string stringValue: + formattedValue = stringValue; + return true; + case Guid guidValue: + formattedValue = guidValue.ToString(); + return true; + case DateTime dateTimeValue: + formattedValue = dateTimeValue.ToUniversalTime().ToString("O"); + return true; + case DateTimeOffset dateTimeOffsetValue: + formattedValue = dateTimeOffsetValue.ToUniversalTime().ToString("O"); + return true; + case Enum enumValue: + formattedValue = enumValue.ToString(); + return true; + case bool boolValue: + formattedValue = boolValue.ToString(); + return true; + case IFormattable formattableValue: + formattedValue = formattableValue.ToString(null, CultureInfo.InvariantCulture) ?? string.Empty; + return true; + default: + formattedValue = string.Empty; + return false; + } + } + + private static string ToSnakeCase(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return value; + } + + StringBuilder builder = new(); + + for (int index = 0; index < value.Length; index++) + { + char character = value[index]; + if (char.IsUpper(character) && builder.Length > 0) + { + builder.Append('_'); + } + + builder.Append(char.ToLowerInvariant(character)); + } + + return builder.ToString(); + } +} diff --git a/TransactionProcessorACL/Common/RequestAuditMiddleware.cs b/TransactionProcessorACL/Common/RequestAuditMiddleware.cs new file mode 100644 index 0000000..c77a573 --- /dev/null +++ b/TransactionProcessorACL/Common/RequestAuditMiddleware.cs @@ -0,0 +1,33 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using NLog; + +namespace TransactionProcessorACL.Common; + +public sealed class RequestAuditMiddleware +{ + private readonly RequestDelegate _next; + private readonly RequestAuditContextAccessor _accessor; + + public RequestAuditMiddleware(RequestDelegate next, RequestAuditContextAccessor accessor) + { + _next = next; + _accessor = accessor; + } + + public async Task InvokeAsync(HttpContext context) + { + RequestAuditContext requestAuditContext = RequestAuditContextFactory.CreateForRequest(context); + _accessor.Current = requestAuditContext; + MappedDiagnosticsLogicalContext.Set("CorrelationId", requestAuditContext.RequestId); + try + { + await _next(context).ConfigureAwait(false); + } + finally + { + _accessor.Current = null; + MappedDiagnosticsLogicalContext.Remove("CorrelationId"); + } + } +} diff --git a/TransactionProcessorACL/Handlers/TransactionHandlers.cs b/TransactionProcessorACL/Handlers/TransactionHandlers.cs index fa07882..dcdee17 100644 --- a/TransactionProcessorACL/Handlers/TransactionHandlers.cs +++ b/TransactionProcessorACL/Handlers/TransactionHandlers.cs @@ -8,7 +8,6 @@ using System.Security.Claims; using System.Threading; using System.Threading.Tasks; -using Shared.Logger; using TransactionProcessorACL.BusinessLogic.Requests; using TransactionProcessorACL.DataTransferObjects; using TransactionProcessorACL.Factories; @@ -27,16 +26,12 @@ public static async Task PerformSaleTransaction(IMediator mediator, SaleTransactionRequestMessage transactionRequest, CancellationToken cancellationToken) { - Logger.LogWarning($"Performing Sale Transaction for TransactionNumber: {transactionRequest.TransactionNumber}, DeviceIdentifier: {transactionRequest.DeviceIdentifier}, TransactionDateTime: {transactionRequest.TransactionDateTime}"); - Result<(Guid estateId, Guid merchantId)> claimsResult = Helpers.GetRequiredClaims(user); if (claimsResult.IsFailed) return ResponseFactory.FromResult(Result.Forbidden()); - Logger.LogWarning("Here 1"); + TransactionCommands.ProcessSaleTransactionCommand saleCommand = CreateSaleCommand(claimsResult.Data.estateId, claimsResult.Data.merchantId, transactionRequest); - Logger.LogWarning("Here 2"); Result saleResponse = await mediator.Send(saleCommand, cancellationToken); - Logger.LogWarning("Here 3"); return ResponseFactory.FromResult(saleResponse, ModelFactory.ConvertFrom); } @@ -66,7 +61,6 @@ public static async Task PerformReconciliationTransaction(IMediator med TransactionCommands.ProcessReconciliationCommand reconCommand = CreateReconciliationCommand(claimsResult.Data.estateId, claimsResult.Data.merchantId, transactionRequest); Result reconResponse = await mediator.Send(reconCommand, cancellationToken); return ResponseFactory.FromResult(reconResponse, ModelFactory.ConvertFrom); - } private static TransactionCommands.ProcessLogonTransactionCommand CreateLogonCommand(Guid estateId, Guid merchantId, LogonTransactionRequestMessage msg) @@ -101,20 +95,18 @@ private static TransactionCommands.ProcessReconciliationCommand CreateReconcilia } } -public static class Helpers { +public static class Helpers +{ public static Result<(Guid estateId, Guid merchantId)> GetRequiredClaims(ClaimsPrincipal user) { - Result estateIdResult = ClaimsHelper.GetUserClaim(user, "estateId"); - if (estateIdResult.IsFailed) + Claim? estateClaim = user.Claims.FirstOrDefault(c => string.Equals(c.Type, "estateId", StringComparison.OrdinalIgnoreCase)); + if (!Guid.TryParse(estateClaim?.Value, out Guid estateId)) return Result.Failure("No Claim found for Estate Id"); - Result merchantIdResult = ClaimsHelper.GetUserClaim(user, "merchantId"); - if (merchantIdResult.IsFailed) + Claim? merchantClaim = user.Claims.FirstOrDefault(c => string.Equals(c.Type, "merchantId", StringComparison.OrdinalIgnoreCase)); + if (!Guid.TryParse(merchantClaim?.Value, out Guid merchantId)) return Result.Failure("No Claim found for Merchant Id"); - // Ok we have the required claims - Guid estateId = Guid.Parse(estateIdResult.Data.Value); - Guid merchantId = Guid.Parse(merchantIdResult.Data.Value); return Result.Success((estateId, merchantId)); } -} \ No newline at end of file +} diff --git a/TransactionProcessorACL/Startup.cs b/TransactionProcessorACL/Startup.cs index 2aa0792..93f5c75 100644 --- a/TransactionProcessorACL/Startup.cs +++ b/TransactionProcessorACL/Startup.cs @@ -29,6 +29,7 @@ namespace TransactionProcessorACL using System.Text; using System.Text.Json; using System.Threading.Tasks; + using TransactionProcessorACL.Common; using TransactionProcessorACL.Middleware; using ILogger = Microsoft.Extensions.Logging.ILogger; @@ -95,10 +96,11 @@ private static void ConfigureMiddleware(IApplicationBuilder app) app.UseMiddleware(); app.AddRequestResponseLogging(); app.AddExceptionHandler(); - app.UseMiddleware(); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); + app.UseMiddleware(); + app.UseMiddleware(); } private static void ConfigureEndpoints(IApplicationBuilder app) diff --git a/TransactionProcessorACL/TransactionProcessorACL.csproj b/TransactionProcessorACL/TransactionProcessorACL.csproj index e38138a..44e078c 100644 --- a/TransactionProcessorACL/TransactionProcessorACL.csproj +++ b/TransactionProcessorACL/TransactionProcessorACL.csproj @@ -11,6 +11,7 @@ + diff --git a/TransactionProcessorACL/appsettings.json b/TransactionProcessorACL/appsettings.json index fa1c63b..32d6052 100644 --- a/TransactionProcessorACL/appsettings.json +++ b/TransactionProcessorACL/appsettings.json @@ -4,6 +4,9 @@ "ClientSecret": "d192cbc46d834d0da90e8a9d50ded543", "MinimumSupportedApplicationVersion": "1.0.5" }, + "AuditSettings": { + "RequestAuditStreamLifetimeDays": 7 + }, "SecurityConfiguration": { "ApiName": "transactionProcessorACL" }