fix(security): bound the API-key store call under the SessionManager lock (archreview 03/S18)

This commit is contained in:
Joseph Doherty
2026-08-20 09:38:41 -04:00
parent 894444376e
commit 3b56e5bd01
4 changed files with 375 additions and 1 deletions
@@ -0,0 +1,244 @@
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>
/// Covers <see cref="BoundedApiKeyStore"/> — the deadline and TTL cache the OPC UA impersonation
/// callback's key lookup runs under (arch-review 03/S18).
/// </summary>
/// <remarks>
/// <para>
/// The deadline cases are driven through the <b>real</b> <see cref="ApiKeyOpcUaUserAuthenticator"/>
/// rather than the store directly, because the fail-closed conversion — a throw becoming the
/// standard opaque denial — is part of what is being asserted, and it lives in the authenticator's
/// catch-all rather than in the decorator.
/// </para>
/// <para>
/// ⚠️ <b>Deadline cases use a REAL short timeout; TTL cases use the fake clock.</b> The hand-rolled
/// <see cref="FakeClock"/> overrides <c>GetUtcNow()</c> only, so <c>CreateTimer</c> falls through to
/// the base implementation and produces a real system timer — advancing the fake clock does not
/// fire a <c>CancelAfter</c>. A deadline test "improved" to advance the fake clock would pass for
/// the wrong reason: the inner task simply never completing.
/// </para>
/// </remarks>
public sealed class BoundedApiKeyStoreTests
{
private const string Pepper = "unit-test-pepper";
private const string Principal = "scadabridge-dcl";
/// <summary>
/// A store that never answers must not hold the activation open: the deadline fires and the
/// activation is denied.
/// </summary>
/// <remarks>
/// The SDK raises impersonation under a <c>SessionManager</c>-wide lock, so a lookup that does not
/// return stalls every session activation in the server — not just this one. That is the whole of
/// 03/S18, and it is why the bound is a deadline rather than a cache.
/// </remarks>
[Fact]
public async Task A_blocked_store_denies_without_hanging_the_activation()
{
var (presented, inner) = SetUp();
inner.BlockForever = true;
var auth = Auth(Bounded(inner, timeoutMs: 150, cacheSeconds: 0));
var result = await auth.AuthenticateUserNameAsync("apikey", presented, CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(5));
result.Success.ShouldBeFalse();
result.Error.ShouldBe(ApiKeyOpcUaUserAuthenticator.DenyMessage);
}
/// <summary>
/// The positive control for <see cref="A_blocked_store_denies_without_hanging_the_activation"/>:
/// the same fake, answering normally, still authenticates.
/// </summary>
/// <remarks>
/// Without it a build whose decorator denied <b>everything</b> would pass every deadline case in
/// this class, and the bound would look correct while having locked the fleet out.
/// </remarks>
[Fact]
public async Task A_reachable_store_still_authenticates()
{
var (presented, inner) = SetUp();
var auth = Auth(Bounded(inner, timeoutMs: 150, cacheSeconds: 0));
var result = await auth.AuthenticateUserNameAsync("apikey", presented, CancellationToken.None);
result.Success.ShouldBeTrue();
result.Principal.ShouldBe(Principal);
}
/// <summary>
/// A lookup that completes with a perfectly valid key <b>after</b> the deadline still denies —
/// nothing races back in past the bound.
/// </summary>
[Fact]
public async Task A_deadline_never_admits()
{
var (presented, inner) = SetUp();
var gate = new TaskCompletionSource();
inner.Gate = gate.Task;
var auth = Auth(Bounded(inner, timeoutMs: 100, cacheSeconds: 0));
var pending = auth.AuthenticateUserNameAsync("apikey", presented, CancellationToken.None);
// Let the deadline pass, THEN hand the inner store its (valid) answer.
await Task.Delay(400);
gate.SetResult();
var result = await pending.WaitAsync(TimeSpan.FromSeconds(5));
result.Success.ShouldBeFalse();
result.Error.ShouldBe(ApiKeyOpcUaUserAuthenticator.DenyMessage);
}
/// <summary>
/// A non-positive <c>StoreTimeoutMs</c> disables the deadline, restoring the unbounded behaviour
/// byte-for-byte — the documented rollback.
/// </summary>
[Fact]
public async Task A_zero_timeout_disables_the_deadline()
{
var (presented, inner) = SetUp();
var gate = new TaskCompletionSource();
inner.Gate = gate.Task;
var auth = Auth(Bounded(inner, timeoutMs: 0, cacheSeconds: 0));
var pending = auth.AuthenticateUserNameAsync("apikey", presented, CancellationToken.None);
// Far longer than any deadline this class configures; an active bound would have denied by now.
await Task.Delay(300);
pending.IsCompleted.ShouldBeFalse("a disabled deadline must not cancel a slow lookup");
gate.SetResult();
(await pending.WaitAsync(TimeSpan.FromSeconds(5))).Success.ShouldBeTrue();
}
/// <summary>The anonymous activation path gets the same deadline, and the same denial.</summary>
/// <remarks>
/// It runs on every unauthenticated connect, so leaving it unbounded would leave the
/// server-wide-stall hazard fully open on the busiest activation path.
/// </remarks>
[Fact]
public async Task Anonymous_grant_resolution_is_bounded_too()
{
var (_, inner) = SetUp();
inner.BlockForever = true;
var auth = Auth(Bounded(inner, timeoutMs: 150, cacheSeconds: 0));
var result = await auth.AuthenticateAnonymousAsync(CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(5));
result.Success.ShouldBeFalse();
}
// ---------- helpers ----------
private static BoundedApiKeyStore Bounded(
IApiKeyStore inner, int timeoutMs, int cacheSeconds, TimeProvider? clock = null) => new(
inner,
Options.Create(new ApiKeyOptions
{
Username = "apikey",
Pepper = Pepper,
StoreTimeoutMs = timeoutMs,
StoreCacheSeconds = cacheSeconds,
}),
NullLogger<BoundedApiKeyStore>.Instance,
clock ?? TimeProvider.System);
private static ApiKeyOpcUaUserAuthenticator Auth(IApiKeyStore store) => new(
store,
Options.Create(new ApiKeyOptions { Username = "apikey", Pepper = Pepper }),
NullLogger<ApiKeyOpcUaUserAuthenticator>.Instance);
private static (string Presented, GatedApiKeyStore Store) SetUp()
{
var (presented, apiKeyId, hash) = ApiKeySecret.Generate(Pepper);
var key = new ApiKey
{
ApiKeyId = apiKeyId,
Name = "test key",
SecretHash = hash,
Principal = Principal,
Enabled = true,
CreatedUtc = DateTime.UtcNow,
};
return (presented, new GatedApiKeyStore(key));
}
/// <summary>
/// An inner store whose answer can be withheld — a hang the EF in-memory provider cannot
/// represent — and whose calls are counted.
/// </summary>
private sealed class GatedApiKeyStore(ApiKey key) : IApiKeyStore
{
/// <summary>When set, every call awaits this before answering.</summary>
public Task? Gate { get; set; }
/// <summary>When true, no call ever completes.</summary>
public bool BlockForever { get; set; }
/// <summary>How many calls reached this store (i.e. were not served from the cache).</summary>
public int Calls => _calls;
/// <summary>When true, the key lookup answers "no usable key" — a revocation.</summary>
public bool Revoked { get; set; }
/// <summary>Number of leading calls that throw before the store starts answering.</summary>
public int ThrowsRemaining { get; set; }
private int _calls;
public async Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct)
{
await WaitAsync(ct).ConfigureAwait(false);
if (Revoked) return null;
return string.Equals(apiKeyId, key.ApiKeyId, StringComparison.Ordinal)
? new ApiKeyLookup(key, [])
: null;
}
public async Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct)
{
await WaitAsync(ct).ConfigureAwait(false);
return [];
}
private async Task WaitAsync(CancellationToken ct)
{
Interlocked.Increment(ref _calls);
if (ThrowsRemaining > 0)
{
ThrowsRemaining--;
throw new InvalidOperationException("store is down");
}
if (BlockForever) await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
if (Gate is { } gate) await gate.WaitAsync(ct).ConfigureAwait(false);
}
}
/// <summary>
/// A hand-advanced clock. Overrides <c>GetUtcNow()</c> only — see the class remarks for why that
/// confines it to the TTL cases.
/// </summary>
private sealed class FakeClock(DateTimeOffset utcNow) : TimeProvider
{
private DateTimeOffset _now = utcNow;
/// <inheritdoc />
public override DateTimeOffset GetUtcNow() => _now;
/// <summary>Moves the clock forward.</summary>
/// <param name="by">How far forward to move.</param>
public void Advance(TimeSpan by) => _now = _now.Add(by);
}
}