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:
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user