fix(security): pin the cached-key expiry guard, meter cache_hit after it, and close two wiring gaps (review fixes)

This commit is contained in:
Joseph Doherty
2026-08-20 10:18:52 -04:00
parent 14094e3a18
commit 62515f3279
5 changed files with 116 additions and 5 deletions
@@ -108,6 +108,10 @@ public sealed class BoundedApiKeyStore : IApiKeyStore
&& 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;
}
@@ -122,7 +126,11 @@ public sealed class BoundedApiKeyStore : IApiKeyStore
public async Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct)
{
var ttl = Ttl;
if (ttl > TimeSpan.Zero && TryServe(_grants, principal, ttl, out var cached)) return cached;
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);
@@ -140,6 +148,10 @@ public sealed class BoundedApiKeyStore : IApiKeyStore
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>
@@ -152,8 +164,6 @@ public sealed class BoundedApiKeyStore : IApiKeyStore
{
if (cache.TryGetValue(key, out var entry) && _time.GetUtcNow() - entry.StampedUtc < ttl)
{
OtOpcUaTelemetry.ApiKeyStoreBounded.Add(
1, new KeyValuePair<string, object?>("outcome", "cache_hit"));
value = entry.Value;
return true;
}
@@ -307,12 +307,19 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
/// </para>
/// <para>Separate from <see cref="StartAsync"/> so the timer's lifecycle is testable without booting the SDK.</para>
/// </remarks>
internal void StartRevalidationTimer() =>
internal void StartRevalidationTimer()
{
// Dispose before overwriting. StartAsync runs again when a host restarts the service around the
// same instance, and an overwritten field would leave the previous timer armed with nothing left
// to dispose it — the same dispose-before-overwrite rule the driver reconnect paths carry.
_revalidationTimer?.Dispose();
_revalidationTimer = new AclRevalidationTimer(
() => _server?.NodeManager?.RevalidateMonitoredItems(),
TimeSpan.FromSeconds(_aclOptions.RevalidationIntervalSeconds),
_time,
_loggerFactory.CreateLogger<AclRevalidationTimer>());
}
/// <summary>
/// Stops the OPC UA server asynchronously.