Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e13152f340 | |||
| deba5ed115 | |||
| 4bf71a0b2c |
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+48
-7
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.Extensions.Primitives;
|
using Microsoft.Extensions.Primitives;
|
||||||
|
using Serilog.Context;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Diagnostics;
|
namespace ZB.MOM.WW.MxGateway.Server.Diagnostics;
|
||||||
|
|
||||||
@@ -17,7 +18,12 @@ public static class GatewayRequestLoggingMiddlewareExtensions
|
|||||||
/// <summary>Header name for the command method name.</summary>
|
/// <summary>Header name for the command method name.</summary>
|
||||||
public const string CommandMethodHeaderName = "x-command-method";
|
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>
|
/// <param name="app">Application builder.</param>
|
||||||
public static IApplicationBuilder UseGatewayRequestLoggingScope(this IApplicationBuilder app)
|
public static IApplicationBuilder UseGatewayRequestLoggingScope(this IApplicationBuilder app)
|
||||||
{
|
{
|
||||||
@@ -25,21 +31,56 @@ public static class GatewayRequestLoggingMiddlewareExtensions
|
|||||||
|
|
||||||
return app.Use(async (context, next) =>
|
return app.Use(async (context, next) =>
|
||||||
{
|
{
|
||||||
ILogger logger = context.RequestServices
|
GatewayLogScope scope = new(
|
||||||
.GetRequiredService<ILoggerFactory>()
|
|
||||||
.CreateLogger("MxGateway.Request");
|
|
||||||
|
|
||||||
using IDisposable? scope = logger.BeginGatewayScope(new GatewayLogScope(
|
|
||||||
SessionId: ReadHeader(context, SessionIdHeaderName),
|
SessionId: ReadHeader(context, SessionIdHeaderName),
|
||||||
WorkerProcessId: ReadInt32Header(context, WorkerProcessIdHeaderName),
|
WorkerProcessId: ReadInt32Header(context, WorkerProcessIdHeaderName),
|
||||||
CorrelationId: ReadUInt64Header(context, CorrelationIdHeaderName),
|
CorrelationId: ReadUInt64Header(context, CorrelationIdHeaderName),
|
||||||
CommandMethod: ReadHeader(context, CommandMethodHeaderName),
|
CommandMethod: ReadHeader(context, CommandMethodHeaderName),
|
||||||
ClientIdentity: ReadHeader(context, "authorization")));
|
ClientIdentity: ReadHeader(context, "authorization"));
|
||||||
|
|
||||||
|
using IDisposable correlationScope = PushCorrelationProperties(scope);
|
||||||
|
|
||||||
await next(context);
|
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)
|
private static string? ReadHeader(HttpContext context, string headerName)
|
||||||
{
|
{
|
||||||
return context.Request.Headers.TryGetValue(headerName, out StringValues values)
|
return context.Request.Headers.TryGetValue(headerName, out StringValues values)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.AspNetCore.Hosting.StaticWebAssets;
|
using Microsoft.AspNetCore.Hosting.StaticWebAssets;
|
||||||
|
using Serilog;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts;
|
using ZB.MOM.WW.MxGateway.Contracts;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Alarms;
|
using ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
@@ -11,6 +12,7 @@ using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Workers;
|
using ZB.MOM.WW.MxGateway.Server.Workers;
|
||||||
|
using ZB.MOM.WW.Telemetry.Serilog;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server;
|
namespace ZB.MOM.WW.MxGateway.Server;
|
||||||
|
|
||||||
@@ -31,7 +33,10 @@ public static class GatewayApplication
|
|||||||
WebApplicationBuilder builder = CreateBuilder(args);
|
WebApplicationBuilder builder = CreateBuilder(args);
|
||||||
WebApplication app = builder.Build();
|
WebApplication app = builder.Build();
|
||||||
|
|
||||||
|
// Push the per-request correlation properties (via Serilog LogContext) before the
|
||||||
|
// request-logging middleware emits its completion event, so those properties appear on it.
|
||||||
app.UseGatewayRequestLoggingScope();
|
app.UseGatewayRequestLoggingScope();
|
||||||
|
app.UseSerilogRequestLogging();
|
||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
@@ -55,6 +60,8 @@ public static class GatewayApplication
|
|||||||
});
|
});
|
||||||
StaticWebAssetsLoader.UseStaticWebAssets(builder.Environment, builder.Configuration);
|
StaticWebAssetsLoader.UseStaticWebAssets(builder.Environment, builder.Configuration);
|
||||||
|
|
||||||
|
ConfigureSerilog(builder);
|
||||||
|
|
||||||
builder.Services.AddGatewayConfiguration();
|
builder.Services.AddGatewayConfiguration();
|
||||||
builder.Services.AddSqliteAuthStore();
|
builder.Services.AddSqliteAuthStore();
|
||||||
builder.Services.AddGatewayGrpcAuthorization();
|
builder.Services.AddGatewayGrpcAuthorization();
|
||||||
@@ -72,6 +79,30 @@ public static class GatewayApplication
|
|||||||
return builder;
|
return builder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces the default Microsoft.Extensions.Logging provider with the shared
|
||||||
|
/// <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)
|
||||||
|
{
|
||||||
|
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";
|
||||||
|
options.SiteId = string.IsNullOrWhiteSpace(siteId) ? null : siteId;
|
||||||
|
options.NodeRole = string.IsNullOrWhiteSpace(nodeRole) ? null : nodeRole;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private static string ResolveContentRootPath()
|
private static string ResolveContentRootPath()
|
||||||
{
|
{
|
||||||
string? configuredContentRootPath = Environment.GetEnvironmentVariable("ASPNETCORE_CONTENTROOT");
|
string? configuredContentRootPath = Environment.GetEnvironmentVariable("ASPNETCORE_CONTENTROOT");
|
||||||
|
|||||||
@@ -15,6 +15,13 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.MxGateway.Contracts\ZB.MOM.WW.MxGateway.Contracts.csproj" />
|
<ProjectReference Include="..\ZB.MOM.WW.MxGateway.Contracts\ZB.MOM.WW.MxGateway.Contracts.csproj" />
|
||||||
|
<!--
|
||||||
|
Shared structured-logging bootstrap (ZB.MOM.WW.Telemetry.Serilog) lives in the sibling
|
||||||
|
scadaproj workspace. Cross-repo ProjectReference: the referenced project resolves its own
|
||||||
|
Directory.Build.props / Directory.Packages.props from its own tree, so it does not perturb
|
||||||
|
this repo's build settings. It transitively brings the ZB.MOM.WW.Telemetry core package.
|
||||||
|
-->
|
||||||
|
<ProjectReference Include="..\..\..\scadaproj\ZB.MOM.WW.Telemetry\src\ZB.MOM.WW.Telemetry.Serilog\ZB.MOM.WW.Telemetry.Serilog.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
{
|
{
|
||||||
"Logging": {
|
"Serilog": {
|
||||||
"LogLevel": {
|
"MinimumLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Override": {
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,30 @@
|
|||||||
{
|
{
|
||||||
"Logging": {
|
"Serilog": {
|
||||||
"LogLevel": {
|
"Using": [
|
||||||
|
"Serilog.Sinks.Console",
|
||||||
|
"Serilog.Sinks.File"
|
||||||
|
],
|
||||||
|
"MinimumLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Override": {
|
||||||
}
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"WriteTo": [
|
||||||
|
{
|
||||||
|
"Name": "Console",
|
||||||
|
"Args": {
|
||||||
|
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] [{NodeRole}/{NodeHostname}] {Message:lj} {Properties:j}{NewLine}{Exception}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "File",
|
||||||
|
"Args": {
|
||||||
|
"path": "logs/mxgateway-.log",
|
||||||
|
"rollingInterval": "Day"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"MxGateway": {
|
"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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user