feat(security): short TTL read-through cache in front of the ConfigDb key store
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
@@ -24,13 +25,34 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
/// 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> 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. Only a COMPLETED lookup — 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>
|
||||
@@ -59,12 +81,88 @@ public sealed class BoundedApiKeyStore : IApiKeyStore
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) =>
|
||||
BoundAsync(token => _inner.FindAsync(apiKeyId, utcNow, token), ct);
|
||||
/// <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))
|
||||
{
|
||||
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 />
|
||||
public Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct) =>
|
||||
BoundAsync(token => _inner.FindGrantsAsync(principal, token), ct);
|
||||
/// <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)) 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>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>
|
||||
|
||||
Reference in New Issue
Block a user