Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/BoundedApiKeyStoreKnownIdTests.cs
T
Joseph Doherty e04156d8f7 fix(host): refuse unknown API-key ids in memory and budget store misses; document the mesh gate's boot window
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
2026-10-22 10:39:12 -04:00

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