refactor(logging): correlation scope + redaction on shared ILogRedactor seam

Move the per-request correlation context and secret redaction off the MEL
mechanism onto the Serilog primitives the shared bootstrap consumes.

- GatewayRequestLoggingMiddlewareExtensions now pushes the correlation
  properties (SessionId / WorkerProcessId / CorrelationId / CommandMethod /
  ClientIdentity) via Serilog LogContext.PushProperty for the request lifetime
  and pops them on completion, replacing MEL ILogger.BeginScope. Header parsing
  and property names are unchanged; GatewayLogScope remains the data holder.
- Add GatewayLogRedactorAdapter : ILogRedactor delegating to the existing
  GatewayLogRedactor policy (mxgw_ bearer tokens / credential-bearing command
  values), registered as a singleton so the shared RedactionEnricher masks
  secrets on every event. Remove the now-dead GatewayLoggerExtensions MEL helper.
- Tests: add GatewayLogRedactorAdapterTests; serialize the four host-building
  test classes into one non-parallel collection (HostBuildingCollection) so the
  process-wide Serilog bootstrap logger is not frozen by two concurrent host
  builds racing in parallel collections.

The net48/x86 worker is untouched.
This commit is contained in:
Joseph Doherty
2026-06-01 08:06:28 -04:00
parent 4bf71a0b2c
commit deba5ed115
10 changed files with 236 additions and 27 deletions
@@ -0,0 +1,64 @@
using ZB.MOM.WW.Telemetry.Serilog;
namespace ZB.MOM.WW.MxGateway.Server.Diagnostics;
/// <summary>
/// Bridges the gateway's <see cref="GatewayLogRedactor"/> policy onto the shared
/// <see cref="ILogRedactor"/> seam consumed by <c>ZB.MOM.WW.Telemetry.Serilog</c>'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
/// (<c>mxgw_*</c>) and command values for credential-bearing MXAccess commands. All masking
/// decisions delegate to <see cref="GatewayLogRedactor"/> — this type adds no new policy.
/// </summary>
public sealed class GatewayLogRedactorAdapter : ILogRedactor
{
/// <summary>Property name carrying a client identity / authorization header value.</summary>
private const string ClientIdentityProperty = "ClientIdentity";
/// <summary>Property name carrying a raw authorization header value.</summary>
private const string AuthorizationProperty = "Authorization";
/// <summary>Property name carrying the MXAccess command method, used to gate value redaction.</summary>
private const string CommandMethodProperty = "CommandMethod";
/// <summary>Property name carrying a command payload value that may bear credentials.</summary>
private const string CommandValueProperty = "CommandValue";
/// <summary>
/// Masks any sensitive values in <paramref name="properties"/> in place using the shared
/// <see cref="GatewayLogRedactor"/> policy. Identity/authorization properties have their API-key
/// secret stripped; a command value is redacted when its associated command method bears
/// credentials.
/// </summary>
/// <param name="properties">The mutable log-event property dictionary for the current event.</param>
public void Redact(IDictionary<string, object?> properties)
{
ArgumentNullException.ThrowIfNull(properties);
RedactIdentity(properties, ClientIdentityProperty);
RedactIdentity(properties, AuthorizationProperty);
RedactCommandValue(properties);
}
private static void RedactIdentity(IDictionary<string, object?> properties, string propertyName)
{
if (properties.TryGetValue(propertyName, out object? value) && value is string identity)
{
properties[propertyName] = GatewayLogRedactor.RedactClientIdentity(identity);
}
}
private static void RedactCommandValue(IDictionary<string, object?> 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);
}
}
@@ -1,20 +0,0 @@
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.MxGateway.Server.Diagnostics;
public static class GatewayLoggerExtensions
{
/// <summary>Begins a gateway log scope with the specified scope properties.</summary>
/// <param name="logger">Logger used for diagnostic output.</param>
/// <param name="scope">Scope properties to apply.</param>
/// <returns>A disposable that ends the scope when disposed.</returns>
public static IDisposable? BeginGatewayScope(
this ILogger logger,
GatewayLogScope scope)
{
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(scope);
return logger.BeginScope(scope.ToDictionary());
}
}
@@ -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
/// <summary>Header name for the command method name.</summary>
public const string CommandMethodHeaderName = "x-command-method";
/// <summary>Adds gateway request logging scope middleware that reads correlation headers and redacts sensitive data.</summary>
/// <summary>
/// Adds gateway request logging middleware that reads the correlation headers and pushes them
/// as Serilog <see cref="LogContext"/> 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.
/// </summary>
/// <param name="app">Application builder.</param>
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<ILoggerFactory>()
.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);
});
}
/// <summary>
/// Pushes the populated <paramref name="scope"/> properties onto the Serilog
/// <see cref="LogContext"/>, returning a single disposable that pops them all when the request
/// completes. Only the properties present in <see cref="GatewayLogScope.ToDictionary"/> (which
/// already applies the client-identity redaction policy) are pushed.
/// </summary>
/// <param name="scope">The correlation properties for the current request.</param>
/// <returns>A disposable that removes the pushed properties on disposal.</returns>
private static IDisposable PushCorrelationProperties(GatewayLogScope scope)
{
Stack<IDisposable> pushed = new();
foreach (KeyValuePair<string, object?> property in scope.ToDictionary())
{
pushed.Push(LogContext.PushProperty(property.Key, property.Value));
}
return new CorrelationPropertyScope(pushed);
}
/// <summary>
/// Disposes the pushed <see cref="LogContext"/> property bindings in reverse order, restoring
/// the ambient context to its pre-request state.
/// </summary>
private sealed class CorrelationPropertyScope(Stack<IDisposable> bindings) : IDisposable
{
private readonly Stack<IDisposable> _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)
@@ -84,6 +84,8 @@ public static class GatewayApplication
/// <c>ZB.MOM.WW.Telemetry.Serilog</c> bootstrap (<see cref="ZbSerilogExtensions.AddZbSerilog"/>).
/// Sinks and minimum level come from the <c>Serilog</c> configuration section; identity
/// (<c>SiteId</c>/<c>NodeRole</c>) is read from <c>MxGateway:Telemetry</c> when present.
/// Also registers the project's <see cref="ILogRedactor"/> adapter so the shared redaction
/// enricher masks gateway secrets on every event.
/// </summary>
/// <param name="builder">The web application builder being configured.</param>
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<ILogRedactor, GatewayLogRedactorAdapter>();
builder.AddZbSerilog(options =>
{
options.ServiceName = "mxgateway";
@@ -0,0 +1,92 @@
using ZB.MOM.WW.MxGateway.Server.Diagnostics;
using ZB.MOM.WW.Telemetry.Serilog;
namespace ZB.MOM.WW.MxGateway.Tests.Diagnostics;
/// <summary>
/// Pins that <see cref="GatewayLogRedactorAdapter"/> applies the gateway's redaction policy through
/// the shared <see cref="ILogRedactor"/> seam — the same secrets the former MEL-scope path masked
/// must still be masked once events flow through the Serilog redaction enricher.
/// </summary>
public sealed class GatewayLogRedactorAdapterTests
{
private readonly ILogRedactor _redactor = new GatewayLogRedactorAdapter();
/// <summary>Verifies the client identity property has its API-key secret stripped in place.</summary>
[Fact]
public void Redact_StripsApiKeySecretFromClientIdentity()
{
Dictionary<string, object?> 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"]);
}
/// <summary>Verifies a raw authorization header property is redacted too.</summary>
[Fact]
public void Redact_StripsApiKeySecretFromAuthorizationProperty()
{
Dictionary<string, object?> properties = new()
{
["Authorization"] = "Bearer mxgw_admin_top-secret",
};
_redactor.Redact(properties);
Assert.Equal("Bearer mxgw_admin_[redacted]", properties["Authorization"]);
}
/// <summary>Verifies a command value is redacted for a credential-bearing command method.</summary>
[Fact]
public void Redact_RedactsCommandValueForCredentialBearingCommand()
{
Dictionary<string, object?> properties = new()
{
["CommandMethod"] = "WriteSecured",
["CommandValue"] = "credential-bearing-value",
};
_redactor.Redact(properties);
Assert.Equal(GatewayLogRedactor.RedactedValue, properties["CommandValue"]);
}
/// <summary>Verifies a command value is redacted by default (value logging disabled) for any command.</summary>
[Fact]
public void Redact_RedactsCommandValueByDefault()
{
Dictionary<string, object?> properties = new()
{
["CommandMethod"] = "Write",
["CommandValue"] = "plaintext-tag-value",
};
_redactor.Redact(properties);
Assert.Equal(GatewayLogRedactor.RedactedValue, properties["CommandValue"]);
}
/// <summary>Verifies non-sensitive properties are left untouched.</summary>
[Fact]
public void Redact_LeavesNonSensitivePropertiesUnchanged()
{
Dictionary<string, object?> 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"]);
}
}
@@ -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
{
/// <summary>Verifies that the application configures secure dashboard authentication cookies.</summary>
@@ -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
{
/// <summary>Verifies that dashboard build maps all three hubs and token endpoint.</summary>
@@ -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
{
/// <summary>Verifies that Build maps the live health check endpoint.</summary>
@@ -0,0 +1,21 @@
namespace ZB.MOM.WW.MxGateway.Tests.Gateway;
/// <summary>
/// Groups every test that constructs a full gateway host via <c>GatewayApplication.Build</c> into a
/// single, non-parallel xUnit collection.
///
/// <para>
/// The shared <c>ZB.MOM.WW.Telemetry.Serilog</c> bootstrap (<c>AddZbSerilog</c>) installs a global
/// <see cref="global::Serilog.Log.Logger"/> bootstrap logger on each host build and freezes it during
/// <c>builder.Build()</c>. 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.
/// </para>
/// </summary>
[CollectionDefinition(Name, DisableParallelization = true)]
public sealed class HostBuildingCollection
{
/// <summary>Collection name shared by every host-building test class.</summary>
public const string Name = "Gateway host building";
}
@@ -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;
/// <summary>
/// Tests for <see cref="SqliteAuthStore"/>.
/// </summary>
[Collection(HostBuildingCollection.Name)]
public sealed class SqliteAuthStoreTests : IDisposable
{
private readonly List<TempDatabaseDirectory> _tempDirectories = [];