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
115 lines
7.2 KiB
C#
115 lines
7.2 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.OtOpcUa.Cluster;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
|
|
/// <summary>
|
|
/// Composition of the OPC UA data-plane authentication + authorization seams, extracted from
|
|
/// <c>Program.cs</c> so the role-conditional branches are reachable from a test.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A store decorator registered in one branch of a role conditional is exactly the
|
|
/// "built but never wired" shape, and <c>Program.cs</c>'s top-level statements cannot be driven by a
|
|
/// test — so the wiring moved here rather than the guard being written against a comment. The
|
|
/// branches are unchanged from the ones that lived inline; see <c>ApiKeyStoreRegistrationTests</c>.
|
|
/// </remarks>
|
|
public static class DataPlaneAuthRegistration
|
|
{
|
|
/// <summary>
|
|
/// Registers the shared clock, the node-scoped ACL gate and the role-appropriate API-key store +
|
|
/// user authenticator.
|
|
/// </summary>
|
|
/// <param name="services">The service collection.</param>
|
|
/// <param name="hasAdmin">Whether this node carries the <c>admin</c> role (and therefore a ConfigDb).</param>
|
|
/// <param name="hasDriver">Whether this node carries the <c>driver</c> role.</param>
|
|
/// <returns>The same collection, for chaining.</returns>
|
|
public static IServiceCollection AddOtOpcUaDataPlaneAuth(
|
|
this IServiceCollection services, bool hasAdmin, bool hasDriver)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(services);
|
|
|
|
// ONE clock for the whole authorization surface. The authorizer, the key-store bound and the
|
|
// revalidation timer all read time, and DataPlaneAuthorizer already documents what a second,
|
|
// diverging clock costs: every request denied, with a verdict indistinguishable from "no grant
|
|
// matched". TryAdd so a test (or a future host) can substitute one before this runs.
|
|
services.TryAddSingleton(TimeProvider.System);
|
|
|
|
// the node-scoped ACL gate. Registered on EVERY node, not just driver ones, because
|
|
// OtOpcUaServerHostedService takes it as a constructor parameter and the built-in container resolves
|
|
// constructors by whether every parameter is registered, not by whether the parameter type is nullable.
|
|
// That is the same trap the IOpcUaUserAuthenticator and IDataPlaneAuthoritySource registrations below
|
|
// both carry a comment about; an absent registration is a node that will not start.
|
|
services.AddSingleton<IPermissionEvaluator>(sp =>
|
|
new TriePermissionEvaluator(sp.GetRequiredService<PermissionTrieCache>()));
|
|
services.AddSingleton(sp => new DataPlaneAuthorizer(
|
|
sp.GetRequiredService<IDataPlaneAuthoritySource>(),
|
|
sp.GetRequiredService<IPermissionEvaluator>(),
|
|
sp.GetRequiredService<IOptions<AclOptions>>().Value.Enabled,
|
|
sp.GetRequiredService<ILoggerFactory>().CreateLogger<DataPlaneAuthorizer>(),
|
|
sp.GetRequiredService<TimeProvider>()));
|
|
|
|
if (hasAdmin)
|
|
{
|
|
// A node WITH a ConfigDb reads keys straight from it, which is what makes revocation take effect
|
|
// on the very next activation rather than on the next deployment. Preferred wherever it is
|
|
// available, including on a fused admin+driver node.
|
|
// The ClusterId confines the grants this node honours to its OWN cluster — the ConfigDb holds
|
|
// the whole fleet's ACL rows, unlike the artifact a driver-only node reads, which
|
|
// ParseDataPlaneAuthority already scoped. Taken from AkkaClusterOptions (pure configuration),
|
|
// NOT from IClusterRoleInfo: that depends on the ActorSystem, and the same options-not-services
|
|
// rule is what keeps the redundancy-singleton registration in Program.cs from recursing into
|
|
// ActorSystem construction. Null on a node with no cluster-{ClusterId} role ⇒ no filter.
|
|
//
|
|
// arch-review 03/S18 — and it is WRAPPED. The lookup runs inline in the SDK's synchronous
|
|
// impersonation callback, under a SessionManager-wide lock, with CancellationToken.None: an
|
|
// unanswered ConfigDb query therefore stalls every session activation in the server. The
|
|
// decorator adds the deadline that bounds that, plus a short TTL that turns a burst of
|
|
// activations into one round trip. It cannot admit anything the inner store did not.
|
|
services.AddSingleton<IApiKeyStore>(sp => new BoundedApiKeyStore(
|
|
new ConfigDbApiKeyStore(
|
|
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>(),
|
|
ConfigDbApiKeyStore.ResolveClusterId(
|
|
sp.GetRequiredService<IOptions<AkkaClusterOptions>>().Value)),
|
|
sp.GetRequiredService<IOptions<ApiKeyOptions>>(),
|
|
sp.GetRequiredService<ILoggerFactory>().CreateLogger<BoundedApiKeyStore>(),
|
|
sp.GetRequiredService<TimeProvider>(),
|
|
// R03:3.3 — the known-id set + miss budget in front of the SQL lookup; same section as
|
|
// ApiKeyOptions, Host-owned knobs.
|
|
ApiKeyStoreGuardOptions.Bind(sp.GetService<IConfiguration>())));
|
|
services.AddSingleton<IOpcUaUserAuthenticator, ApiKeyOpcUaUserAuthenticator>();
|
|
|
|
// Minting surface for the AdminUI. Same node condition as the store above: both need a ConfigDb.
|
|
services.AddSingleton<IApiKeyAdminService>(sp => new ApiKeyAdminService(
|
|
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>(),
|
|
sp.GetRequiredService<IOptions<ApiKeyOptions>>().Value.Pepper));
|
|
}
|
|
else if (hasDriver)
|
|
{
|
|
// Phase 4 left a driver-only node with no ConfigDb, so its keys ride the deployment artifact.
|
|
// Before stage 4 this node fell through to NullOpcUaUserAuthenticator and denied EVERY UserName
|
|
// token — safe, but it meant no machine client could authenticate against a site node at all.
|
|
// Deliberately NOT wrapped by the S18 bound: the artifact store is two dictionary probes on an
|
|
// in-memory snapshot, so it can neither hang nor be worth caching.
|
|
services.AddSingleton<IApiKeyStore, ArtifactApiKeyStore>();
|
|
services.AddSingleton<IOpcUaUserAuthenticator, ApiKeyOpcUaUserAuthenticator>();
|
|
}
|
|
else
|
|
{
|
|
// Neither role: nothing serves a data plane here. MUST be registered explicitly, not left absent —
|
|
// OtOpcUaServerHostedService takes IOpcUaUserAuthenticator as a REQUIRED constructor parameter, so
|
|
// an unregistered seam is not a quiet fallback to deny-all; it fails DI and the node does not start.
|
|
services.AddSingleton<IOpcUaUserAuthenticator>(NullOpcUaUserAuthenticator.Instance);
|
|
}
|
|
|
|
return services;
|
|
}
|
|
}
|