Merge stream C (gRPC surface: audit aggregation, streaming auth, alarm overflow/snapshot, write cancellation, correlation echo, disconnect reasons, verifier single-flight, opt-in /metrics auth)

This commit is contained in:
Joseph Doherty
2026-10-22 06:47:49 -04:00
39 changed files with 2535 additions and 112 deletions
@@ -0,0 +1,39 @@
namespace ZB.MOM.WW.MxGateway.Server.Alarms;
/// <summary>
/// Completes an alarm-feed subscriber that fell behind its bounded queue and was dropped.
/// </summary>
/// <remarks>
/// A typed exception rather than a bare <see cref="InvalidOperationException"/> so the gRPC
/// layer can map it to <c>ResourceExhausted</c> — the status a <c>StreamEvents</c> overflow
/// carries — without treating a slow client as a gateway failure (an unrecognized exception is
/// logged as a warning with its stack and surfaced as <c>Unavailable</c>). The remedy is the
/// same as for an event-stream overflow: reconnect, which re-snapshots the alarm set.
/// </remarks>
public sealed class AlarmFeedSubscriberOverflowException : Exception
{
/// <summary>The message every dropped subscriber is completed with.</summary>
public const string DefaultMessage =
"Alarm feed subscriber fell behind and was dropped; reconnect to re-snapshot.";
/// <summary>Initializes a new instance of the <see cref="AlarmFeedSubscriberOverflowException"/> class.</summary>
public AlarmFeedSubscriberOverflowException()
: base(DefaultMessage)
{
}
/// <summary>Initializes a new instance of the <see cref="AlarmFeedSubscriberOverflowException"/> class.</summary>
/// <param name="message">The error message.</param>
public AlarmFeedSubscriberOverflowException(string message)
: base(message)
{
}
/// <summary>Initializes a new instance of the <see cref="AlarmFeedSubscriberOverflowException"/> class.</summary>
/// <param name="message">The error message.</param>
/// <param name="innerException">The exception that caused this one.</param>
public AlarmFeedSubscriberOverflowException(string message, Exception innerException)
: base(message, innerException)
{
}
}
@@ -967,7 +967,9 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
}
// Caller holds _sync. Pushes a feed message to every matching subscriber;
// a subscriber that has fallen behind is completed with an error and dropped.
// a subscriber that has fallen behind is completed with an AlarmFeedSubscriberOverflowException
// and dropped. The typed fault is what lets StreamAlarms answer ResourceExhausted instead of
// treating a slow client as a gateway failure.
private void Broadcast(AlarmFeedMessage message, string reference)
{
for (int index = _subscribers.Count - 1; index >= 0; index--)
@@ -980,8 +982,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
if (!subscriber.Channel.Writer.TryWrite(message))
{
subscriber.Channel.Writer.TryComplete(new InvalidOperationException(
"Alarm feed subscriber fell behind and was dropped; reconnect to re-snapshot."));
subscriber.Channel.Writer.TryComplete(new AlarmFeedSubscriberOverflowException());
_subscribers.RemoveAt(index);
}
}
@@ -997,8 +998,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
Subscriber subscriber = _subscribers[index];
if (!subscriber.Channel.Writer.TryWrite(message))
{
subscriber.Channel.Writer.TryComplete(new InvalidOperationException(
"Alarm feed subscriber fell behind and was dropped; reconnect to re-snapshot."));
subscriber.Channel.Writer.TryComplete(new AlarmFeedSubscriberOverflowException());
_subscribers.RemoveAt(index);
}
}
@@ -1046,9 +1046,14 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
_subscribers.Add(subscriber);
providerStatus = BuildProviderStatus();
snapshotTruncated = _snapshotTruncated;
snapshot = _alarms.Values
.Where(alarm => prefix.Length == 0
|| alarm.AlarmFullReference.StartsWith(prefix, StringComparison.Ordinal))
// Take the memoized projection rather than cloning the whole set per attach: the
// clone runs at most once per alarm-set generation (shared with CurrentAlarms), so a
// burst of reconnecting subscribers no longer holds the broadcast lock for one full
// copy each. The array is never mutated once built — a write to _alarms replaces it
// with null — so capturing the reference here pins exactly the generation this
// subscriber registered against, which is what keeps snapshot-then-live ordering.
snapshot = _currentAlarmsProjection ??= _alarms.Values
.Select(alarm => alarm.Clone())
.ToArray();
}
@@ -1068,8 +1073,15 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
SnapshotStatus = new AlarmSnapshotStatus { Truncated = snapshotTruncated },
};
// The prefix filter runs here, outside _sync, over the immutable projection.
foreach (ActiveAlarmSnapshot alarm in snapshot)
{
if (prefix.Length != 0
&& !alarm.AlarmFullReference.StartsWith(prefix, StringComparison.Ordinal))
{
continue;
}
yield return new AlarmFeedMessage { ActiveAlarm = alarm };
}
@@ -52,4 +52,7 @@ public sealed class GatewayOptions
/// login / API-key rate limiting).
/// </summary>
public SecurityOptions Security { get; init; } = new();
/// <summary>Gets Prometheus <c>/metrics</c> endpoint options.</summary>
public MetricsOptions Metrics { get; init; } = new();
}
@@ -0,0 +1,15 @@
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
/// <summary>Options for the Prometheus <c>/metrics</c> scrape endpoint (<c>MxGateway:Metrics</c>).</summary>
public sealed class MetricsOptions
{
/// <summary>
/// Gets a value indicating whether <c>/metrics</c> requires an authenticated caller. When
/// <see langword="true"/>, a scrape must present either an API key carrying the <c>admin</c>
/// scope (<c>Authorization: Bearer mxgw_…</c>) or a dashboard login cookie holding a real
/// dashboard role; anonymous localhost access does not qualify. Default <see langword="false"/>
/// keeps the endpoint anonymous for existing scrapers. The <c>/health</c> endpoints stay
/// anonymous either way.
/// </summary>
public bool RequireAuthentication { get; init; }
}
@@ -113,6 +113,7 @@ public static class GatewayApplication
builder.Services.AddZbSecrets(builder.Configuration, "Secrets");
builder.Services.AddSqliteAuthStore(builder.Configuration);
builder.Services.AddGatewayGrpcAuthorization();
builder.Services.AddGatewayMetricsAuthorization();
AddLoginRateLimiter(builder);
builder.Services.AddHealthChecks()
.AddTypeActivatedCheck<AuthStoreHealthCheck>(
@@ -350,8 +351,19 @@ public static class GatewayApplication
{
endpoints.MapStaticAssets(ResolveStaticAssetsManifestPath());
// /health stays anonymous unconditionally: an off-repo Overview dashboard polls
// /health/active without credentials.
endpoints.MapZbHealth();
endpoints.MapZbMetrics();
IEndpointConventionBuilder metricsEndpoint = endpoints.MapZbMetrics();
// Read from configuration rather than IOptions<GatewayOptions>: resolving the options here
// would run whole-options validation during Build instead of at StartAsync.
if (endpoints.ServiceProvider
.GetRequiredService<IConfiguration>()
.GetValue<bool>("MxGateway:Metrics:RequireAuthentication"))
{
// Opt-in (SEC-14): an admin-scoped API key or a dashboard login with a real role.
metricsEndpoint.RequireAuthorization(MetricsAuthorizationDefaults.Policy);
}
endpoints.MapGrpcService<MxAccessGatewayService>();
endpoints.MapZbGalaxyRepository();
@@ -0,0 +1,30 @@
namespace ZB.MOM.WW.MxGateway.Server.Grpc;
/// <summary>
/// The bounded set of <c>reason</c> tag values recorded on
/// <c>mxgateway.grpc.streams.disconnected</c> when a <c>StreamEvents</c> stream ends.
/// </summary>
/// <remarks>
/// Constants rather than exception messages or free text so the metric's tag cardinality stays
/// fixed: an exporter mints one time series per distinct tag value.
/// </remarks>
public static class EventStreamDisconnectReasons
{
/// <summary>The session's event stream completed cleanly (session closed or the worker stream ended).</summary>
public const string SessionClosed = "SessionClosed";
/// <summary>The subscriber fell behind its bounded queue and was disconnected.</summary>
public const string Overflow = "Overflow";
/// <summary>The worker event stream terminated with a fault.</summary>
public const string WorkerFault = "WorkerFault";
/// <summary>The client cancelled the call or disconnected.</summary>
public const string ClientCancelled = "ClientCancelled";
/// <summary>The stream ended on an unclassified error.</summary>
public const string Error = "Error";
/// <summary>The consumer stopped reading without cancelling the call.</summary>
public const string Detached = "Detached";
}
@@ -129,6 +129,10 @@ public sealed class EventStreamService(
IDisposable backlogRegistration = metrics.RegisterEventStreamBacklogSource(
() => subscriber.Reader.CanCount ? subscriber.Reader.Count : 0);
// Why this stream ended, recorded as a bounded constant on the disconnect metric. Set at
// the point the end is observed; a consumer that simply stops iterating keeps Detached.
string disconnectReason = EventStreamDisconnectReasons.Detached;
try
{
// Emit order for a resume: the ReplayGap sentinel FIRST (only when events were
@@ -173,13 +177,31 @@ public sealed class EventStreamService(
// here. Mirror the original ProduceEventsAsync behavior: fault the
// session and record the metric, then propagate the terminal fault to the
// gRPC client.
disconnectReason = EventStreamDisconnectReasons.WorkerFault;
session.MarkFaulted(workerException.Message);
metrics.Fault(WorkerClientErrorCode.WorkerFaulted.ToString());
throw;
}
catch (SessionManagerException sessionException)
when (sessionException.ErrorCode == SessionManagerErrorCode.EventQueueOverflow)
{
disconnectReason = EventStreamDisconnectReasons.Overflow;
throw;
}
catch (OperationCanceledException)
{
disconnectReason = EventStreamDisconnectReasons.ClientCancelled;
throw;
}
catch (Exception)
{
disconnectReason = EventStreamDisconnectReasons.Error;
throw;
}
if (!hasMore)
{
disconnectReason = EventStreamDisconnectReasons.SessionClosed;
break;
}
@@ -214,7 +236,15 @@ public sealed class EventStreamService(
backlogRegistration.Dispose();
subscriber.Dispose();
metrics.StreamDisconnected("Detached");
// A consumer that stopped iterating mid-stream lands here with the Detached default;
// when the call token has fired, that early stop was the client going away.
if (disconnectReason == EventStreamDisconnectReasons.Detached
&& cancellationToken.IsCancellationRequested)
{
disconnectReason = EventStreamDisconnectReasons.ClientCancelled;
}
metrics.StreamDisconnected(disconnectReason);
}
}
@@ -144,6 +144,25 @@ public sealed class MxAccessGatewayService(
publicReply = bulkConstraintPlan.MergeDeniedInto(publicReply);
}
// MxCommandReply.correlation_id always echoes the caller's client_correlation_id —
// the same value the constraint-denied replies carry — so a client can match every
// reply to its request. The worker reply carries the gateway's internal envelope id,
// which is a gateway↔worker routing detail and is overwritten here, empty included.
// Record the pairing once, before the envelope id is overwritten, so a client error
// report quoting its correlation id can be matched to gateway and worker log lines
// (which carry the envelope id). Ids and the command kind only — never values.
if (logger.IsEnabled(LogLevel.Debug))
{
logger.LogDebug(
"Invoke {CommandKind} for session {SessionId}: client correlation id {ClientCorrelationId} maps to worker envelope correlation id {WorkerCorrelationId}.",
command.Kind,
request.SessionId,
request.ClientCorrelationId,
publicReply.CorrelationId);
}
publicReply.CorrelationId = request.ClientCorrelationId;
session.TrackCommandReply(commandToInvoke, publicReply);
return publicReply;
}
@@ -172,14 +191,22 @@ public sealed class MxAccessGatewayService(
// volume gateway path. Stopwatch.GetTimestamp/GetElapsedTime measure the exact
// same wall-clock span the former Stopwatch.StartNew()/.Elapsed did.
long sendStartTimestamp = Stopwatch.GetTimestamp();
await responseStream.WriteAsync(publicEvent).ConfigureAwait(false);
// The call token releases a write parked on a slow or vanished client when the
// call is cancelled, instead of leaving it to the transport's own timeout.
await responseStream.WriteAsync(publicEvent, context.CancellationToken).ConfigureAwait(false);
metrics.RecordEventStreamSend(
publicEvent.Family.ToString(),
MxEventFamilyNames.Get(publicEvent.Family),
Stopwatch.GetElapsedTime(sendStartTimestamp));
}
}
catch (Exception exception) when (exception is not RpcException)
{
if (IsClientAbort(exception, context))
{
throw ClientCancelled();
}
throw MapException(exception);
}
}
@@ -225,11 +252,24 @@ public sealed class MxAccessGatewayService(
.WithCancellation(context.CancellationToken)
.ConfigureAwait(false))
{
await responseStream.WriteAsync(message).ConfigureAwait(false);
await responseStream.WriteAsync(message, context.CancellationToken).ConfigureAwait(false);
}
}
catch (AlarmFeedSubscriberOverflowException overflow)
{
// The monitor dropped this subscriber for falling behind its bounded queue. That is a
// client-side slowness condition with a client-side remedy (reconnect and
// re-snapshot), so it maps to ResourceExhausted like a StreamEvents overflow rather
// than reaching MapException's warning-with-stack + Unavailable fallback.
throw new RpcException(new Status(StatusCode.ResourceExhausted, overflow.Message));
}
catch (Exception exception) when (exception is not RpcException)
{
if (IsClientAbort(exception, context))
{
throw ClientCancelled();
}
throw MapException(exception);
}
}
@@ -264,6 +304,17 @@ public sealed class MxAccessGatewayService(
}
}
// A response write fails with an IOException (or an operation-cancelled exception) when the
// client disconnects mid-stream. With the call token already cancelled that is a routine
// client abort, not a gateway fault: it maps to Cancelled with no warning. The same exception
// on a live call token is a genuine transport failure and keeps the MapException path.
private static bool IsClientAbort(Exception exception, ServerCallContext context) =>
context.CancellationToken.IsCancellationRequested
&& exception is IOException or OperationCanceledException;
private static RpcException ClientCancelled() =>
new(new Status(StatusCode.Cancelled, "gRPC request was canceled."));
private string? ResolveClientIdentity()
{
return identityAccessor.Current?.DisplayName ?? identityAccessor.Current?.KeyId;
@@ -488,6 +539,7 @@ public sealed class MxAccessGatewayService(
Dictionary<int, SubscribeResult> denied = [];
List<string> allowed = [];
ConstraintDenialSummary? denialSummary = null;
for (int index = 0; index < tagAddresses.Count; index++)
{
string tagAddress = tagAddresses[index];
@@ -500,8 +552,7 @@ public sealed class MxAccessGatewayService(
continue;
}
await constraintEnforcer.RecordDenialAsync(identity, command.Kind.ToString(), tagAddress, failure, correlationId, cancellationToken)
.ConfigureAwait(false);
(denialSummary ??= new ConstraintDenialSummary(tagAddresses.Count)).Add(tagAddress, failure);
denied[index] = new SubscribeResult
{
ServerHandle = serverHandle,
@@ -511,11 +562,17 @@ public sealed class MxAccessGatewayService(
};
}
if (denied.Count == 0)
if (denialSummary is null)
{
return null;
}
// One aggregated audit event per bulk command, not one per denied item (see
// ConstraintDenialSummary for why).
await constraintEnforcer
.RecordBulkDenialAsync(identity, command.Kind.ToString(), denialSummary, correlationId, cancellationToken)
.ConfigureAwait(false);
// Build the filtered command directly instead of cloning the original and clearing it:
// the clone deep-copied every denied address only to drop it. The payload's other fields
// (server_handle) are copied across explicitly. Nothing aliases the request here — these
@@ -556,6 +613,7 @@ public sealed class MxAccessGatewayService(
Dictionary<int, BulkReadResult> denied = [];
List<string> allowed = [];
ConstraintDenialSummary? denialSummary = null;
for (int index = 0; index < tagAddresses.Count; index++)
{
string tagAddress = tagAddresses[index];
@@ -568,8 +626,7 @@ public sealed class MxAccessGatewayService(
continue;
}
await constraintEnforcer.RecordDenialAsync(identity, command.Kind.ToString(), tagAddress, failure, correlationId, cancellationToken)
.ConfigureAwait(false);
(denialSummary ??= new ConstraintDenialSummary(tagAddresses.Count)).Add(tagAddress, failure);
denied[index] = new BulkReadResult
{
ServerHandle = serverHandle,
@@ -580,11 +637,17 @@ public sealed class MxAccessGatewayService(
};
}
if (denied.Count == 0)
if (denialSummary is null)
{
return null;
}
// One aggregated audit event per bulk command, not one per denied item (see
// ConstraintDenialSummary for why).
await constraintEnforcer
.RecordBulkDenialAsync(identity, command.Kind.ToString(), denialSummary, correlationId, cancellationToken)
.ConfigureAwait(false);
MxCommand filtered = new() { Kind = command.Kind };
ReadBulkCommand payload = new()
{
@@ -621,6 +684,7 @@ public sealed class MxAccessGatewayService(
Dictionary<int, BulkWriteResult> denied = [];
List<TEntry> allowed = [];
ConstraintDenialSummary? denialSummary = null;
for (int index = 0; index < entries.Count; index++)
{
TEntry entry = entries[index];
@@ -634,14 +698,7 @@ public sealed class MxAccessGatewayService(
continue;
}
await constraintEnforcer.RecordDenialAsync(
identity,
command.Kind.ToString(),
itemHandle.ToString(System.Globalization.CultureInfo.InvariantCulture),
failure,
correlationId,
cancellationToken)
.ConfigureAwait(false);
(denialSummary ??= new ConstraintDenialSummary(entries.Count)).Add(itemHandle, failure);
denied[index] = new BulkWriteResult
{
ServerHandle = serverHandle,
@@ -651,11 +708,17 @@ public sealed class MxAccessGatewayService(
};
}
if (denied.Count == 0)
if (denialSummary is null)
{
return null;
}
// One aggregated audit event per bulk command, not one per denied item (see
// ConstraintDenialSummary for why).
await constraintEnforcer
.RecordBulkDenialAsync(identity, command.Kind.ToString(), denialSummary, correlationId, cancellationToken)
.ConfigureAwait(false);
return new WriteBulkConstraintPlan(
BuildFilteredWriteBulkCommand(command, allowed),
entries.Count,
@@ -742,6 +805,7 @@ public sealed class MxAccessGatewayService(
Dictionary<int, SubscribeResult> denied = [];
List<int> allowed = [];
ConstraintDenialSummary? denialSummary = null;
for (int index = 0; index < itemHandles.Count; index++)
{
int itemHandle = itemHandles[index];
@@ -754,8 +818,7 @@ public sealed class MxAccessGatewayService(
continue;
}
await constraintEnforcer.RecordDenialAsync(identity, command.Kind.ToString(), itemHandle.ToString(System.Globalization.CultureInfo.InvariantCulture), failure, correlationId, cancellationToken)
.ConfigureAwait(false);
(denialSummary ??= new ConstraintDenialSummary(itemHandles.Count)).Add(itemHandle, failure);
denied[index] = new SubscribeResult
{
ServerHandle = serverHandle,
@@ -765,11 +828,17 @@ public sealed class MxAccessGatewayService(
};
}
if (denied.Count == 0)
if (denialSummary is null)
{
return null;
}
// One aggregated audit event per bulk command, not one per denied item (see
// ConstraintDenialSummary for why).
await constraintEnforcer
.RecordBulkDenialAsync(identity, command.Kind.ToString(), denialSummary, correlationId, cancellationToken)
.ConfigureAwait(false);
MxCommand filtered = new() { Kind = command.Kind };
AdviseItemBulkCommand payload = new() { ServerHandle = command.AdviseItemBulk.ServerHandle };
payload.ItemHandles.Add(allowed);
@@ -0,0 +1,28 @@
using System.Collections.Frozen;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Server.Metrics;
/// <summary>
/// Cached <see cref="MxEventFamily"/> names for the per-event metric tags.
/// </summary>
/// <remarks>
/// <see cref="Enum.ToString()"/> allocates a new string on every call, and the event-received
/// and stream-send metrics tag every event with its family — once per event at the worker
/// boundary and once per event per subscriber on the stream. The names are fixed by the
/// contract, so they are computed once here. A value the contract does not define falls back
/// to <see cref="Enum.ToString()"/>, which is the same string the uncached call produced.
/// </remarks>
public static class MxEventFamilyNames
{
private static readonly FrozenDictionary<MxEventFamily, string> Names =
Enum.GetValues<MxEventFamily>()
.Distinct()
.ToFrozenDictionary(family => family, family => family.ToString());
/// <summary>Gets the name of an event family.</summary>
/// <param name="family">The event family.</param>
/// <returns>The family's name, identical to <see cref="Enum.ToString()"/>.</returns>
public static string Get(MxEventFamily family) =>
Names.TryGetValue(family, out string? name) ? name : family.ToString();
}
@@ -58,6 +58,19 @@ public interface IApiKeyCacheInvalidator
/// window and it is intentionally kept short (<see cref="SecurityOptions.ApiKeyVerificationCacheSeconds"/>,
/// default 15 s).
/// </para>
/// <para>
/// Misses are single-flight: concurrent misses for the same token and the same key generation share
/// one inner verification, so a TTL expiry under load costs one SQLite read rather than one per
/// in-flight RPC. A miss that starts after an <see cref="Invalidate"/> never joins a flight that
/// started before it (the flight is keyed on the generation too), so the revoke guarantee holds for
/// joiners as well. The shared inner call is not bound to any one caller's cancellation token; each
/// caller stops waiting on its own token without cancelling the others.
/// </para>
/// <para>
/// The key-id index is pruned by a post-eviction callback whenever the cache drops an entry on its
/// own (expiry, compaction), not only by <see cref="Invalidate"/>, so it stays proportional to the
/// live cache rather than to every token ever verified.
/// </para>
/// </remarks>
public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalidator
{
@@ -69,9 +82,20 @@ public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalid
private readonly TimeSpan _ttl;
// keyId -> set of cache keys currently held for that id, so a revoke/rotate can evict every
// secret variant (e.g. an old secret still within TTL after a rotate).
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _keyIdIndex =
new(StringComparer.Ordinal);
// secret variant (e.g. an old secret still within TTL after a rotate). Guarded by _indexSync,
// which also covers the cache Set that an index entry describes: the post-eviction callback
// runs asynchronously, so Set + index and check-cache + unindex must each be atomic with
// respect to the other, or a late callback could unindex a freshly re-cached entry and leave
// it out of reach of a later Invalidate. Every operation under the lock is O(1) and the lock is
// only taken on misses, evictions and invalidations — never on a cache hit.
private readonly object _indexSync = new();
private readonly Dictionary<string, HashSet<string>> _keyIdIndex = new(StringComparer.Ordinal);
// (cache key, key generation) -> the in-flight inner verification for that pair (single-flight).
private readonly ConcurrentDictionary<(string CacheKey, long Generation), Lazy<Task<ApiKeyVerification>>> _inflight =
new();
private readonly PostEvictionDelegate _onEvicted;
// keyId -> monotonic generation, bumped by Invalidate before it evicts. A VerifyAsync snapshots
// the generation before calling the inner verifier and only caches if it is unchanged after the
@@ -103,6 +127,37 @@ public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalid
_inner = inner;
_cache = cache;
_ttl = ttl;
_onEvicted = OnCacheEntryEvicted;
}
/// <summary>Gets the number of cache keys currently held in the key-id index (test seam).</summary>
internal int IndexedCacheKeyCount
{
get
{
lock (_indexSync)
{
int count = 0;
foreach (HashSet<string> set in _keyIdIndex.Values)
{
count += set.Count;
}
return count;
}
}
}
/// <summary>Gets the number of key ids currently present in the key-id index (test seam).</summary>
internal int IndexedKeyIdCount
{
get
{
lock (_indexSync)
{
return _keyIdIndex.Count;
}
}
}
/// <summary>
@@ -126,11 +181,50 @@ public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalid
}
// Snapshot the key's generation BEFORE the inner verify so a concurrent Invalidate that runs
// while we are in the library is detected below and its repopulation discarded.
// while we are in the library is detected and its repopulation discarded. The snapshot is
// also half of the single-flight key, so a miss that starts after an Invalidate cannot join
// a flight that started before it.
string? tokenKeyId = TryParseKeyId(authorizationHeader);
long generationAtStart = tokenKeyId is null ? 0 : ReadGeneration(tokenKeyId);
(string CacheKey, long Generation) flightKey = (cacheKey, generationAtStart);
ApiKeyVerification result = await _inner.VerifyAsync(authorizationHeader, ct).ConfigureAwait(false);
Lazy<Task<ApiKeyVerification>> created = new(
() => VerifyMissAsync(authorizationHeader, cacheKey, tokenKeyId, generationAtStart));
Lazy<Task<ApiKeyVerification>> flight = _inflight.GetOrAdd(flightKey, created);
Task<ApiKeyVerification> verification = flight.Value;
if (ReferenceEquals(flight, created))
{
// The flight's creator retires it once it completes (success, failure or fault), so the
// next miss for this token starts fresh. Removing by (key, value) pair never retires a
// newer flight that replaced this one.
_ = verification.ContinueWith(
static (_, state) =>
{
var (owner, key, completed) =
((CachingApiKeyVerifier, (string, long), Lazy<Task<ApiKeyVerification>>))state!;
owner._inflight.TryRemove(
new KeyValuePair<(string CacheKey, long Generation), Lazy<Task<ApiKeyVerification>>>(key, completed));
},
(this, flightKey, flight),
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
}
return await verification.WaitAsync(ct).ConfigureAwait(false);
}
// The shared body of one single-flight miss. Not bound to any caller's token: a caller that
// gives up must not cancel the verification other callers are waiting on.
private async Task<ApiKeyVerification> VerifyMissAsync(
string authorizationHeader,
string cacheKey,
string? tokenKeyId,
long generationAtStart)
{
ApiKeyVerification result = await _inner
.VerifyAsync(authorizationHeader, CancellationToken.None)
.ConfigureAwait(false);
if (result.Succeeded && result.Identity is not null)
{
@@ -158,11 +252,21 @@ public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalid
}
string keyId = result.Identity!.KeyId;
_cache.Set(cacheKey, result, new MemoryCacheEntryOptions
MemoryCacheEntryOptions entryOptions = new()
{
AbsoluteExpirationRelativeToNow = _ttl,
};
entryOptions.PostEvictionCallbacks.Add(new PostEvictionCallbackRegistration
{
EvictionCallback = _onEvicted,
State = keyId,
});
IndexCacheKey(keyId, cacheKey);
lock (_indexSync)
{
_cache.Set(cacheKey, result, entryOptions);
IndexCacheKey(keyId, cacheKey);
}
// Set-then-recheck: an Invalidate that landed between the pre-Set check and IndexCacheKey
// would have missed this entry (it was not yet indexed). If the generation has moved, evict
@@ -170,13 +274,38 @@ public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalid
if (tokenKeyId is not null && ReadGeneration(tokenKeyId) != generationAtStart)
{
_cache.Remove(cacheKey);
if (_keyIdIndex.TryGetValue(keyId, out ConcurrentDictionary<string, byte>? set))
lock (_indexSync)
{
set.TryRemove(cacheKey, out _);
UnindexCacheKey(keyId, cacheKey);
}
}
}
// Post-eviction callback: drops the evicted entry's key-id index slot so the index tracks the
// live cache. MemoryCache raises it asynchronously, so by the time it runs the same cache key
// may already hold a newer entry (re-verified after the expiry); that entry owns the slot, and
// the check below runs under the lock that also covers Set + index, so it cannot interleave
// with a re-cache.
private void OnCacheEntryEvicted(object key, object? value, EvictionReason reason, object? state)
{
if (reason == EvictionReason.Replaced || key is not string cacheKey || state is not string keyId)
{
// Replaced: a newer entry under the same key was Set in this entry's place and is
// indexed under the same slot already.
return;
}
lock (_indexSync)
{
if (_cache.TryGetValue(cacheKey, out _))
{
return;
}
UnindexCacheKey(keyId, cacheKey);
}
}
/// <inheritdoc />
public void Invalidate(string keyId)
{
@@ -189,12 +318,22 @@ public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalid
// generation refuses to cache (or self-evicts) its now-stale repopulation.
_generations.AddOrUpdate(keyId, 1, static (_, current) => current + 1);
if (_keyIdIndex.TryRemove(keyId, out ConcurrentDictionary<string, byte>? cacheKeys))
string[] cacheKeys;
lock (_indexSync)
{
foreach (string cacheKey in cacheKeys.Keys)
if (!_keyIdIndex.Remove(keyId, out HashSet<string>? indexed))
{
_cache.Remove(cacheKey);
return;
}
cacheKeys = [.. indexed];
}
// Evicted outside the lock: the eviction callbacks these raise take it themselves (and find
// the slot already gone).
foreach (string cacheKey in cacheKeys)
{
_cache.Remove(cacheKey);
}
}
@@ -257,12 +396,28 @@ public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalid
return new string(afterPrefix[..separator]);
}
// Caller holds _indexSync.
private void IndexCacheKey(string keyId, string cacheKey)
{
ConcurrentDictionary<string, byte> set = _keyIdIndex.GetOrAdd(
keyId,
static _ => new ConcurrentDictionary<string, byte>(StringComparer.Ordinal));
set.TryAdd(cacheKey, 0);
if (!_keyIdIndex.TryGetValue(keyId, out HashSet<string>? set))
{
set = new HashSet<string>(StringComparer.Ordinal);
_keyIdIndex[keyId] = set;
}
set.Add(cacheKey);
}
// Caller holds _indexSync. Drops the key id's slot entirely once its last cache key goes, so
// the index does not accumulate empty sets for keys that are no longer cached.
private void UnindexCacheKey(string keyId, string cacheKey)
{
if (_keyIdIndex.TryGetValue(keyId, out HashSet<string>? set)
&& set.Remove(cacheKey)
&& set.Count == 0)
{
_keyIdIndex.Remove(keyId);
}
}
private static bool TryComputeCacheKey(string? authorizationHeader, out string cacheKey)
@@ -0,0 +1,90 @@
using System.Globalization;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
/// <summary>
/// Accumulates the constraint denials of one bulk command so they are audited as a single
/// aggregated event rather than one event per denied item.
/// </summary>
/// <remarks>
/// A bulk command can carry thousands of items, and the audit writer is a bounded, drop-on-full
/// channel. One audit event per denied item let a single low-privilege key flood that channel and
/// push every other audit record out of it. The summary keeps the forensic signal — how many items
/// were denied, which constraints denied them, and a bounded sample of the targets — at a fixed
/// cost of one audit event per command.
/// </remarks>
public sealed class ConstraintDenialSummary
{
/// <summary>The maximum number of denied targets retained as a sample.</summary>
public const int MaxSampleTargets = 20;
private readonly List<string> _constraintNames = [];
private readonly List<string> _sampleTargets = [];
/// <summary>Initializes a new instance of the <see cref="ConstraintDenialSummary"/> class.</summary>
/// <param name="totalCount">The number of items the bulk command carried.</param>
public ConstraintDenialSummary(int totalCount)
{
TotalCount = totalCount;
}
/// <summary>Gets the number of items the bulk command carried.</summary>
public int TotalCount { get; }
/// <summary>Gets the number of items denied.</summary>
public int DeniedCount { get; private set; }
/// <summary>Gets the distinct constraint names that denied at least one item, in first-seen order.</summary>
public IReadOnlyList<string> ConstraintNames => _constraintNames;
/// <summary>Gets up to <see cref="MaxSampleTargets"/> denied targets, in item order.</summary>
public IReadOnlyList<string> SampleTargets => _sampleTargets;
/// <summary>Gets a value indicating whether more items were denied than the sample retains.</summary>
public bool SampleTruncated => DeniedCount > _sampleTargets.Count;
/// <summary>Records one denied tag-address item.</summary>
/// <param name="target">The denied tag address.</param>
/// <param name="failure">The constraint failure that denied it.</param>
public void Add(string target, ConstraintFailure failure)
{
ArgumentNullException.ThrowIfNull(failure);
AddConstraint(failure.ConstraintName);
if (_sampleTargets.Count < MaxSampleTargets)
{
_sampleTargets.Add(target);
}
DeniedCount++;
}
/// <summary>Records one denied item-handle item.</summary>
/// <param name="itemHandle">The denied MXAccess item handle.</param>
/// <param name="failure">The constraint failure that denied it.</param>
/// <remarks>The handle is formatted only while the sample still has room.</remarks>
public void Add(int itemHandle, ConstraintFailure failure)
{
ArgumentNullException.ThrowIfNull(failure);
AddConstraint(failure.ConstraintName);
if (_sampleTargets.Count < MaxSampleTargets)
{
_sampleTargets.Add(itemHandle.ToString(CultureInfo.InvariantCulture));
}
DeniedCount++;
}
private void AddConstraint(string constraintName)
{
// The constraint vocabulary is a handful of fixed names, so a linear scan beats a set.
for (int index = 0; index < _constraintNames.Count; index++)
{
if (string.Equals(_constraintNames[index], constraintName, StringComparison.Ordinal))
{
return;
}
}
_constraintNames.Add(constraintName);
}
}
@@ -25,7 +25,7 @@ public sealed class ConstraintEnforcer(
identity?.EffectiveConstraints.HasWriteConstraints ?? false;
/// <inheritdoc />
public Task<ConstraintFailure?> CheckReadTagAsync(
public ValueTask<ConstraintFailure?> CheckReadTagAsync(
ApiKeyIdentity? identity,
string tagAddress,
CancellationToken cancellationToken)
@@ -33,14 +33,14 @@ public sealed class ConstraintEnforcer(
ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
if (!constraints.HasReadConstraints)
{
return Task.FromResult<ConstraintFailure?>(null);
return ValueTask.FromResult<ConstraintFailure?>(null);
}
return Task.FromResult(CheckReadTarget(constraints, tagAddress));
return ValueTask.FromResult(CheckReadTarget(constraints, tagAddress));
}
/// <inheritdoc />
public Task<ConstraintFailure?> CheckReadHandleAsync(
public ValueTask<ConstraintFailure?> CheckReadHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
int serverHandle,
@@ -50,19 +50,19 @@ public sealed class ConstraintEnforcer(
ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
if (!constraints.HasReadConstraints)
{
return Task.FromResult<ConstraintFailure?>(null);
return ValueTask.FromResult<ConstraintFailure?>(null);
}
if (!session.TryGetItemRegistration(serverHandle, itemHandle, out SessionItemRegistration registration))
{
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("item_handle", "Item handle is not registered in the constrained session."));
return ValueTask.FromResult<ConstraintFailure?>(new ConstraintFailure("item_handle", "Item handle is not registered in the constrained session."));
}
return Task.FromResult(CheckReadTarget(constraints, registration.TagAddress));
return ValueTask.FromResult(CheckReadTarget(constraints, registration.TagAddress));
}
/// <inheritdoc />
public Task<ConstraintFailure?> CheckWriteHandleAsync(
public ValueTask<ConstraintFailure?> CheckWriteHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
int serverHandle,
@@ -72,23 +72,23 @@ public sealed class ConstraintEnforcer(
ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
if (!constraints.HasWriteConstraints)
{
return Task.FromResult<ConstraintFailure?>(null);
return ValueTask.FromResult<ConstraintFailure?>(null);
}
if (!session.TryGetItemRegistration(serverHandle, itemHandle, out SessionItemRegistration registration))
{
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("item_handle", "Item handle is not registered in the constrained session."));
return ValueTask.FromResult<ConstraintFailure?>(new ConstraintFailure("item_handle", "Item handle is not registered in the constrained session."));
}
GalaxyTagLookup? target = ResolveTarget(registration.TagAddress);
if (target is null)
{
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("tag_metadata", "Tag metadata is not available in the Galaxy hierarchy cache."));
return ValueTask.FromResult<ConstraintFailure?>(new ConstraintFailure("tag_metadata", "Tag metadata is not available in the Galaxy hierarchy cache."));
}
if (!MatchesPathOrTag(target.ContainedPath, registration.TagAddress, constraints.WriteSubtrees, constraints.WriteTagGlobs))
{
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("write_scope", "Tag is outside the API key write scope."));
return ValueTask.FromResult<ConstraintFailure?>(new ConstraintFailure("write_scope", "Tag is outside the API key write scope."));
}
if (constraints.MaxWriteClassification is { } maxClassification)
@@ -96,18 +96,18 @@ public sealed class ConstraintEnforcer(
GalaxyAttribute? attribute = target.Attribute;
if (attribute is null)
{
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("max_write_classification", "Attribute security classification is not available."));
return ValueTask.FromResult<ConstraintFailure?>(new ConstraintFailure("max_write_classification", "Attribute security classification is not available."));
}
if (attribute.SecurityClassification > maxClassification)
{
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure(
return ValueTask.FromResult<ConstraintFailure?>(new ConstraintFailure(
"max_write_classification",
$"Attribute security classification {attribute.SecurityClassification} exceeds allowed maximum {maxClassification}."));
}
}
return Task.FromResult<ConstraintFailure?>(null);
return ValueTask.FromResult<ConstraintFailure?>(null);
}
/// <inheritdoc />
@@ -150,6 +150,46 @@ public sealed class ConstraintEnforcer(
await auditWriter.WriteAsync(auditEvent, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task RecordBulkDenialAsync(
ApiKeyIdentity? identity,
string commandKind,
ConstraintDenialSummary summary,
string? correlationId,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(summary);
if (summary.DeniedCount == 0)
{
return;
}
// One event for the whole command. Target stays "<commandKind>:<target>"-shaped so audit
// queries that split on ':' keep working; the "bulk" target marks the aggregated form.
AuditEvent auditEvent = new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = identity?.KeyId ?? "anonymous",
Action = "constraint-denied",
Outcome = AuditOutcome.Denied,
Category = CanonicalForwardingApiKeyAuditStore.ApiKeyCategory,
Target = $"{commandKind}:bulk",
SourceNode = null,
CorrelationId = Guid.TryParse(correlationId, out var cid) ? cid : (Guid?)null,
DetailsJson = JsonSerializer.Serialize(new BulkDenialDetails(
summary.ConstraintNames,
commandKind,
summary.DeniedCount,
summary.TotalCount,
summary.SampleTargets,
summary.SampleTruncated,
correlationId ?? "")),
};
await auditWriter.WriteAsync(auditEvent, cancellationToken).ConfigureAwait(false);
}
private ConstraintFailure? CheckReadTarget(
ApiKeyConstraints constraints,
string tagAddress)
@@ -240,4 +280,15 @@ public sealed class ConstraintEnforcer(
return false;
}
// DetailsJson shape of an aggregated bulk denial. Serialized with camelCase names to match the
// single-item envelope's keys ("commandKind", "clientCorrelationId").
private sealed record BulkDenialDetails(
[property: System.Text.Json.Serialization.JsonPropertyName("constraints")] IReadOnlyList<string> Constraints,
[property: System.Text.Json.Serialization.JsonPropertyName("commandKind")] string CommandKind,
[property: System.Text.Json.Serialization.JsonPropertyName("deniedCount")] int DeniedCount,
[property: System.Text.Json.Serialization.JsonPropertyName("totalCount")] int TotalCount,
[property: System.Text.Json.Serialization.JsonPropertyName("sampleTargets")] IReadOnlyList<string> SampleTargets,
[property: System.Text.Json.Serialization.JsonPropertyName("sampleTruncated")] bool SampleTruncated,
[property: System.Text.Json.Serialization.JsonPropertyName("clientCorrelationId")] string ClientCorrelationId);
}
@@ -30,7 +30,10 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
ApiKeyIdentity? identity = await AuthenticateAndAuthorizeAsync(request, context).ConfigureAwait(false);
ApiKeyIdentity? identity = await AuthenticateAndAuthorizeAsync(
scopeResolver.ResolveRequiredScope(request),
context)
.ConfigureAwait(false);
IDisposable? identityScope = identity is null ? null : identityAccessor.Push(identity);
using (identityScope)
{
@@ -45,7 +48,10 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
ServerCallContext context,
ServerStreamingServerMethod<TRequest, TResponse> continuation)
{
ApiKeyIdentity? identity = await AuthenticateAndAuthorizeAsync(request, context).ConfigureAwait(false);
ApiKeyIdentity? identity = await AuthenticateAndAuthorizeAsync(
scopeResolver.ResolveRequiredScope(request),
context)
.ConfigureAwait(false);
IDisposable? identityScope = identity is null ? null : identityAccessor.Push(identity);
using (identityScope)
{
@@ -53,15 +59,51 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
}
}
// The base Interceptor passes client- and duplex-streaming calls straight to the handler, so
// without these overrides any such RPC (none exist today, but a .proto edit could add one, and
// the shared Galaxy Repository service is mapped through the same interceptor) would be
// reachable with no API key at all. Both run the identical authentication path. There is no
// single request message to derive a scope from before the handler reads the stream, so they
// fail closed to the scope the resolver assigns an unrecognized request: admin.
/// <inheritdoc />
public override async Task<TResponse> ClientStreamingServerHandler<TRequest, TResponse>(
IAsyncStreamReader<TRequest> requestStream,
ServerCallContext context,
ClientStreamingServerMethod<TRequest, TResponse> continuation)
{
ApiKeyIdentity? identity = await AuthenticateAndAuthorizeAsync(GatewayScopes.Admin, context)
.ConfigureAwait(false);
IDisposable? identityScope = identity is null ? null : identityAccessor.Push(identity);
using (identityScope)
{
return await continuation(requestStream, context).ConfigureAwait(false);
}
}
/// <inheritdoc />
public override async Task DuplexStreamingServerHandler<TRequest, TResponse>(
IAsyncStreamReader<TRequest> requestStream,
IServerStreamWriter<TResponse> responseStream,
ServerCallContext context,
DuplexStreamingServerMethod<TRequest, TResponse> continuation)
{
ApiKeyIdentity? identity = await AuthenticateAndAuthorizeAsync(GatewayScopes.Admin, context)
.ConfigureAwait(false);
IDisposable? identityScope = identity is null ? null : identityAccessor.Push(identity);
using (identityScope)
{
await continuation(requestStream, responseStream, context).ConfigureAwait(false);
}
}
/// <summary>Authenticates the API key and authorizes the RPC call by required scope.</summary>
/// <typeparam name="TRequest">Request message type.</typeparam>
/// <param name="request">Request payload.</param>
/// <param name="requiredScope">The scope the call requires, resolved by the caller.</param>
/// <param name="context">RPC server call context.</param>
/// <returns>Authenticated API key identity, or null if authentication is disabled.</returns>
private async Task<ApiKeyIdentity?> AuthenticateAndAuthorizeAsync<TRequest>(
TRequest request,
private async Task<ApiKeyIdentity?> AuthenticateAndAuthorizeAsync(
string requiredScope,
ServerCallContext context)
where TRequest : class
{
if (options.Value.Authentication.Mode == AuthenticationMode.Disabled)
{
@@ -116,7 +158,6 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
ApiKeyIdentity identity = GatewayApiKeyIdentityMapper.ToGatewayIdentity(verification.Identity);
string requiredScope = scopeResolver.ResolveRequiredScope(request);
if (!identity.Scopes.Contains(requiredScope))
{
throw new RpcException(new Status(
@@ -3,6 +3,13 @@ using ZB.MOM.WW.MxGateway.Server.Sessions;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
/// <summary>Enforces API-key read/write constraints and records constraint denials for audit.</summary>
/// <remarks>
/// The per-item checks return <see cref="ValueTask{TResult}"/> because every production check is
/// synchronous (an in-memory Galaxy hierarchy lookup) and runs once per item of a bulk command:
/// a <see cref="Task{TResult}"/> return allocated a task per item for a value that was already
/// known. An implementation that genuinely needs to await still can.
/// </remarks>
public interface IConstraintEnforcer
{
/// <summary>
@@ -34,7 +41,7 @@ public interface IConstraintEnforcer
/// <param name="tagAddress">Tag address to check.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>The constraint failure details if denied; otherwise null.</returns>
Task<ConstraintFailure?> CheckReadTagAsync(
ValueTask<ConstraintFailure?> CheckReadTagAsync(
ApiKeyIdentity? identity,
string tagAddress,
CancellationToken cancellationToken);
@@ -46,7 +53,7 @@ public interface IConstraintEnforcer
/// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>The constraint failure details if denied; otherwise null.</returns>
Task<ConstraintFailure?> CheckReadHandleAsync(
ValueTask<ConstraintFailure?> CheckReadHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
int serverHandle,
@@ -60,7 +67,7 @@ public interface IConstraintEnforcer
/// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>The constraint failure details if denied; otherwise null.</returns>
Task<ConstraintFailure?> CheckWriteHandleAsync(
ValueTask<ConstraintFailure?> CheckWriteHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
int serverHandle,
@@ -85,4 +92,25 @@ public interface IConstraintEnforcer
ConstraintFailure failure,
string? correlationId,
CancellationToken cancellationToken);
/// <summary>
/// Records every constraint denial of one bulk command as a single aggregated audit event.
/// </summary>
/// <param name="identity">The API key identity.</param>
/// <param name="commandKind">The kind of bulk command whose items were denied.</param>
/// <param name="summary">The accumulated denials: count, constraint names and a bounded target sample.</param>
/// <param name="correlationId">The per-request client correlation id, if any (same handling as <see cref="RecordDenialAsync"/>).</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
/// <remarks>
/// Bulk callers use this instead of one <see cref="RecordDenialAsync"/> per item: the audit
/// channel is bounded and drops on full, so per-item events let one bulk command evict every
/// other pending audit record.
/// </remarks>
Task RecordBulkDenialAsync(
ApiKeyIdentity? identity,
string commandKind,
ConstraintDenialSummary summary,
string? correlationId,
CancellationToken cancellationToken);
}
@@ -0,0 +1,222 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Metrics;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
/// <summary>Names shared by the optional <c>/metrics</c> authentication (<c>MxGateway:Metrics:RequireAuthentication</c>).</summary>
public static class MetricsAuthorizationDefaults
{
/// <summary>Authentication scheme that authenticates a metrics scrape.</summary>
public const string AuthenticationScheme = "MxGateway.Metrics";
/// <summary>Authorization policy attached to <c>/metrics</c> when authentication is required.</summary>
public const string Policy = "MxGateway.Metrics";
/// <summary>Claim type carrying one API-key scope on a principal the metrics scheme authenticated.</summary>
public const string ScopeClaimType = "mxgateway:scope";
}
/// <summary>
/// Authenticates a <c>/metrics</c> scrape from either credential the gateway already issues: an API
/// key presented as <c>Authorization: Bearer mxgw_…</c> (verified through the same cached
/// <see cref="IApiKeyVerifier"/> and per-peer failure limiter as gRPC calls), or, when no
/// authorization header is sent, the dashboard login cookie.
/// </summary>
/// <remarks>
/// One scheme fronting both credentials keeps the challenge a plain <c>401</c> with
/// <c>WWW-Authenticate: Bearer</c>, which is what a Prometheus scraper understands; listing the
/// cookie scheme on the policy directly would turn an anonymous scrape into a 302 redirect to the
/// login page. A throttled key partition answers <c>429</c>, mirroring the gRPC
/// <c>ResourceExhausted</c>. Failure reasons are never echoed to the caller and the presented
/// secret is never logged. The limiter's per-peer partition here is the remote IP, whereas the gRPC
/// interceptor's is the gRPC peer string (address and port, i.e. per connection), so the two paths
/// share only the per-key-id aggregate layer.
/// </remarks>
public sealed class MetricsAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IApiKeyVerifier apiKeyVerifier,
ApiKeyFailureLimiter failureLimiter,
IAuthenticationSchemeProvider schemeProvider,
IOptions<GatewayOptions> gatewayOptions,
GatewayMetrics metrics)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
private bool _throttled;
/// <inheritdoc />
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
string? authorizationHeader = Request.Headers.Authorization;
if (string.IsNullOrWhiteSpace(authorizationHeader))
{
return await AuthenticateDashboardCookieAsync().ConfigureAwait(false);
}
// Same throttle as the gRPC interceptor: checked before the store read so online guessing
// against /metrics cannot spend a verification per attempt or bypass the gRPC limiter.
ApiKeyThrottlePartition partition = new(
Context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
GatewayGrpcAuthorizationInterceptor.TryResolveKeyId(authorizationHeader));
ApiKeyThrottleDecision decision = failureLimiter.Check(
partition,
out ApiKeyFailureLimiter.PartitionResolution resolution);
if (decision is ApiKeyThrottleDecision.ThrottledByPeer or ApiKeyThrottleDecision.ThrottledByAggregate)
{
_throttled = true;
metrics.RecordAuthThrottled(decision == ApiKeyThrottleDecision.ThrottledByPeer ? "peer" : "aggregate");
return AuthenticateResult.Fail("Too many authentication attempts.");
}
ApiKeyVerification verification = await apiKeyVerifier
.VerifyAsync(authorizationHeader, Context.RequestAborted)
.ConfigureAwait(false);
if (!verification.Succeeded || verification.Identity is null)
{
failureLimiter.RecordFailure(partition);
return AuthenticateResult.Fail("Missing or invalid API key.");
}
failureLimiter.Reset(partition, resolution);
List<Claim> claims = [new Claim(ClaimTypes.Name, verification.Identity.KeyId)];
foreach (string scope in verification.Identity.Scopes)
{
claims.Add(new Claim(MetricsAuthorizationDefaults.ScopeClaimType, scope));
}
ClaimsPrincipal principal = new(new ClaimsIdentity(claims, Scheme.Name));
return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name));
}
/// <inheritdoc />
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
if (_throttled)
{
Response.StatusCode = StatusCodes.Status429TooManyRequests;
return Task.CompletedTask;
}
Response.StatusCode = StatusCodes.Status401Unauthorized;
Response.Headers.WWWAuthenticate = "Bearer";
return Task.CompletedTask;
}
// No authorization header: fall back to the dashboard login cookie, so an operator already
// signed in to the dashboard can open /metrics in the same browser.
private async Task<AuthenticateResult> AuthenticateDashboardCookieAsync()
{
// Dashboard:DisableLogin registers an always-succeeding auto-login handler under the cookie
// scheme name. Falling back to it would let every anonymous request satisfy /metrics, so
// with DisableLogin on, RequireAuthentication=true accepts API keys only.
if (gatewayOptions.Value.Dashboard.DisableLogin)
{
return AuthenticateResult.NoResult();
}
if (await schemeProvider.GetSchemeAsync(DashboardAuthenticationDefaults.AuthenticationScheme).ConfigureAwait(false)
is null)
{
return AuthenticateResult.NoResult();
}
AuthenticateResult cookie = await Context
.AuthenticateAsync(DashboardAuthenticationDefaults.AuthenticationScheme)
.ConfigureAwait(false);
return cookie.Succeeded && cookie.Principal is not null
? AuthenticateResult.Success(new AuthenticationTicket(cookie.Principal, Scheme.Name))
: AuthenticateResult.NoResult();
}
}
/// <summary>Requirement for <c>/metrics</c> access; see <see cref="MetricsAccessHandler"/>.</summary>
public sealed class MetricsAccessRequirement : IAuthorizationRequirement
{
}
/// <summary>
/// Grants <c>/metrics</c> to an API key carrying the <c>admin</c> scope or to a dashboard login
/// holding a real dashboard role (Administrator or Viewer). It reads no options, so neither
/// anonymous localhost nor <c>Authentication:Mode=Disabled</c> can satisfy it.
/// </summary>
/// <remarks>
/// A Viewer login qualifies because every dashboard role already sees the same aggregate counters
/// on the dashboard itself; the metrics carry no per-session or per-tag data. An API key needs
/// <c>admin</c> because metrics are an operator surface, not one of the data-plane scopes a client
/// key is normally issued with.
/// </remarks>
public sealed class MetricsAccessHandler : AuthorizationHandler<MetricsAccessRequirement>
{
/// <inheritdoc />
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
MetricsAccessRequirement requirement)
{
foreach (ClaimsIdentity identity in context.User.Identities)
{
if (!identity.IsAuthenticated)
{
continue;
}
// Scope claims count only on an identity the metrics scheme minted from a verified key.
bool isApiKeyIdentity = string.Equals(
identity.AuthenticationType,
MetricsAuthorizationDefaults.AuthenticationScheme,
StringComparison.Ordinal);
if (isApiKeyIdentity
&& identity.HasClaim(MetricsAuthorizationDefaults.ScopeClaimType, GatewayScopes.Admin))
{
context.Succeed(requirement);
return Task.CompletedTask;
}
if (!isApiKeyIdentity
&& (identity.HasClaim(identity.RoleClaimType, DashboardRoles.Admin)
|| identity.HasClaim(identity.RoleClaimType, DashboardRoles.Viewer)))
{
context.Succeed(requirement);
return Task.CompletedTask;
}
}
return Task.CompletedTask;
}
}
/// <summary>Registration for the optional <c>/metrics</c> authentication.</summary>
public static class MetricsAuthorizationServiceCollectionExtensions
{
/// <summary>
/// Registers the metrics authentication scheme, the <see cref="MetricsAuthorizationDefaults.Policy"/>
/// policy and its handler. Registration is unconditional and inert: the policy only takes effect
/// when <c>MxGateway:Metrics:RequireAuthentication</c> attaches it to the endpoint.
/// </summary>
/// <param name="services">Service collection to register into.</param>
/// <returns>The same service collection, for chaining.</returns>
public static IServiceCollection AddGatewayMetricsAuthorization(this IServiceCollection services)
{
services.AddAuthentication()
.AddScheme<AuthenticationSchemeOptions, MetricsAuthenticationHandler>(
MetricsAuthorizationDefaults.AuthenticationScheme,
_ => { });
services.AddAuthorizationBuilder()
.AddPolicy(
MetricsAuthorizationDefaults.Policy,
policy => policy
.AddAuthenticationSchemes(MetricsAuthorizationDefaults.AuthenticationScheme)
.AddRequirements(new MetricsAccessRequirement()));
services.AddSingleton<IAuthorizationHandler, MetricsAccessHandler>();
return services;
}
}
@@ -705,7 +705,7 @@ public sealed class WorkerClient : IWorkerClient
{
if (workerEvent.Event is not null)
{
_metrics?.EventReceived(SessionId, workerEvent.Event.Family.ToString());
_metrics?.EventReceived(SessionId, MxEventFamilyNames.Get(workerEvent.Event.Family));
}
if (_eventStaging.Writer.TryWrite(workerEvent))