diff --git a/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactorAdapter.cs b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactorAdapter.cs new file mode 100644 index 0000000..a6cee23 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactorAdapter.cs @@ -0,0 +1,64 @@ +using ZB.MOM.WW.Telemetry.Serilog; + +namespace ZB.MOM.WW.MxGateway.Server.Diagnostics; + +/// +/// Bridges the gateway's policy onto the shared +/// seam consumed by ZB.MOM.WW.Telemetry.Serilog's redaction +/// enricher. Applied to every Serilog log event before it reaches a sink, it masks the same +/// secrets the original MEL-scope path masked: API-key bearer tokens / client identities +/// (mxgw_*) and command values for credential-bearing MXAccess commands. All masking +/// decisions delegate to — this type adds no new policy. +/// +public sealed class GatewayLogRedactorAdapter : ILogRedactor +{ + /// Property name carrying a client identity / authorization header value. + private const string ClientIdentityProperty = "ClientIdentity"; + + /// Property name carrying a raw authorization header value. + private const string AuthorizationProperty = "Authorization"; + + /// Property name carrying the MXAccess command method, used to gate value redaction. + private const string CommandMethodProperty = "CommandMethod"; + + /// Property name carrying a command payload value that may bear credentials. + private const string CommandValueProperty = "CommandValue"; + + /// + /// Masks any sensitive values in in place using the shared + /// policy. Identity/authorization properties have their API-key + /// secret stripped; a command value is redacted when its associated command method bears + /// credentials. + /// + /// The mutable log-event property dictionary for the current event. + public void Redact(IDictionary properties) + { + ArgumentNullException.ThrowIfNull(properties); + + RedactIdentity(properties, ClientIdentityProperty); + RedactIdentity(properties, AuthorizationProperty); + RedactCommandValue(properties); + } + + private static void RedactIdentity(IDictionary properties, string propertyName) + { + if (properties.TryGetValue(propertyName, out object? value) && value is string identity) + { + properties[propertyName] = GatewayLogRedactor.RedactClientIdentity(identity); + } + } + + private static void RedactCommandValue(IDictionary properties) + { + if (!properties.TryGetValue(CommandValueProperty, out object? value) || value is null) + { + return; + } + + string? commandMethod = properties.TryGetValue(CommandMethodProperty, out object? method) + ? method as string + : null; + + properties[CommandValueProperty] = GatewayLogRedactor.RedactCommandValue(commandMethod, value); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLoggerExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLoggerExtensions.cs deleted file mode 100644 index 1772e61..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLoggerExtensions.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Microsoft.Extensions.Logging; - -namespace ZB.MOM.WW.MxGateway.Server.Diagnostics; - -public static class GatewayLoggerExtensions -{ - /// Begins a gateway log scope with the specified scope properties. - /// Logger used for diagnostic output. - /// Scope properties to apply. - /// A disposable that ends the scope when disposed. - public static IDisposable? BeginGatewayScope( - this ILogger logger, - GatewayLogScope scope) - { - ArgumentNullException.ThrowIfNull(logger); - ArgumentNullException.ThrowIfNull(scope); - - return logger.BeginScope(scope.ToDictionary()); - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayRequestLoggingMiddlewareExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayRequestLoggingMiddlewareExtensions.cs index 84a0af0..0da213e 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayRequestLoggingMiddlewareExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayRequestLoggingMiddlewareExtensions.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Primitives; +using Serilog.Context; namespace ZB.MOM.WW.MxGateway.Server.Diagnostics; @@ -17,7 +18,12 @@ public static class GatewayRequestLoggingMiddlewareExtensions /// Header name for the command method name. public const string CommandMethodHeaderName = "x-command-method"; - /// Adds gateway request logging scope middleware that reads correlation headers and redacts sensitive data. + /// + /// Adds gateway request logging middleware that reads the correlation headers and pushes them + /// as Serilog properties for the duration of the request. The pushed + /// properties (SessionId / WorkerProcessId / CorrelationId / CommandMethod / ClientIdentity) + /// are disposed when the request completes; the shared redaction enricher masks any secrets. + /// /// Application builder. public static IApplicationBuilder UseGatewayRequestLoggingScope(this IApplicationBuilder app) { @@ -25,21 +31,56 @@ public static class GatewayRequestLoggingMiddlewareExtensions return app.Use(async (context, next) => { - ILogger logger = context.RequestServices - .GetRequiredService() - .CreateLogger("MxGateway.Request"); - - using IDisposable? scope = logger.BeginGatewayScope(new GatewayLogScope( + GatewayLogScope scope = new( SessionId: ReadHeader(context, SessionIdHeaderName), WorkerProcessId: ReadInt32Header(context, WorkerProcessIdHeaderName), CorrelationId: ReadUInt64Header(context, CorrelationIdHeaderName), CommandMethod: ReadHeader(context, CommandMethodHeaderName), - ClientIdentity: ReadHeader(context, "authorization"))); + ClientIdentity: ReadHeader(context, "authorization")); + + using IDisposable correlationScope = PushCorrelationProperties(scope); await next(context); }); } + /// + /// Pushes the populated properties onto the Serilog + /// , returning a single disposable that pops them all when the request + /// completes. Only the properties present in (which + /// already applies the client-identity redaction policy) are pushed. + /// + /// The correlation properties for the current request. + /// A disposable that removes the pushed properties on disposal. + private static IDisposable PushCorrelationProperties(GatewayLogScope scope) + { + Stack pushed = new(); + + foreach (KeyValuePair property in scope.ToDictionary()) + { + pushed.Push(LogContext.PushProperty(property.Key, property.Value)); + } + + return new CorrelationPropertyScope(pushed); + } + + /// + /// Disposes the pushed property bindings in reverse order, restoring + /// the ambient context to its pre-request state. + /// + private sealed class CorrelationPropertyScope(Stack bindings) : IDisposable + { + private readonly Stack _bindings = bindings; + + public void Dispose() + { + while (_bindings.Count > 0) + { + _bindings.Pop().Dispose(); + } + } + } + private static string? ReadHeader(HttpContext context, string headerName) { return context.Request.Headers.TryGetValue(headerName, out StringValues values) diff --git a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs index 90e3f61..037bc85 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs @@ -84,6 +84,8 @@ public static class GatewayApplication /// ZB.MOM.WW.Telemetry.Serilog bootstrap (). /// Sinks and minimum level come from the Serilog configuration section; identity /// (SiteId/NodeRole) is read from MxGateway:Telemetry when present. + /// Also registers the project's adapter so the shared redaction + /// enricher masks gateway secrets on every event. /// /// The web application builder being configured. private static void ConfigureSerilog(WebApplicationBuilder builder) @@ -91,6 +93,8 @@ public static class GatewayApplication string? siteId = builder.Configuration["MxGateway:Telemetry:SiteId"]; string? nodeRole = builder.Configuration["MxGateway:Telemetry:NodeRole"]; + builder.Services.AddSingleton(); + builder.AddZbSerilog(options => { options.ServiceName = "mxgateway"; diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/GatewayLogRedactorAdapterTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/GatewayLogRedactorAdapterTests.cs new file mode 100644 index 0000000..bdc8098 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/GatewayLogRedactorAdapterTests.cs @@ -0,0 +1,92 @@ +using ZB.MOM.WW.MxGateway.Server.Diagnostics; +using ZB.MOM.WW.Telemetry.Serilog; + +namespace ZB.MOM.WW.MxGateway.Tests.Diagnostics; + +/// +/// Pins that applies the gateway's redaction policy through +/// the shared seam — the same secrets the former MEL-scope path masked +/// must still be masked once events flow through the Serilog redaction enricher. +/// +public sealed class GatewayLogRedactorAdapterTests +{ + private readonly ILogRedactor _redactor = new GatewayLogRedactorAdapter(); + + /// Verifies the client identity property has its API-key secret stripped in place. + [Fact] + public void Redact_StripsApiKeySecretFromClientIdentity() + { + Dictionary properties = new() + { + ["ClientIdentity"] = "Bearer mxgw_operator01_super-secret", + }; + + _redactor.Redact(properties); + + Assert.Equal("Bearer mxgw_operator01_[redacted]", properties["ClientIdentity"]); + Assert.DoesNotContain("super-secret", (string?)properties["ClientIdentity"]); + } + + /// Verifies a raw authorization header property is redacted too. + [Fact] + public void Redact_StripsApiKeySecretFromAuthorizationProperty() + { + Dictionary properties = new() + { + ["Authorization"] = "Bearer mxgw_admin_top-secret", + }; + + _redactor.Redact(properties); + + Assert.Equal("Bearer mxgw_admin_[redacted]", properties["Authorization"]); + } + + /// Verifies a command value is redacted for a credential-bearing command method. + [Fact] + public void Redact_RedactsCommandValueForCredentialBearingCommand() + { + Dictionary properties = new() + { + ["CommandMethod"] = "WriteSecured", + ["CommandValue"] = "credential-bearing-value", + }; + + _redactor.Redact(properties); + + Assert.Equal(GatewayLogRedactor.RedactedValue, properties["CommandValue"]); + } + + /// Verifies a command value is redacted by default (value logging disabled) for any command. + [Fact] + public void Redact_RedactsCommandValueByDefault() + { + Dictionary properties = new() + { + ["CommandMethod"] = "Write", + ["CommandValue"] = "plaintext-tag-value", + }; + + _redactor.Redact(properties); + + Assert.Equal(GatewayLogRedactor.RedactedValue, properties["CommandValue"]); + } + + /// Verifies non-sensitive properties are left untouched. + [Fact] + public void Redact_LeavesNonSensitivePropertiesUnchanged() + { + Dictionary properties = new() + { + ["SessionId"] = "session-1", + ["CorrelationId"] = (ulong)99, + ["ClientIdentity"] = "Bearer plain-token-no-marker", + }; + + _redactor.Redact(properties); + + Assert.Equal("session-1", properties["SessionId"]); + Assert.Equal((ulong)99, properties["CorrelationId"]); + // No mxgw_ marker — identity passes through unchanged. + Assert.Equal("Bearer plain-token-no-marker", properties["ClientIdentity"]); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs index 95ed26c..a28acff 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs @@ -5,9 +5,11 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using ZB.MOM.WW.MxGateway.Server; using ZB.MOM.WW.MxGateway.Server.Dashboard; +using ZB.MOM.WW.MxGateway.Tests.Gateway; namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; +[Collection(HostBuildingCollection.Name)] public sealed class DashboardCookieOptionsTests { /// Verifies that the application configures secure dashboard authentication cookies. diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardHubsRegistrationTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardHubsRegistrationTests.cs index 3348029..ff6f2fa 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardHubsRegistrationTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardHubsRegistrationTests.cs @@ -4,9 +4,11 @@ using Microsoft.Extensions.DependencyInjection; using ZB.MOM.WW.MxGateway.Server; using ZB.MOM.WW.MxGateway.Server.Dashboard; using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; +using ZB.MOM.WW.MxGateway.Tests.Gateway; namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; +[Collection(HostBuildingCollection.Name)] public sealed class DashboardHubsRegistrationTests { /// Verifies that dashboard build maps all three hubs and token endpoint. diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs index 5f2c746..0890398 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs @@ -9,6 +9,7 @@ using ZB.MOM.WW.MxGateway.Server.Metrics; namespace ZB.MOM.WW.MxGateway.Tests.Gateway; +[Collection(HostBuildingCollection.Name)] public sealed class GatewayApplicationTests { /// Verifies that Build maps the live health check endpoint. diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/HostBuildingCollection.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/HostBuildingCollection.cs new file mode 100644 index 0000000..fce3f29 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/HostBuildingCollection.cs @@ -0,0 +1,21 @@ +namespace ZB.MOM.WW.MxGateway.Tests.Gateway; + +/// +/// Groups every test that constructs a full gateway host via GatewayApplication.Build into a +/// single, non-parallel xUnit collection. +/// +/// +/// The shared ZB.MOM.WW.Telemetry.Serilog bootstrap (AddZbSerilog) installs a global +/// bootstrap logger on each host build and freezes it during +/// builder.Build(). That global is process-wide, so two host builds racing in parallel +/// collections interleave on it and the loser throws "The logger is already frozen." Serializing the +/// host-building tests (which is the only place a full host is built) removes the race without +/// changing any production behaviour — the gateway process only ever builds one host. +/// +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class HostBuildingCollection +{ + /// Collection name shared by every host-building test class. + public const string Name = "Gateway host building"; +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/SqliteAuthStoreTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/SqliteAuthStoreTests.cs index fd8076d..11e70e0 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/SqliteAuthStoreTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/SqliteAuthStoreTests.cs @@ -5,12 +5,14 @@ using Microsoft.Extensions.DependencyInjection; using ZB.MOM.WW.MxGateway.Server; using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Security.Authentication; +using ZB.MOM.WW.MxGateway.Tests.Gateway; namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication; /// /// Tests for . /// +[Collection(HostBuildingCollection.Name)] public sealed class SqliteAuthStoreTests : IDisposable { private readonly List _tempDirectories = [];