feat(security): short TTL read-through cache in front of the ConfigDb key store
This commit is contained in:
@@ -5,6 +5,7 @@ using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
|
||||
|
||||
@@ -138,6 +139,137 @@ public sealed class BoundedApiKeyStoreTests
|
||||
result.Success.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---------- TTL read-through cache ----------
|
||||
|
||||
/// <summary>A second activation inside the TTL is served from the cache and never reaches the store.</summary>
|
||||
[Fact]
|
||||
public async Task A_second_activation_within_the_TTL_does_not_reach_the_store()
|
||||
{
|
||||
var (_, inner) = SetUp();
|
||||
var clock = new FakeClock(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero));
|
||||
var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 5, clock);
|
||||
|
||||
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull();
|
||||
clock.Advance(TimeSpan.FromSeconds(2));
|
||||
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull();
|
||||
|
||||
inner.Calls.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FALSIFICATION CONTROL. A key revoked in the store is denied once the TTL has passed — the
|
||||
/// cache expires rather than merely existing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A cache with no expiry at all passes every other case in this class: the hit test, the
|
||||
/// read-through test and the fault test are all satisfied by "store once, serve forever". This is
|
||||
/// the one that makes <c>StoreCacheSeconds</c> a bound rather than a claim.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task A_revoked_key_is_denied_once_the_TTL_has_passed()
|
||||
{
|
||||
var (_, inner) = SetUp();
|
||||
var clock = new FakeClock(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero));
|
||||
var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 5, clock);
|
||||
|
||||
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default))
|
||||
.ShouldNotBeNull("non-vacuity: the key really was usable before the revocation");
|
||||
|
||||
inner.Revoked = true;
|
||||
|
||||
// Still inside the TTL — the documented, accepted staleness window.
|
||||
clock.Advance(TimeSpan.FromSeconds(3));
|
||||
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull();
|
||||
|
||||
// Past it — the store is re-read and the revocation lands.
|
||||
clock.Advance(TimeSpan.FromSeconds(3));
|
||||
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldBeNull();
|
||||
inner.Calls.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>A non-positive TTL disables the cache: every call reads through, today's behaviour.</summary>
|
||||
[Fact]
|
||||
public async Task TTL_zero_reads_through_every_time()
|
||||
{
|
||||
var (_, inner) = SetUp();
|
||||
var clock = new FakeClock(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero));
|
||||
var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 0, clock);
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default);
|
||||
|
||||
inner.Calls.ShouldBe(3);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A fault is never cached: the next call reaches the store, so an outage cannot outlive itself.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Caching a deny would be fail-closed and is still wrong — it would go on denying valid keys for
|
||||
/// a further TTL after the database came back.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task A_faulted_store_is_not_cached()
|
||||
{
|
||||
var (_, inner) = SetUp();
|
||||
var clock = new FakeClock(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero));
|
||||
var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 60, clock);
|
||||
inner.ThrowsRemaining = 1;
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default));
|
||||
|
||||
// Same instant — a cached fault would still be "fresh" here, so the clock cannot rescue this.
|
||||
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull();
|
||||
inner.Calls.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>The anonymous grant lookup is cached on the same terms, keyed by principal.</summary>
|
||||
[Fact]
|
||||
public async Task Anonymous_grants_are_cached_too()
|
||||
{
|
||||
var (_, inner) = SetUp();
|
||||
var clock = new FakeClock(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero));
|
||||
var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 5, clock);
|
||||
|
||||
await sut.FindGrantsAsync(OpcUaPrincipals.Anonymous, default);
|
||||
await sut.FindGrantsAsync(OpcUaPrincipals.Anonymous, default);
|
||||
inner.Calls.ShouldBe(1);
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(6));
|
||||
await sut.FindGrantsAsync(OpcUaPrincipals.Anonymous, default);
|
||||
inner.Calls.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Past the entry ceiling the cache clears wholesale and keeps answering — a key-id guessing flood
|
||||
/// is a bounded memory event, not an unbounded one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wholesale rather than per-entry eviction for the reason <c>SessionAclDecisionCache</c> already
|
||||
/// documents: choosing WHICH entry to drop needs knowledge the cache does not have, and a
|
||||
/// repopulate is cheap and cannot be wrong.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task Cache_overflow_clears_wholesale_and_keeps_serving()
|
||||
{
|
||||
var (_, inner) = SetUp();
|
||||
var clock = new FakeClock(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero));
|
||||
var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 600, clock);
|
||||
|
||||
// Seed the real key, then flood past the ceiling with ids that do not exist.
|
||||
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull();
|
||||
for (var i = 0; i < BoundedApiKeyStore.MaxCacheEntries + 1; i++)
|
||||
await sut.FindAsync($"ghost-{i}", clock.GetUtcNow().UtcDateTime, default);
|
||||
|
||||
var callsAfterFlood = inner.Calls;
|
||||
|
||||
// Still correct after the clear — and the real key was evicted, so it is re-read rather than
|
||||
// served from a cache the flood was supposed to have emptied.
|
||||
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull();
|
||||
inner.Calls.ShouldBe(callsAfterFlood + 1);
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
private static BoundedApiKeyStore Bounded(
|
||||
@@ -179,6 +311,9 @@ public sealed class BoundedApiKeyStoreTests
|
||||
/// </summary>
|
||||
private sealed class GatedApiKeyStore(ApiKey key) : IApiKeyStore
|
||||
{
|
||||
/// <summary>The id of the key this store holds.</summary>
|
||||
public string KeyId => key.ApiKeyId;
|
||||
|
||||
/// <summary>When set, every call awaits this before answering.</summary>
|
||||
public Task? Gate { get; set; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user