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); }