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:
@@ -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_<random>_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,
|
||||
|
||||
Reference in New Issue
Block a user