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
This commit is contained in:
Joseph Doherty
2026-10-22 10:39:12 -04:00
parent 63714e430a
commit e04156d8f7
8 changed files with 421 additions and 5 deletions
+44
View File
@@ -245,6 +245,25 @@ with two `Security:ApiKeys` knobs:
a completed lookup (a hit or an authoritative miss) is stored, and a cached hit whose `ExpiresUtc` has
since passed is re-read rather than served, so the TTL can never resurrect an expired key.
#### Pre-authentication guards (`KnownIdSet` / `MissBurst` / `MissRefillPerSecond`)
The negative cache above is keyed by **key id**, so a flood of *distinct*, well-formed `otua_<random>_x`
credentials missed it every time and cost one indexed SQL query per attempt — under the SDK's
`SessionManager`-wide lock, with no credential required (review 03 §3.3). `BoundedApiKeyStore` now puts two
guards in front of the store, bound from the same `Security:ApiKeys` section
(`ApiKeyStoreGuardOptions`, Host-owned):
| Key | Default | Meaning |
|---|---|---|
| `KnownIdSet` | `true` | Keep an in-memory set of every `ApiKeyId` the ConfigDb holds, refreshed every `StoreCacheSeconds` (single-flight; a failed refresh keeps the previous set). An id outside it is refused in memory — `otopcua.opcua.apikey_store.bounded{outcome=unknown_id}` — and the store is never asked. A key minted less than `StoreCacheSeconds` ago is refused until the next refresh (the same window `Enabled` revocation already has). `StoreCacheSeconds = 0` disables the set with the cache. |
| `MissBurst` | `100` | Token bucket on lookups that **reach** the store and **miss**, for when the set is unavailable (not loaded yet, or the store cannot list ids). Exhausted ⇒ unknown ids are refused without a store read (`{outcome=miss_budget}`, a Warning-free deny through the authenticator's catch-all). A hit refunds its token, so legitimate clients are never charged. `0` disables the budget. |
| `MissRefillPerSecond` | `20` | Refill rate of the miss bucket. |
The budget is **process-wide, not per remote endpoint**: the OPC UA SDK's impersonation callback (the
`IOpcUaUserAuthenticator` seam) does not carry the client's address, so per-endpoint accounting needs that
seam widened in the OpcUaServer project (a Wave 3 follow-up). Neither guard applies to a driver-only node:
its `ArtifactApiKeyStore` is two in-memory dictionary probes and is never wrapped.
### LDAP bind flow (`OtOpcUaLdapAuthService`)
LDAP is configured under the `Security:Ldap` section (bound to `LdapOptions`, `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs`, `SectionName = "Security:Ldap"`). The app authenticator is `OtOpcUaLdapAuthService` — a thin wrapper around the shared `ZB.MOM.WW.Auth.Ldap` directory client that adds two app-only concerns the shared library deliberately does not model: the `Enabled` master switch and `DevStubMode`. Since #520 it serves the **Admin UI cookie login (`/auth/login`) only** — `LdapOpcUaUserAuthenticator` is deleted and the OPC UA UserName path authenticates API keys instead (see [API-key identity](#api-key-identity-opc-ua-data-plane)).
@@ -1116,6 +1135,31 @@ See `archreview/plans/R3-01-mesh-grpc-allowanonymous-plan.md` (R3-01) for the fu
record, and `docs/Configuration.md` §`MeshTransport` / §`ConfigSource`-`ConfigServe` and `docs/Telemetry.md`
for the affected listener ports.
### The known-cluster gate is fail-OPEN until central's first ClusterNode load (review 03 §4.2)
`CentralCommandAuthInterceptor` verifies an inbound deploy ack against the PSK of the cluster named in
`x-otopcua-cluster`. `MeshPskProvider` consults a **known-cluster gate** first — the set of cluster ids
central read from `ClusterNode` on its last contact refresh — and refuses anything outside it in O(1),
touching neither the config map nor the secret store. That gate is **inactive until the first successful
load** (`CentralCommunicationActor` publishes it from `ContactsLoaded`; an empty publication also
deactivates it, deliberately, so "the DB told me nothing" never becomes a fleet-wide authentication
outage). During a central boot with the ConfigDb down, therefore:
- an unauthenticated caller can name *any* `x-otopcua-cluster` value and make central perform one bounded
(3 s) secret-store read plus one Warning line per request — the cost/oracle the gate exists to remove;
- **keys are still required**: nothing is admitted without the matching PSK, so this is a cost exposure,
not an authentication bypass;
- the window is bounded by the bootstrap retry (15 × 2 s) and the `ContactRefreshSeconds` (60 s) periodic
refresh, and closes on the first load that succeeds. The actor logs the condition on every failed load
(*"The known-cluster gate was NOT refreshed either … it is INACTIVE"*).
Since review 03 §3.4 the per-cluster keys are **refreshed asynchronously** from the same handler
(`IMeshPskProvider.RefreshAsync`: concurrent bounded reads, atomic snapshot swap, a failed read keeps the
previous key) instead of being invalidated on every refresh, so the first ack per cluster per minute no
longer pays a synchronous store read on a gRPC thread. Accepted as documented, bounded behaviour; the
alternative — refusing every cluster until SQL answers — would turn a ConfigDb outage into a deploy-ack
outage on top of it.
---
## OTOPCUA0001 Analyzer — Compile-Time Guard
@@ -80,7 +80,10 @@ public static class DataPlaneAuthRegistration
sp.GetRequiredService<IOptions<AkkaClusterOptions>>().Value)),
sp.GetRequiredService<IOptions<ApiKeyOptions>>(),
sp.GetRequiredService<ILoggerFactory>().CreateLogger<BoundedApiKeyStore>(),
sp.GetRequiredService<TimeProvider>()));
sp.GetRequiredService<TimeProvider>(),
// R03:3.3 — the known-id set + miss budget in front of the SQL lookup; same section as
// ApiKeyOptions, Host-owned knobs.
ApiKeyStoreGuardOptions.Bind(sp.GetService<IConfiguration>())));
services.AddSingleton<IOpcUaUserAuthenticator, ApiKeyOpcUaUserAuthenticator>();
// Minting surface for the AdminUI. Same node condition as the store above: both need a ConfigDb.
@@ -0,0 +1,56 @@
using Microsoft.Extensions.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
/// <summary>
/// The pre-authentication guards <see cref="BoundedApiKeyStore"/> puts in front of the ConfigDb key
/// lookup (R03:3.3). Bound from the same <c>Security:ApiKeys</c> section as <c>ApiKeyOptions</c> but
/// owned by the Host, where the store lives.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why.</b> The negative cache was keyed by key id, so a flood of DISTINCT well-formed
/// <c>otua_&lt;random&gt;_x</c> credentials missed it every time and cost one indexed SQL query per
/// attempt — under the SDK's SessionManager-wide lock — with no credential needed. Two guards close
/// that: a <b>known-id set</b> (every <c>ApiKeyId</c> the ConfigDb holds, refreshed every
/// <c>StoreCacheSeconds</c>) rejects an unknown id in memory, and a <b>miss budget</b> (token
/// bucket) bounds how many lookups that reach the store and MISS are allowed per second when the
/// set is unavailable.
/// </para>
/// <para>
/// The budget is process-wide, not per remote endpoint: the OPC UA SDK's impersonation callback
/// (the <c>IOpcUaUserAuthenticator</c> seam) does not carry the client's address. Per-endpoint
/// accounting needs that seam widened in the OpcUaServer project — a follow-up, not this lane.
/// </para>
/// </remarks>
public sealed class ApiKeyStoreGuardOptions
{
/// <summary>The configuration section the knobs live in (shared with <c>ApiKeyOptions</c>).</summary>
public const string SectionName = "Security:ApiKeys";
/// <summary>
/// Whether the known-id set is consulted. <see langword="false"/> restores a store read per unknown
/// id. Only meaningful when the inner store can list ids (the ConfigDb store can; the artifact
/// store is never wrapped).
/// </summary>
public bool KnownIdSet { get; set; } = true;
/// <summary>
/// How many store MISSES (lookups that reach the inner store and find no key) may burst before
/// further lookups for unknown ids are refused without a store read. <c>0</c> disables the budget.
/// </summary>
public int MissBurst { get; set; } = 100;
/// <summary>How many miss tokens are restored per second, up to <see cref="MissBurst"/>.</summary>
public double MissRefillPerSecond { get; set; } = 20;
/// <summary>Binds the section, with defaults for anything absent.</summary>
/// <param name="configuration">The app configuration; null yields the defaults.</param>
/// <returns>The bound options.</returns>
public static ApiKeyStoreGuardOptions Bind(IConfiguration? configuration)
{
var options = new ApiKeyStoreGuardOptions();
configuration?.GetSection(SectionName).Bind(options);
return options;
}
}
@@ -56,6 +56,21 @@ public sealed class BoundedApiKeyStore : IApiKeyStore
private readonly ConcurrentDictionary<string, Entry<ApiKeyLookup?>> _keys = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, Entry<IReadOnlyList<NodeAcl>>> _grants =
new(StringComparer.Ordinal);
private readonly ApiKeyStoreGuardOptions _guard;
// R03:3.3 — the known-id set: every ApiKeyId the inner store holds, refreshed every StoreCacheSeconds
// (single-flight; a refresh that fails or times out keeps the previous set). Null until the first
// successful load, or when the inner store cannot list ids — then the per-id path runs as before.
private volatile IReadOnlySet<string>? _knownIds;
private DateTimeOffset _knownIdsStampedUtc = DateTimeOffset.MinValue;
private readonly SemaphoreSlim _knownIdsRefresh = new(1, 1);
// R03:3.3 — the miss budget: a token bucket on lookups that REACH the inner store and miss. Guards
// the path the known-id set cannot (set unavailable), and bounds a flood of unknown ids to the
// configured rate instead of one SQL round trip per attempt under the SessionManager lock.
private readonly object _bucketGate = new();
private double _missTokens;
private DateTimeOffset _missRefilledUtc;
/// <summary>Initializes a new instance of the <see cref="BoundedApiKeyStore"/> class.</summary>
/// <param name="inner">The store being bounded.</param>
@@ -66,11 +81,13 @@ public sealed class BoundedApiKeyStore : IApiKeyStore
/// <c>DataPlaneAuthRegistration</c> for why a second clock here would be a hazard rather than a
/// detail.
/// </param>
/// <param name="guard">The pre-authentication guards (R03:3.3); defaults when omitted.</param>
public BoundedApiKeyStore(
IApiKeyStore inner,
IOptions<ApiKeyOptions> options,
ILogger<BoundedApiKeyStore> log,
TimeProvider time)
TimeProvider time,
ApiKeyStoreGuardOptions? guard = null)
{
ArgumentNullException.ThrowIfNull(inner);
ArgumentNullException.ThrowIfNull(options);
@@ -81,8 +98,14 @@ public sealed class BoundedApiKeyStore : IApiKeyStore
_options = options;
_log = log;
_time = time;
_guard = guard ?? new ApiKeyStoreGuardOptions();
_missTokens = Math.Max(0, _guard.MissBurst);
_missRefilledUtc = time.GetUtcNow();
}
/// <summary>The known-id set currently in force, or null when none has loaded. Exposed for tests and wiring guards.</summary>
public IReadOnlySet<string>? KnownKeyIds => _knownIds;
/// <summary>The store being bounded.</summary>
/// <remarks>
/// Exposed so the composition root's wiring can be asserted. A decorator registered in one branch
@@ -115,12 +138,104 @@ public sealed class BoundedApiKeyStore : IApiKeyStore
return cached;
}
// R03:3.3, guard 1 — an id the ConfigDb does not hold is refused in memory; the store is never asked.
var known = await KnownIdsAsync(ttl, ct).ConfigureAwait(false);
if (known is not null && !known.Contains(apiKeyId))
{
OtOpcUaTelemetry.ApiKeyStoreBounded.Add(1, new KeyValuePair<string, object?>("outcome", "unknown_id"));
return null;
}
// R03:3.3, guard 2 — a lookup that will reach the store needs a miss token unless the id is known
// to exist (a known id is a real key being verified, never a guess).
if (known is null && !TryTakeMissToken())
{
OtOpcUaTelemetry.ApiKeyStoreBounded.Add(1, new KeyValuePair<string, object?>("outcome", "miss_budget"));
throw new InvalidOperationException(
"ApiKeyStore: the unknown-key miss budget is exhausted (Security:ApiKeys:MissBurst / "
+ "MissRefillPerSecond); refusing this lookup without a store read.");
}
var fresh = await BoundAsync(token => _inner.FindAsync(apiKeyId, utcNow, token), ct)
.ConfigureAwait(false);
if (fresh is not null && known is null) RefundMissToken();
if (ttl > TimeSpan.Zero) Store(_keys, apiKeyId, fresh);
return fresh;
}
/// <summary>
/// The known-id set, refreshed when older than <paramref name="ttl"/>. Single-flight: concurrent
/// callers during a refresh use the previous set (or, before the first load, the per-id path). A
/// refresh that fails or exceeds the deadline logs and keeps the previous set.
/// </summary>
/// <param name="ttl">The refresh interval (the cache TTL); non-positive disables the set.</param>
/// <param name="ct">The caller's token.</param>
/// <returns>The set, or null.</returns>
private async Task<IReadOnlySet<string>?> KnownIdsAsync(TimeSpan ttl, CancellationToken ct)
{
if (!_guard.KnownIdSet || ttl <= TimeSpan.Zero) return null;
var now = _time.GetUtcNow();
var current = _knownIds;
if (current is not null && now - _knownIdsStampedUtc < ttl) return current;
if (!await _knownIdsRefresh.WaitAsync(0, ct).ConfigureAwait(false)) return current;
try
{
// Re-check under the gate: another caller may have refreshed while this one waited.
if (_knownIds is { } refreshed && now - _knownIdsStampedUtc < ttl) return refreshed;
var ids = await BoundAsync(token => _inner.ListKeyIdsAsync(token), ct).ConfigureAwait(false);
if (ids is null)
{
// Unsupported by the inner store: say so once and stop asking.
_guard.KnownIdSet = false;
return null;
}
_knownIds = ids;
_knownIdsStampedUtc = _time.GetUtcNow();
return ids;
}
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
{
_log.LogWarning(ex,
"ApiKeyStore: refreshing the known key-id set failed; {Mode}",
current is null ? "unknown ids reach the store until it loads" : "the previous set stays in force");
return current;
}
finally
{
_knownIdsRefresh.Release();
}
}
/// <summary>Takes one miss token, refilling by elapsed time first; always succeeds when the budget is disabled.</summary>
/// <returns><see langword="false"/> when the budget is exhausted.</returns>
private bool TryTakeMissToken()
{
var burst = _guard.MissBurst;
if (burst <= 0) return true;
lock (_bucketGate)
{
var now = _time.GetUtcNow();
var elapsed = (now - _missRefilledUtc).TotalSeconds;
if (elapsed > 0)
{
_missTokens = Math.Min(burst, _missTokens + elapsed * Math.Max(0, _guard.MissRefillPerSecond));
_missRefilledUtc = now;
}
if (_missTokens < 1) return false;
_missTokens -= 1;
return true;
}
}
/// <summary>A lookup that reached the store and HIT was not a miss: its token goes back.</summary>
private void RefundMissToken()
{
if (_guard.MissBurst <= 0) return;
lock (_bucketGate) _missTokens = Math.Min(_guard.MissBurst, _missTokens + 1);
}
// Keyed by principal — the anonymous activation path, which runs on every unauthenticated connect.
/// <inheritdoc />
public async Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct)
@@ -125,6 +125,16 @@ public sealed class ConfigDbApiKeyStore : IApiKeyStore
return await LoadGrantsAsync(db, principal, ct).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlySet<string>?> ListKeyIdsAsync(CancellationToken ct)
{
await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false);
// One projection over the whole (small) table — the unique index on ApiKeyId makes this an index
// scan — so an unknown id can be refused in memory instead of costing a query per attempt.
var ids = await db.ApiKeys.AsNoTracking().Select(k => k.ApiKeyId).ToListAsync(ct).ConfigureAwait(false);
return ids.ToHashSet(StringComparer.Ordinal);
}
/// <summary>Loads a principal's grants, confined to this node's cluster.</summary>
/// <remarks>
/// <b>The full grant set, not just the <c>Cluster</c>-scoped rows</b>. The old
@@ -35,6 +35,16 @@ public interface IApiKeyStore
/// <param name="ct">The cancellation token.</param>
/// <returns>The matching ACL rows; empty when none.</returns>
Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct);
/// <summary>
/// Every <c>ApiKeyId</c> the store holds (enabled or not), for the pre-authentication known-id set
/// (R03:3.3); <see langword="null"/> when the store cannot cheaply list them, in which case callers
/// fall back to a per-id lookup. The set only answers "does this id exist at all" — enabled, expiry
/// and the secret are still decided by <see cref="FindAsync"/>.
/// </summary>
/// <param name="ct">The cancellation token.</param>
/// <returns>The id set, or null when unsupported.</returns>
Task<IReadOnlySet<string>?> ListKeyIdsAsync(CancellationToken ct) => Task.FromResult<IReadOnlySet<string>?>(null);
}
/// <summary>An API key resolved for authentication, with the ACL grants naming its principal.</summary>
@@ -0,0 +1,173 @@
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);
}
}
@@ -363,7 +363,10 @@ public sealed class BoundedApiKeyStoreTests
{
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);
// The miss budget (R03:3.3) is OFF here on purpose: this fact floods 1025 distinct unknown ids — the
// exact shape the budget refuses — to pin the cache CEILING, which is a separate rule.
var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 600, clock,
guard: new ApiKeyStoreGuardOptions { MissBurst = 0 });
// 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();
@@ -477,7 +480,8 @@ public sealed class BoundedApiKeyStoreTests
int timeoutMs,
int cacheSeconds,
TimeProvider? clock = null,
ILogger<BoundedApiKeyStore>? log = null) => new(
ILogger<BoundedApiKeyStore>? log = null,
ApiKeyStoreGuardOptions? guard = null) => new(
inner,
Options.Create(new ApiKeyOptions
{
@@ -487,7 +491,8 @@ public sealed class BoundedApiKeyStoreTests
StoreCacheSeconds = cacheSeconds,
}),
log ?? NullLogger<BoundedApiKeyStore>.Instance,
clock ?? TimeProvider.System);
clock ?? TimeProvider.System,
guard);
private static ApiKeyOpcUaUserAuthenticator Auth(IApiKeyStore store) => new(
store,