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;
///
/// Bounds what an can cost the OPC UA impersonation callback (arch-review
/// 03/S18). Wraps on a node that has a ConfigDb; the in-memory
/// needs neither half and is deliberately left unwrapped.
///
///
///
/// Why a deadline is the fix and the cache is not. The SDK raises impersonation
/// synchronously under a SessionManager-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.
///
///
/// ⚠️ Every bounded outcome DENIES. A deadline exceeded, a faulted inner store and a
/// cancelled call all surface as an exception into
/// '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.
///
///
/// ⚠️ Faults and timeouts are NEVER cached, 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.
///
///
public sealed class BoundedApiKeyStore : IApiKeyStore
{
///
/// Entries either cache may hold before it is cleared wholesale.
///
///
/// Wholesale rather than per-entry eviction, for the reason SessionAclDecisionCache 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.
///
public const int MaxCacheEntries = 1024;
private readonly IApiKeyStore _inner;
private readonly IOptions _options;
private readonly ILogger _log;
private readonly TimeProvider _time;
private readonly ConcurrentDictionary> _keys = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary>> _grants =
new(StringComparer.Ordinal);
/// Initializes a new instance of the class.
/// The store being bounded.
/// The Security:ApiKeys options carrying the deadline and TTL.
/// The logger.
///
/// The shared clock. The same instance the authorizer reads — see
/// DataPlaneAuthRegistration for why a second clock here would be a hazard rather than a
/// detail.
///
public BoundedApiKeyStore(
IApiKeyStore inner,
IOptions options,
ILogger log,
TimeProvider time)
{
ArgumentNullException.ThrowIfNull(inner);
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(log);
ArgumentNullException.ThrowIfNull(time);
_inner = inner;
_options = options;
_log = log;
_time = time;
}
/// The store being bounded.
///
/// 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.
///
public IApiKeyStore Inner => _inner;
/// The clock this bound's deadline and TTL are measured on.
/// Same reason as : the shared-clock invariant is only pinnable by reference.
public TimeProvider Clock => _time;
///
///
/// Keyed by alone. 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
/// Enabled revocation may be, and nothing else.
///
public async Task 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;
}
///
/// Keyed by principal — the anonymous activation path, which runs on every unauthenticated connect.
public async Task> 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;
}
/// The configured TTL; non-positive means the cache is off.
private TimeSpan Ttl => TimeSpan.FromSeconds(_options.Value.StoreCacheSeconds);
/// Whether a cached lookup has since expired by the clock.
/// The cached lookup, possibly null (an authoritative miss).
/// The caller's current time.
/// when the cached key must not be served.
private static bool IsExpired(ApiKeyLookup? cached, DateTime utcNow) =>
cached?.Key.ExpiresUtc is { } expires && expires <= utcNow;
/// Counts one lookup the cache actually answered.
private static void MeterCacheHit() =>
OtOpcUaTelemetry.ApiKeyStoreBounded.Add(1, new KeyValuePair("outcome", "cache_hit"));
/// Serves an entry if one is present and still inside the TTL.
/// The cached value type.
/// The cache to read.
/// The cache key.
/// How long an entry stays servable.
/// The cached value, when this returns true.
/// on a hit.
private bool TryServe(
ConcurrentDictionary> 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;
}
/// Stores a COMPLETED lookup, clearing wholesale first if the ceiling has been reached.
/// The cached value type.
/// The cache to write.
/// The cache key.
/// The value to cache.
private void Store(ConcurrentDictionary> cache, string key, T value)
{
if (cache.Count >= MaxCacheEntries) cache.Clear();
cache[key] = new Entry(_time.GetUtcNow(), value);
}
/// One cached lookup and the instant it was taken.
/// The cached value type.
/// When the inner store answered.
/// What it answered.
private readonly record struct Entry(DateTimeOffset StampedUtc, T Value);
/// Runs one inner call under the configured deadline.
/// The inner call's result type.
/// The inner call, taking the deadline-linked token.
/// The caller's token — in production always None, see remarks.
/// Whatever the inner store returned.
///
///
/// The deadline is created HERE rather than received from the caller because there is no caller
/// to receive one from: OpcUaApplicationHost block-bridges the SDK's synchronous
/// impersonation callback with , so the ct the store
/// sees in production is always uncancellable.
///
///
/// ⚠️ The deadline is a HARD race, not a cooperative token — and that distinction was a live
/// gate failure, not a theoretical one. This used to be a
/// CancellationTokenSource(TimeSpan, TimeProvider) linked into the inner call, which
/// bounds the call only if the callee chooses to observe the token.
/// Microsoft.Data.SqlClient 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 42.9 s past its 2000 ms bound (the Warning below fired only at
/// unpause), stalling an unrelated already-open subscription for 57 s behind the
/// SessionManager lock — the exact hazard the bound exists to prevent. So the inner call
/// is now raced against a
/// on the injected clock and abandoned when the delay wins.
///
///
/// ⚠️ An abandoned lookup's answer is DISCARDED, 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. The token is still cancelled — a
/// cooperative inner store should stop — and the abandoned task's outcome is observed by
/// so it can never surface as an
/// UnobservedTaskException.
///
///
/// ⚠️ It throws rather than returning . null means
/// "no such usable key", which the authenticator logs as denied — no enabled, unexpired key
/// with id X. 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.
///
///
private async Task BoundAsync(Func> 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 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("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.");
}
///
/// Watches an abandoned inner lookup to its end so its outcome is observed and its resources are
/// released, and discards whatever it produced.
///
/// The abandoned call's result type.
/// The lookup the deadline gave up on.
/// The cancellation source that call was handed; disposed once it finishes.
///
/// Nobody awaits , 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.
///
private void ObserveAbandoned(Task 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);
}