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 = [];