e04156d8f7
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
57 lines
2.8 KiB
C#
57 lines
2.8 KiB
C#
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;
|
|
}
|
|
}
|