c657b2e03d
BoundedApiKeyStore bounded ConfigDbApiKeyStore with a CancellationTokenSource(TimeSpan, TimeProvider) linked into the inner call. That bounds the call only if the callee chooses to observe the token, and Microsoft.Data.SqlClient does not while a connect/login is in flight on a black-holed socket. The 2026-08-20 live gate paused the SQL container and watched a keyed activation hang 42.9 s past its 2000 ms bound — the "did not answer within 2000 ms" Warning fired only at unpause — and because this runs inside the SDK's synchronous SessionManager-lock-holding impersonation callback, an unrelated already-open subscription stalled 57 s behind it. That is precisely the hazard the bound exists to prevent, and the TTL cache cannot help a miss. BoundAsync now races the inner call against Task.Delay on the injected clock with Task.WhenAny. Inner wins: stop the timer, await it, faults and genuine caller cancellation propagate unchanged. Delay wins: cancel the linked token anyway (a cooperative inner store should still stop), hand the abandoned task to ObserveAbandoned, meter outcome=timeout, log the now-honest Warning, and throw OperationCanceledException — the same exception the authenticator's catch-all already maps to the standard opaque denial. Fail-closed is unchanged: nothing here can admit. ObserveAbandoned disposes the CTS and TOUCHES finished.Exception (the observation itself), so a late SqlClient fault can never become an UnobservedTaskException. Debug, not Warning — the timeout is already reported at Warning, and an outage must not produce two lines per activation for one event. An abandoned answer is NEVER cached: it was taken before the deadline and would be served after it, so a key revoked or expired in the meantime could be admitted from a lookup nobody was still waiting for. StoreTimeoutMs <= 0 still short-circuits to the unbounded call byte-for-byte (the documented rollback). RED BEFORE GREEN. Every pre-existing deadline test drives GatedApiKeyStore, whose waits take the token, so a cooperative CancelAfter unblocks them and all 15 passed against the defective build — the suite could not see the gate's failure at all. The new UncooperativeApiKeyStore ignores ct entirely and completes only when the test releases it. Against the unmodified store the three new tests fail 3/18 with "should throw OperationCanceledException but threw TimeoutException" under a test-side WaitAsync(2 s), so the red is a failure and not a hang — and that exception swap IS the finding restated as an assertion. Host.Tests 137/137.
316 lines
16 KiB
C#
316 lines
16 KiB
C#
using System.Collections.Concurrent;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
|
|
|
/// <summary>
|
|
/// Bounds what an <see cref="IApiKeyStore"/> can cost the OPC UA impersonation callback (arch-review
|
|
/// 03/S18). Wraps <see cref="ConfigDbApiKeyStore"/> on a node that has a ConfigDb; the in-memory
|
|
/// <see cref="ArtifactApiKeyStore"/> needs neither half and is deliberately left unwrapped.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Why a deadline is the fix and the cache is not.</b> The SDK raises impersonation
|
|
/// synchronously under a <c>SessionManager</c>-wide lock, so a store that does not answer does not
|
|
/// stall one activation — it stalls every activation in the server. A cache lowers how OFTEN the
|
|
/// store is asked; only a deadline bounds how LONG one ask may hold that lock.
|
|
/// </para>
|
|
/// <para>
|
|
/// ⚠️ <b>Every bounded outcome DENIES.</b> A deadline exceeded, a faulted inner store and a
|
|
/// cancelled call all surface as an exception into
|
|
/// <see cref="ApiKeyOpcUaUserAuthenticator"/>'s catch-all, which returns the same opaque denial as
|
|
/// a bad credential. Nothing here can admit a session the inner store did not authorize — the
|
|
/// decorator can only return what the inner store returned, or throw.
|
|
/// </para>
|
|
/// <para>
|
|
/// ⚠️ <b>Faults and timeouts are NEVER cached</b>, and neither is the late answer of a lookup the
|
|
/// deadline abandoned. Caching a deny would be fail-closed and is still wrong: it would extend a
|
|
/// ConfigDb outage past the outage, denying valid keys for a further TTL after the database came
|
|
/// back. Caching an abandoned answer would be worse than that — it was taken before the deadline
|
|
/// and served after it. Only a lookup that completed BEFORE its deadline — a hit or an
|
|
/// authoritative miss — is stored.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class BoundedApiKeyStore : IApiKeyStore
|
|
{
|
|
/// <summary>
|
|
/// Entries either cache may hold before it is cleared wholesale.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Wholesale rather than per-entry eviction, for the reason <c>SessionAclDecisionCache</c> already
|
|
/// documents: picking WHICH entry to drop needs knowledge the cache does not have, and a
|
|
/// repopulate is cheap and cannot be wrong. The ceiling exists so a key-id guessing flood is a
|
|
/// bounded memory event — negative results are cached deliberately, so that a flood of unknown ids
|
|
/// does not become a SQL flood.
|
|
/// </remarks>
|
|
public const int MaxCacheEntries = 1024;
|
|
|
|
private readonly IApiKeyStore _inner;
|
|
private readonly IOptions<ApiKeyOptions> _options;
|
|
private readonly ILogger<BoundedApiKeyStore> _log;
|
|
private readonly TimeProvider _time;
|
|
private readonly ConcurrentDictionary<string, Entry<ApiKeyLookup?>> _keys = new(StringComparer.Ordinal);
|
|
private readonly ConcurrentDictionary<string, Entry<IReadOnlyList<NodeAcl>>> _grants =
|
|
new(StringComparer.Ordinal);
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="BoundedApiKeyStore"/> class.</summary>
|
|
/// <param name="inner">The store being bounded.</param>
|
|
/// <param name="options">The <c>Security:ApiKeys</c> options carrying the deadline and TTL.</param>
|
|
/// <param name="log">The logger.</param>
|
|
/// <param name="time">
|
|
/// The shared clock. The same instance the authorizer reads — see
|
|
/// <c>DataPlaneAuthRegistration</c> for why a second clock here would be a hazard rather than a
|
|
/// detail.
|
|
/// </param>
|
|
public BoundedApiKeyStore(
|
|
IApiKeyStore inner,
|
|
IOptions<ApiKeyOptions> options,
|
|
ILogger<BoundedApiKeyStore> log,
|
|
TimeProvider time)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(inner);
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
ArgumentNullException.ThrowIfNull(log);
|
|
ArgumentNullException.ThrowIfNull(time);
|
|
|
|
_inner = inner;
|
|
_options = options;
|
|
_log = log;
|
|
_time = time;
|
|
}
|
|
|
|
/// <summary>The store being bounded.</summary>
|
|
/// <remarks>
|
|
/// Exposed so the composition root's wiring can be asserted. A decorator registered in one branch
|
|
/// of a role conditional is the "built but never wired" shape, and a guard that cannot see what is
|
|
/// inside the wrapper cannot tell a correctly-wired node from one wrapping the wrong store.
|
|
/// </remarks>
|
|
public IApiKeyStore Inner => _inner;
|
|
|
|
/// <summary>The clock this bound's deadline and TTL are measured on.</summary>
|
|
/// <remarks>Same reason as <see cref="Inner"/>: the shared-clock invariant is only pinnable by reference.</remarks>
|
|
public TimeProvider Clock => _time;
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// Keyed by <paramref name="apiKeyId"/> alone. <paramref name="utcNow"/> is deliberately not part
|
|
/// of the key — it changes on every call, so including it would defeat the cache entirely — but a
|
|
/// cached hit IS re-checked against it before being served, so the TTL can never resurrect a key
|
|
/// that has expired by the clock in the meantime. The TTL therefore bounds how stale an
|
|
/// <c>Enabled</c> revocation may be, and nothing else.
|
|
/// </remarks>
|
|
public async Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct)
|
|
{
|
|
var ttl = Ttl;
|
|
if (ttl > TimeSpan.Zero
|
|
&& TryServe(_keys, apiKeyId, ttl, out var cached)
|
|
&& !IsExpired(cached, utcNow))
|
|
{
|
|
// Metered HERE rather than inside TryServe: an entry that is inside the TTL but whose key has
|
|
// since expired is not a hit — it falls through to a store read — so counting it in TryServe
|
|
// would report cache hits that never happened.
|
|
MeterCacheHit();
|
|
return cached;
|
|
}
|
|
|
|
var fresh = await BoundAsync(token => _inner.FindAsync(apiKeyId, utcNow, token), ct)
|
|
.ConfigureAwait(false);
|
|
if (ttl > TimeSpan.Zero) Store(_keys, apiKeyId, fresh);
|
|
return fresh;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>Keyed by principal — the anonymous activation path, which runs on every unauthenticated connect.</remarks>
|
|
public async Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct)
|
|
{
|
|
var ttl = Ttl;
|
|
if (ttl > TimeSpan.Zero && TryServe(_grants, principal, ttl, out var cached))
|
|
{
|
|
MeterCacheHit();
|
|
return cached;
|
|
}
|
|
|
|
var fresh = await BoundAsync(token => _inner.FindGrantsAsync(principal, token), ct)
|
|
.ConfigureAwait(false);
|
|
if (ttl > TimeSpan.Zero) Store(_grants, principal, fresh);
|
|
return fresh;
|
|
}
|
|
|
|
/// <summary>The configured TTL; non-positive means the cache is off.</summary>
|
|
private TimeSpan Ttl => TimeSpan.FromSeconds(_options.Value.StoreCacheSeconds);
|
|
|
|
/// <summary>Whether a cached lookup has since expired by the clock.</summary>
|
|
/// <param name="cached">The cached lookup, possibly null (an authoritative miss).</param>
|
|
/// <param name="utcNow">The caller's current time.</param>
|
|
/// <returns><see langword="true"/> when the cached key must not be served.</returns>
|
|
private static bool IsExpired(ApiKeyLookup? cached, DateTime utcNow) =>
|
|
cached?.Key.ExpiresUtc is { } expires && expires <= utcNow;
|
|
|
|
/// <summary>Counts one lookup the cache actually answered.</summary>
|
|
private static void MeterCacheHit() =>
|
|
OtOpcUaTelemetry.ApiKeyStoreBounded.Add(1, new KeyValuePair<string, object?>("outcome", "cache_hit"));
|
|
|
|
/// <summary>Serves an entry if one is present and still inside the TTL.</summary>
|
|
/// <typeparam name="T">The cached value type.</typeparam>
|
|
/// <param name="cache">The cache to read.</param>
|
|
/// <param name="key">The cache key.</param>
|
|
/// <param name="ttl">How long an entry stays servable.</param>
|
|
/// <param name="value">The cached value, when this returns true.</param>
|
|
/// <returns><see langword="true"/> on a hit.</returns>
|
|
private bool TryServe<T>(
|
|
ConcurrentDictionary<string, Entry<T>> cache, string key, TimeSpan ttl, out T value)
|
|
{
|
|
if (cache.TryGetValue(key, out var entry) && _time.GetUtcNow() - entry.StampedUtc < ttl)
|
|
{
|
|
value = entry.Value;
|
|
return true;
|
|
}
|
|
|
|
value = default!;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>Stores a COMPLETED lookup, clearing wholesale first if the ceiling has been reached.</summary>
|
|
/// <typeparam name="T">The cached value type.</typeparam>
|
|
/// <param name="cache">The cache to write.</param>
|
|
/// <param name="key">The cache key.</param>
|
|
/// <param name="value">The value to cache.</param>
|
|
private void Store<T>(ConcurrentDictionary<string, Entry<T>> cache, string key, T value)
|
|
{
|
|
if (cache.Count >= MaxCacheEntries) cache.Clear();
|
|
cache[key] = new Entry<T>(_time.GetUtcNow(), value);
|
|
}
|
|
|
|
/// <summary>One cached lookup and the instant it was taken.</summary>
|
|
/// <typeparam name="T">The cached value type.</typeparam>
|
|
/// <param name="StampedUtc">When the inner store answered.</param>
|
|
/// <param name="Value">What it answered.</param>
|
|
private readonly record struct Entry<T>(DateTimeOffset StampedUtc, T Value);
|
|
|
|
/// <summary>Runs one inner call under the configured deadline.</summary>
|
|
/// <typeparam name="T">The inner call's result type.</typeparam>
|
|
/// <param name="call">The inner call, taking the deadline-linked token.</param>
|
|
/// <param name="ct">The caller's token — in production always <c>None</c>, see remarks.</param>
|
|
/// <returns>Whatever the inner store returned.</returns>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The deadline is created HERE rather than received from the caller because there is no caller
|
|
/// to receive one from: <c>OpcUaApplicationHost</c> block-bridges the SDK's synchronous
|
|
/// impersonation callback with <see cref="CancellationToken.None"/>, so the <c>ct</c> the store
|
|
/// sees in production is always uncancellable.
|
|
/// </para>
|
|
/// <para>
|
|
/// ⚠️ <b>The deadline is a HARD race, not a cooperative token — and that distinction was a live
|
|
/// gate failure, not a theoretical one.</b> This used to be a
|
|
/// <c>CancellationTokenSource(TimeSpan, TimeProvider)</c> linked into the inner call, which
|
|
/// bounds the call only if the callee chooses to observe the token.
|
|
/// <c>Microsoft.Data.SqlClient</c> does not, while a connect or login is in flight on a
|
|
/// black-holed socket: the 2026-08-20 R3-05 gate paused the SQL container and watched a keyed
|
|
/// activation hang <b>42.9 s</b> past its 2000 ms bound (the Warning below fired only at
|
|
/// unpause), stalling an unrelated already-open subscription for <b>57 s</b> behind the
|
|
/// <c>SessionManager</c> lock — the exact hazard the bound exists to prevent. So the inner call
|
|
/// is now raced against a <see cref="Task.Delay(TimeSpan, TimeProvider, CancellationToken)"/>
|
|
/// on the injected clock and <b>abandoned</b> when the delay wins.
|
|
/// </para>
|
|
/// <para>
|
|
/// ⚠️ <b>An abandoned lookup's answer is DISCARDED, never cached.</b> It was taken before the
|
|
/// deadline and would be served after it, so a key revoked or expired in the meantime could be
|
|
/// admitted from a lookup nobody was still waiting for. The token is still cancelled — a
|
|
/// cooperative inner store should stop — and the abandoned task's outcome is observed by
|
|
/// <see cref="ObserveAbandoned{T}"/> so it can never surface as an
|
|
/// <c>UnobservedTaskException</c>.
|
|
/// </para>
|
|
/// <para>
|
|
/// ⚠️ <b>It throws rather than returning <see langword="null"/>.</b> <c>null</c> means
|
|
/// "no such usable key", which the authenticator logs as <i>denied — no enabled, unexpired key
|
|
/// with id X</i>. A timeout is not that, and an operator reading that line during a ConfigDb
|
|
/// outage would go hunting for a key problem that does not exist. The distinct Warning below is
|
|
/// the honest signal; the denial itself is unchanged either way — the authenticator's catch-all
|
|
/// turns any throw into the same opaque denial.
|
|
/// </para>
|
|
/// </remarks>
|
|
private async Task<T> BoundAsync<T>(Func<CancellationToken, Task<T>> call, CancellationToken ct)
|
|
{
|
|
var timeoutMs = _options.Value.StoreTimeoutMs;
|
|
if (timeoutMs <= 0) return await call(ct).ConfigureAwait(false);
|
|
|
|
// Linked with the caller's token so a genuine caller cancellation is still honoured — and so the
|
|
// winner check below can tell the two apart. NOT linked to the deadline: the deadline is the race,
|
|
// and this token is only the courtesy signal to an inner store that happens to be cooperative.
|
|
var innerCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
Task<T> inner;
|
|
try
|
|
{
|
|
inner = call(innerCts.Token);
|
|
}
|
|
catch
|
|
{
|
|
innerCts.Dispose();
|
|
throw;
|
|
}
|
|
|
|
// The delay runs on the INJECTED clock, so it is the same TimeProvider the authorizer and the
|
|
// revalidation timer read. Its own CTS exists only to stop the timer when the inner call wins.
|
|
using var delayCts = new CancellationTokenSource();
|
|
var deadline = Task.Delay(TimeSpan.FromMilliseconds(timeoutMs), _time, delayCts.Token);
|
|
|
|
if (await Task.WhenAny(inner, deadline).ConfigureAwait(false) == inner)
|
|
{
|
|
delayCts.Cancel();
|
|
innerCts.Dispose();
|
|
return await inner.ConfigureAwait(false);
|
|
}
|
|
|
|
innerCts.Cancel();
|
|
ObserveAbandoned(inner, innerCts);
|
|
|
|
OtOpcUaTelemetry.ApiKeyStoreBounded.Add(
|
|
1, new KeyValuePair<string, object?>("outcome", "timeout"));
|
|
_log.LogWarning(
|
|
"ApiKeyStore: the key store did not answer within {TimeoutMs} ms — denying this activation. "
|
|
+ "The lookup is abandoned and its result, if it ever arrives, is discarded. Every OPC UA "
|
|
+ "session activation on this server is serialized behind this call; check ConfigDb health.",
|
|
timeoutMs);
|
|
throw new OperationCanceledException(
|
|
$"The API key store did not answer within {timeoutMs} ms; the lookup was abandoned.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Watches an abandoned inner lookup to its end so its outcome is observed and its resources are
|
|
/// released, and discards whatever it produced.
|
|
/// </summary>
|
|
/// <typeparam name="T">The abandoned call's result type.</typeparam>
|
|
/// <param name="abandoned">The lookup the deadline gave up on.</param>
|
|
/// <param name="cts">The cancellation source that call was handed; disposed once it finishes.</param>
|
|
/// <remarks>
|
|
/// Nobody awaits <paramref name="abandoned"/>, so without this its fault would be unobserved.
|
|
/// Debug rather than Warning: the timeout has already been reported at Warning, and a ConfigDb
|
|
/// outage would otherwise produce two lines per activation for one event.
|
|
/// </remarks>
|
|
private void ObserveAbandoned<T>(Task<T> abandoned, CancellationTokenSource cts) =>
|
|
_ = abandoned.ContinueWith(
|
|
finished =>
|
|
{
|
|
cts.Dispose();
|
|
var error = finished.Exception; // The observation itself — do not remove.
|
|
if (error is not null)
|
|
{
|
|
_log.LogDebug(
|
|
error,
|
|
"ApiKeyStore: an abandoned key store lookup faulted after its deadline; discarded.");
|
|
}
|
|
else
|
|
{
|
|
_log.LogDebug(
|
|
"ApiKeyStore: an abandoned key store lookup finished after its deadline; discarded.");
|
|
}
|
|
},
|
|
CancellationToken.None,
|
|
TaskContinuationOptions.ExecuteSynchronously,
|
|
TaskScheduler.Default);
|
|
}
|