fix(security): pin the cached-key expiry guard, meter cache_hit after it, and close two wiring gaps (review fixes)

This commit is contained in:
Joseph Doherty
2026-08-20 10:18:52 -04:00
parent 14094e3a18
commit 62515f3279
5 changed files with 116 additions and 5 deletions
@@ -76,6 +76,26 @@ public sealed class ApiKeyStoreRegistrationTests
sp.GetRequiredService<IApiKeyStore>().ShouldBeOfType<ArtifactApiKeyStore>();
}
/// <summary>
/// A node carrying neither role registers the deny-all authenticator <b>explicitly</b>, and no key
/// store at all.
/// </summary>
/// <remarks>
/// Explicitly, not by absence: <c>OtOpcUaServerHostedService</c> takes
/// <see cref="IOpcUaUserAuthenticator"/> as a required constructor parameter, and the built-in
/// container does not fall back to a default for an unregistered service — it throws, and the node
/// does not start. The assertion is on the singleton INSTANCE, so a future registration that
/// happened to resolve some other deny-all type still surfaces here.
/// </remarks>
[Fact]
public void A_node_with_neither_role_registers_the_deny_all_authenticator()
{
using var sp = BuildNode(hasAdmin: false, hasDriver: false);
sp.GetRequiredService<IOpcUaUserAuthenticator>().ShouldBeSameAs(NullOpcUaUserAuthenticator.Instance);
sp.GetService<IApiKeyStore>().ShouldBeNull("a node that serves no data plane has no keys to read");
}
/// <summary>Builds the data-plane authentication wiring for one node role combination.</summary>
/// <param name="hasAdmin">Whether the node carries the <c>admin</c> role.</param>
/// <param name="hasDriver">Whether the node carries the <c>driver</c> role.</param>
@@ -272,6 +272,43 @@ public sealed class BoundedApiKeyStoreTests
inner.Calls.ShouldBe(callsAfterFlood + 1);
}
/// <summary>
/// A cached key whose <c>ExpiresUtc</c> passes <b>inside</b> the TTL is not served: the store is
/// re-read instead.
/// </summary>
/// <remarks>
/// <para>
/// Without this the TTL would resurrect an expired key for up to <c>StoreCacheSeconds</c> —
/// the exact axis 03/S19 exists to close, re-opened by 03/S18's own cache. The cache key is
/// <c>apiKeyId</c> alone (<c>utcNow</c> changes every call and would defeat the cache), so the
/// clock check has to happen on the way OUT of the cache.
/// </para>
/// <para>
/// The inner store is flipped to <c>Revoked</c> first, so "served from cache" and "re-read"
/// give different ANSWERS rather than only different call counts — a build that re-read and
/// then ignored the result could not pass.
/// </para>
/// </remarks>
[Fact]
public async Task A_cached_key_that_expires_by_the_clock_is_not_served()
{
var start = new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero);
var (_, inner) = SetUp(expiresUtc: start.UtcDateTime.AddSeconds(2));
var clock = new FakeClock(start);
var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 60, clock);
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default))
.ShouldNotBeNull("non-vacuity: the key was usable and IS now cached");
inner.Revoked = true;
// Past ExpiresUtc (+2 s) but far inside the 60 s TTL — the entry is still "fresh" to the cache.
clock.Advance(TimeSpan.FromSeconds(5));
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldBeNull();
inner.Calls.ShouldBe(2, "the expired entry must be re-read, not served");
}
// ---------- metering ----------
/// <summary>A deadline that fires meters <c>outcome=timeout</c> — the alarm signal for a sick ConfigDb.</summary>
@@ -347,7 +384,7 @@ public sealed class BoundedApiKeyStoreTests
Options.Create(new ApiKeyOptions { Username = "apikey", Pepper = Pepper }),
NullLogger<ApiKeyOpcUaUserAuthenticator>.Instance);
private static (string Presented, GatedApiKeyStore Store) SetUp()
private static (string Presented, GatedApiKeyStore Store) SetUp(DateTime? expiresUtc = null)
{
var (presented, apiKeyId, hash) = ApiKeySecret.Generate(Pepper);
var key = new ApiKey
@@ -358,6 +395,7 @@ public sealed class BoundedApiKeyStoreTests
Principal = Principal,
Enabled = true,
CreatedUtc = DateTime.UtcNow,
ExpiresUtc = expiresUtc,
};
return (presented, new GatedApiKeyStore(key));
}