fix(security): bound the API-key store call under the SessionManager lock (archreview 03/S18)
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Bounds what an <see cref="IApiKeyStore"/> can cost the OPC UA impersonation callback (arch-review
|
||||
/// 03/S18). Wraps <see cref="ConfigDbApiKeyStore"/> on a node that has a ConfigDb; the in-memory
|
||||
/// <see cref="ArtifactApiKeyStore"/> needs neither half and is deliberately left unwrapped.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why a deadline is the fix and the cache is not.</b> The SDK raises impersonation
|
||||
/// synchronously under a <c>SessionManager</c>-wide lock, so a store that does not answer does not
|
||||
/// stall one activation — it stalls every activation in the server. A cache lowers how OFTEN the
|
||||
/// store is asked; only a deadline bounds how LONG one ask may hold that lock.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>Every bounded outcome DENIES.</b> A deadline exceeded, a faulted inner store and a
|
||||
/// cancelled call all surface as an exception into
|
||||
/// <see cref="ApiKeyOpcUaUserAuthenticator"/>'s catch-all, which returns the same opaque denial as
|
||||
/// a bad credential. Nothing here can admit a session the inner store did not authorize — the
|
||||
/// decorator can only return what the inner store returned, or throw.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class BoundedApiKeyStore : IApiKeyStore
|
||||
{
|
||||
private readonly IApiKeyStore _inner;
|
||||
private readonly IOptions<ApiKeyOptions> _options;
|
||||
private readonly ILogger<BoundedApiKeyStore> _log;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="BoundedApiKeyStore"/> class.</summary>
|
||||
/// <param name="inner">The store being bounded.</param>
|
||||
/// <param name="options">The <c>Security:ApiKeys</c> options carrying the deadline and TTL.</param>
|
||||
/// <param name="log">The logger.</param>
|
||||
/// <param name="time">
|
||||
/// The shared clock. The same instance the authorizer reads — see
|
||||
/// <c>DataPlaneAuthRegistration</c> for why a second clock here would be a hazard rather than a
|
||||
/// detail.
|
||||
/// </param>
|
||||
public BoundedApiKeyStore(
|
||||
IApiKeyStore inner,
|
||||
IOptions<ApiKeyOptions> options,
|
||||
ILogger<BoundedApiKeyStore> log,
|
||||
TimeProvider time)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(inner);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(log);
|
||||
ArgumentNullException.ThrowIfNull(time);
|
||||
|
||||
_inner = inner;
|
||||
_options = options;
|
||||
_log = log;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) =>
|
||||
BoundAsync(token => _inner.FindAsync(apiKeyId, utcNow, token), ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct) =>
|
||||
BoundAsync(token => _inner.FindGrantsAsync(principal, token), ct);
|
||||
|
||||
/// <summary>Runs one inner call under the configured deadline.</summary>
|
||||
/// <typeparam name="T">The inner call's result type.</typeparam>
|
||||
/// <param name="call">The inner call, taking the deadline-linked token.</param>
|
||||
/// <param name="ct">The caller's token — in production always <c>None</c>, see remarks.</param>
|
||||
/// <returns>Whatever the inner store returned.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The deadline is created HERE rather than received from the caller because there is no caller
|
||||
/// to receive one from: <c>OpcUaApplicationHost</c> block-bridges the SDK's synchronous
|
||||
/// impersonation callback with <see cref="CancellationToken.None"/>, so the <c>ct</c> the store
|
||||
/// sees in production is always uncancellable.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>It re-throws rather than returning <see langword="null"/>.</b> <c>null</c> means
|
||||
/// "no such usable key", which the authenticator logs as <i>denied — no enabled, unexpired key
|
||||
/// with id X</i>. A timeout is not that, and an operator reading that line during a ConfigDb
|
||||
/// outage would go hunting for a key problem that does not exist. The distinct Warning below is
|
||||
/// the honest signal; the denial itself is unchanged either way.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<T> BoundAsync<T>(Func<CancellationToken, Task<T>> call, CancellationToken ct)
|
||||
{
|
||||
var timeoutMs = _options.Value.StoreTimeoutMs;
|
||||
if (timeoutMs <= 0) return await call(ct).ConfigureAwait(false);
|
||||
|
||||
// The deadline runs on the INJECTED clock (the TimeProvider ctor overload), not on a plain
|
||||
// CancelAfter, so it is the same TimeProvider the authorizer and the revalidation timer read.
|
||||
// Linked with the caller's token so a genuine caller cancellation is still honoured — and so the
|
||||
// catch below can tell the two apart.
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromMilliseconds(timeoutMs), _time);
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct, deadline.Token);
|
||||
|
||||
try
|
||||
{
|
||||
return await call(cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
_log.LogWarning(
|
||||
"ApiKeyStore: the key store did not answer within {TimeoutMs} ms — denying this activation. "
|
||||
+ "Every OPC UA session activation on this server is serialized behind this call; "
|
||||
+ "check ConfigDb health.",
|
||||
timeoutMs);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user