e04156d8f7
BoundedApiKeyStore's negative cache was keyed by key id, so a flood of DISTINCT well-formed
otua_<random>_x credentials missed it every time and cost one SQL query per attempt under the
SDK's SessionManager-wide lock, no credential needed (R03:3.3).
- IApiKeyStore.ListKeyIdsAsync (default null = unsupported; ConfigDbApiKeyStore lists every
ApiKeyId): the bounded store keeps a known-id set refreshed every StoreCacheSeconds
(single-flight, failed refresh keeps the previous set) and refuses an unknown id without a store
read ({outcome=unknown_id}).
- A process-wide miss budget (token bucket, Security:ApiKeys:MissBurst=100 /
MissRefillPerSecond=20, ApiKeyStoreGuardOptions) bounds lookups that reach the store and miss
while no set is available; a hit refunds its token. Per-endpoint accounting needs the
OpcUaServer impersonation seam to carry the client address — follow-up.
- docs/security.md: the new knobs, and the known-cluster gate's documented fail-open window at
central boot (R03:4.2) plus the async PSK refresh (R03:3.4).
Refs #733
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
174 lines
7.8 KiB
C#
174 lines
7.8 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
|
|
|
|
/// <summary>
|
|
/// R03:3.3 — the negative cache was keyed by key id, so a flood of DISTINCT well-formed ids cost one
|
|
/// SQL query per attempt under the SessionManager lock, no credential needed. Two guards:
|
|
/// the known-id set (unknown ids refused in memory) and the miss budget (a token bucket on lookups
|
|
/// that reach the store and miss, for when the set is unavailable).
|
|
/// </summary>
|
|
public sealed class BoundedApiKeyStoreKnownIdTests
|
|
{
|
|
private static readonly DateTimeOffset T0 = new(2026, 10, 22, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
[Fact]
|
|
public async Task An_unknown_id_is_refused_without_reaching_the_store_and_a_known_one_still_does()
|
|
{
|
|
var inner = new ListingStore("k-real");
|
|
var clock = new FakeClock(T0);
|
|
var sut = Bounded(inner, clock, cacheSeconds: 5);
|
|
|
|
(await sut.FindAsync("k-guess-1", T0.UtcDateTime, default)).ShouldBeNull();
|
|
(await sut.FindAsync("k-guess-2", T0.UtcDateTime, default)).ShouldBeNull();
|
|
inner.FindCalls.ShouldBe(0, "unknown ids never reach the store");
|
|
inner.ListCalls.ShouldBe(1, "one set load serves every unknown id inside the TTL");
|
|
|
|
// Positive control: the real id reaches the store and is found.
|
|
(await sut.FindAsync("k-real", T0.UtcDateTime, default)).ShouldNotBeNull();
|
|
inner.FindCalls.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_set_is_refreshed_after_the_TTL_so_a_newly_minted_key_becomes_visible()
|
|
{
|
|
var inner = new ListingStore("k-old");
|
|
var clock = new FakeClock(T0);
|
|
var sut = Bounded(inner, clock, cacheSeconds: 5);
|
|
|
|
(await sut.FindAsync("k-new", T0.UtcDateTime, default)).ShouldBeNull();
|
|
inner.Mint("k-new");
|
|
// Still inside the TTL: the stale set refuses it (the documented window).
|
|
clock.Advance(TimeSpan.FromSeconds(3));
|
|
(await sut.FindAsync("k-new", clock.GetUtcNow().UtcDateTime, default)).ShouldBeNull();
|
|
inner.FindCalls.ShouldBe(0);
|
|
|
|
clock.Advance(TimeSpan.FromSeconds(3));
|
|
(await sut.FindAsync("k-new", clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull();
|
|
inner.ListCalls.ShouldBe(2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_failed_set_load_falls_back_to_the_per_id_lookup_and_keeps_an_older_set()
|
|
{
|
|
var inner = new ListingStore("k-real") { ListThrows = true };
|
|
var clock = new FakeClock(T0);
|
|
var sut = Bounded(inner, clock, cacheSeconds: 5);
|
|
|
|
// No set yet and the load fails: the per-id path runs (old behaviour), nothing is wedged.
|
|
(await sut.FindAsync("k-guess", T0.UtcDateTime, default)).ShouldBeNull();
|
|
inner.FindCalls.ShouldBe(1);
|
|
sut.KnownKeyIds.ShouldBeNull();
|
|
|
|
inner.ListThrows = false;
|
|
clock.Advance(TimeSpan.FromSeconds(6));
|
|
(await sut.FindAsync("k-guess-2", clock.GetUtcNow().UtcDateTime, default)).ShouldBeNull();
|
|
sut.KnownKeyIds.ShouldNotBeNull();
|
|
inner.FindCalls.ShouldBe(1, "once the set loaded, the guess was refused in memory");
|
|
|
|
// A later failed refresh keeps the previous set in force.
|
|
inner.ListThrows = true;
|
|
clock.Advance(TimeSpan.FromSeconds(6));
|
|
(await sut.FindAsync("k-guess-3", clock.GetUtcNow().UtcDateTime, default)).ShouldBeNull();
|
|
inner.FindCalls.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>With no id set available, misses are rate-limited; hits are not charged; time refills.</summary>
|
|
[Fact]
|
|
public async Task The_miss_budget_bounds_store_misses_when_no_set_is_available()
|
|
{
|
|
var inner = new ListingStore("k-real") { SupportsListing = false };
|
|
var clock = new FakeClock(T0);
|
|
var sut = Bounded(inner, clock, cacheSeconds: 0, missBurst: 3, refillPerSecond: 1);
|
|
|
|
for (var i = 0; i < 3; i++)
|
|
(await sut.FindAsync($"k-guess-{i}", T0.UtcDateTime, default)).ShouldBeNull();
|
|
inner.FindCalls.ShouldBe(3);
|
|
|
|
// Budget exhausted: refused without a store read, as a throw the authenticator denies on.
|
|
await Should.ThrowAsync<InvalidOperationException>(() => sut.FindAsync("k-guess-9", T0.UtcDateTime, default));
|
|
inner.FindCalls.ShouldBe(3);
|
|
|
|
// Refill: one second restores one token.
|
|
clock.Advance(TimeSpan.FromSeconds(1));
|
|
(await sut.FindAsync("k-guess-10", clock.GetUtcNow().UtcDateTime, default)).ShouldBeNull();
|
|
inner.FindCalls.ShouldBe(4);
|
|
|
|
// A hit is not a miss: it reaches the store even with the bucket empty? No — it needs a token to
|
|
// be attempted, but the token is refunded, so the next attempt still has it. Prove the refund.
|
|
clock.Advance(TimeSpan.FromSeconds(1));
|
|
(await sut.FindAsync("k-real", clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull();
|
|
(await sut.FindAsync("k-guess-11", clock.GetUtcNow().UtcDateTime, default)).ShouldBeNull();
|
|
inner.FindCalls.ShouldBe(6);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_guards_are_off_when_the_cache_is_off_restoring_a_read_per_lookup()
|
|
{
|
|
var inner = new ListingStore("k-real");
|
|
var sut = Bounded(inner, new FakeClock(T0), cacheSeconds: 0, missBurst: 0);
|
|
|
|
(await sut.FindAsync("k-guess", T0.UtcDateTime, default)).ShouldBeNull();
|
|
(await sut.FindAsync("k-guess", T0.UtcDateTime, default)).ShouldBeNull();
|
|
inner.FindCalls.ShouldBe(2);
|
|
inner.ListCalls.ShouldBe(0);
|
|
}
|
|
|
|
private static BoundedApiKeyStore Bounded(
|
|
IApiKeyStore inner, TimeProvider clock, int cacheSeconds, int missBurst = 100, double refillPerSecond = 20) => new(
|
|
inner,
|
|
Options.Create(new ApiKeyOptions
|
|
{
|
|
Username = "apikey", Pepper = "unit-test-pepper-xxxx", StoreTimeoutMs = 0, StoreCacheSeconds = cacheSeconds,
|
|
}),
|
|
NullLogger<BoundedApiKeyStore>.Instance,
|
|
clock,
|
|
new ApiKeyStoreGuardOptions { MissBurst = missBurst, MissRefillPerSecond = refillPerSecond });
|
|
|
|
private sealed class ListingStore(params string[] ids) : IApiKeyStore
|
|
{
|
|
private readonly HashSet<string> _ids = ids.ToHashSet(StringComparer.Ordinal);
|
|
private int _findCalls;
|
|
private int _listCalls;
|
|
|
|
public bool SupportsListing { get; set; } = true;
|
|
public bool ListThrows { get; set; }
|
|
public int FindCalls => _findCalls;
|
|
public int ListCalls => _listCalls;
|
|
|
|
public void Mint(string id) => _ids.Add(id);
|
|
|
|
public Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct)
|
|
{
|
|
Interlocked.Increment(ref _findCalls);
|
|
return Task.FromResult(_ids.Contains(apiKeyId)
|
|
? new ApiKeyLookup(new ApiKey { ApiKeyId = apiKeyId, Name = "k", SecretHash = "h", Principal = "p" }, [])
|
|
: null);
|
|
}
|
|
|
|
public Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct) =>
|
|
Task.FromResult<IReadOnlyList<NodeAcl>>([]);
|
|
|
|
public Task<IReadOnlySet<string>?> ListKeyIdsAsync(CancellationToken ct)
|
|
{
|
|
if (!SupportsListing) return Task.FromResult<IReadOnlySet<string>?>(null);
|
|
Interlocked.Increment(ref _listCalls);
|
|
if (ListThrows) throw new InvalidOperationException("ConfigDb unreachable (simulated)");
|
|
return Task.FromResult<IReadOnlySet<string>?>(_ids.ToHashSet(StringComparer.Ordinal));
|
|
}
|
|
}
|
|
|
|
private sealed class FakeClock(DateTimeOffset utcNow) : TimeProvider
|
|
{
|
|
private DateTimeOffset _now = utcNow;
|
|
public override DateTimeOffset GetUtcNow() => _now;
|
|
public void Advance(TimeSpan by) => _now = _now.Add(by);
|
|
}
|
|
}
|