fix(logging): fail-closed bearer redaction; hoist per-request logger creation

This commit is contained in:
Joseph Doherty
2026-08-15 12:17:27 -04:00
parent e2ac5d117a
commit ca34a2d65d
4 changed files with 182 additions and 73 deletions
+27 -37
View File
@@ -84,42 +84,32 @@ The names match the MXAccess command list in `gateway.md` exactly. `Write` and `
### API key redaction
`RedactApiKey` is built around the `mxgw_` API key format issued by the gateway. It preserves the bearer scheme and the key id segment so that operators can correlate a log entry to a specific principal, but always strips the secret tail:
`RedactClientIdentity` is the single redaction path for identity-bearing values; `RedactApiKey` is a
name-preserving alias for it. Redaction **fails closed**: the only value that survives with any of its
content is a gateway-issued `mxgw_<key-id>_<secret>` key, whose key id is kept so operators can
correlate a log entry to a specific principal.
```csharp
public static string? RedactApiKey(string? authorizationHeader)
{
if (string.IsNullOrWhiteSpace(authorizationHeader))
{
return authorizationHeader;
}
| Input | Output | Why |
|-------|--------|-----|
| `Bearer mxgw_operator01_super-secret` | `Bearer mxgw_operator01_[redacted]` | Recognized gateway key; key id identifies the principal |
| `Bearer eyJhbGciOi…` (any foreign token) | `Bearer [redacted]` | Structure is unknown, so the whole credential goes |
| `Basic dXNlcjpwYXNz` | `Basic [redacted]` | Same, for any recognized scheme |
| `Bearer mxgw_operator01` (no secret separator) | `Bearer mxgw_[redacted]` | No trustworthy key-id boundary |
| `Bearer` (scheme only), `anonymous`, `some junk` | `[redacted]` | No scheme/credential split that can be trusted |
| `null`, `""`, whitespace | unchanged | Nothing to redact |
const string bearerPrefix = "Bearer ";
if (!authorizationHeader.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
{
return RedactedValue;
}
A scheme word survives only when it is one of the recognized authorization schemes (`Bearer`,
`Basic`, `Digest`, `Negotiate`, `NTLM`, `ApiKey`, `Token`). An unrecognized leading word is as likely
to be credential material as it is to be a scheme, so it is dropped along with the rest. The key id is
also dropped when it runs longer than 64 characters, which no issued key id does — a long run before
the first `_` is secret material, not an identifier.
string token = authorizationHeader[bearerPrefix.Length..].Trim();
The parse is span-based (no regex, no `Split` allocation): the value is split once at the first space,
and the key id is read up to the first `_` of the remainder.
if (!token.StartsWith("mxgw_", StringComparison.OrdinalIgnoreCase))
{
return $"{bearerPrefix}{RedactedValue}";
}
string[] tokenParts = token.Split('_', 3, StringSplitOptions.RemoveEmptyEntries);
if (tokenParts.Length < 2)
{
return $"{bearerPrefix}mxgw_{RedactedValue}";
}
return $"{bearerPrefix}mxgw_{tokenParts[1]}_{RedactedValue}";
}
```
The split uses `count: 3` because the secret portion may itself contain underscores; only the first two segments (`mxgw` and the key id) are kept verbatim. Authorization headers that are not bearer tokens are reduced to `[redacted]` rather than passed through, since the gateway cannot reason about their structure.
`RedactClientIdentity` is the entry point used by `GatewayLogScope` and `DashboardRedactor`. It only invokes `RedactApiKey` when the input contains the `mxgw_` marker, leaving non-key identities (for example, Windows account names) untouched.
The consequence for callers is that a non-key identity (for example a Windows account name) reaching
`RedactClientIdentity` is now replaced rather than passed through. `DashboardRedactor` routes only
values containing the `mxgw_` marker here, so dashboard display names are unaffected.
### Command value redaction
@@ -160,12 +150,12 @@ public static IApplicationBuilder UseGatewayRequestLoggingScope(this IApplicatio
{
ArgumentNullException.ThrowIfNull(app);
ILogger logger = app.ApplicationServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger("MxGateway.Request");
return app.Use(async (context, next) =>
{
ILogger logger = context.RequestServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger("ZB.MOM.WW.MxGateway.Request");
using IDisposable? scope = logger.BeginGatewayScope(new GatewayLogScope(
SessionId: ReadHeader(context, SessionIdHeaderName),
WorkerProcessId: ReadInt32Header(context, WorkerProcessIdHeaderName),
@@ -190,7 +180,7 @@ The scope is keyed off four custom headers and the standard `authorization` head
The numeric headers use `int.TryParse` and `ulong.TryParse`; missing or unparseable values become `null` and are dropped by `GatewayLogScope.ToDictionary`. This keeps the middleware tolerant of clients that do not yet emit every header, which matters because the earliest call in a session (`OpenSession`) has no `SessionId` to send.
The logger category is `ZB.MOM.WW.MxGateway.Request`, which lets operators filter the request scope events independently from per-component categories.
The logger category is `MxGateway.Request`, which lets operators filter the request scope events independently from per-component categories. The logger is resolved once at registration rather than per request: the category is fixed, so a per-request `IServiceProvider` resolve and `ILoggerFactory.CreateLogger` (which takes the factory lock) bought nothing. Scope construction itself stays unconditional — gating it on `ILogger.IsEnabled` would drop scope state for providers and scope consumers registered after startup.
### Pipeline ordering