feat(security): register TimeProvider + S18/S19 option knobs (archreview 03/S18,S19)

This commit is contained in:
Joseph Doherty
2026-08-20 09:36:04 -04:00
parent dd7f495432
commit 894444376e
9 changed files with 266 additions and 54 deletions
@@ -0,0 +1,100 @@
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 (#520), 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);
// #520 stage 5 — 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.
services.AddSingleton<IApiKeyStore>(sp => new ConfigDbApiKeyStore(
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>(),
ConfigDbApiKeyStore.ResolveClusterId(sp.GetRequiredService<IOptions<AkkaClusterOptions>>().Value)));
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;
}
}
+4 -49
View File
@@ -398,55 +398,10 @@ if (hasDriver)
builder.Services.AddSingleton<PermissionTrieCache>();
}
// #520 stage 5 — 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 above
// both carry a comment about; an absent registration is a node that will not start.
builder.Services.AddSingleton<IPermissionEvaluator>(sp =>
new TriePermissionEvaluator(sp.GetRequiredService<PermissionTrieCache>()));
builder.Services.AddSingleton(sp => new DataPlaneAuthorizer(
sp.GetRequiredService<IDataPlaneAuthoritySource>(),
sp.GetRequiredService<IPermissionEvaluator>(),
sp.GetRequiredService<IOptions<AclOptions>>().Value.Enabled,
sp.GetRequiredService<ILoggerFactory>().CreateLogger<DataPlaneAuthorizer>()));
if (hasAdmin)
{
// A node WITH a ConfigDb reads keys straight from it, which is what makes revocation take effect
// on the very next request 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 below from recursing into ActorSystem
// construction. Null on a node with no cluster-{ClusterId} role ⇒ no filter (legacy behaviour).
builder.Services.AddSingleton<IApiKeyStore>(sp => new ConfigDbApiKeyStore(
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>(),
ConfigDbApiKeyStore.ResolveClusterId(sp.GetRequiredService<IOptions<AkkaClusterOptions>>().Value)));
builder.Services.AddSingleton<IOpcUaUserAuthenticator, ApiKeyOpcUaUserAuthenticator>();
// Minting surface for the AdminUI. Same node condition as the store above: both need a ConfigDb.
builder.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.
builder.Services.AddSingleton<IApiKeyStore, ArtifactApiKeyStore>();
builder.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.
builder.Services.AddSingleton<IOpcUaUserAuthenticator>(NullOpcUaUserAuthenticator.Instance);
}
// #520 + arch-review 03/S18 — the node-scoped ACL gate, the shared clock and the role-appropriate
// API-key store + user authenticator, all in one extension so the role-conditional branches are
// reachable from a test (Program.cs's top-level statements are not). See DataPlaneAuthRegistration.
builder.Services.AddOtOpcUaDataPlaneAuth(hasAdmin, hasDriver);
// Bind + validate the OPC UA host options the same way (fail-fast at start via ValidateOnStart)
// and let OtOpcUaServerHostedService consume the validated IOptions instance rather than
@@ -108,6 +108,15 @@ public sealed class DataPlaneAuthorizer
/// </summary>
public bool Enabled { get; }
/// <summary>The clock this gate evaluates key expiry against.</summary>
/// <remarks>
/// Exposed so the composition root's wiring can be asserted by reference. The remarks on
/// <c>UserAuthorizationState</c> record what a second, diverging clock costs: every request denied,
/// with a verdict indistinguishable from "no grant matched". One registered singleton threaded into
/// every consumer is the fix, and a test that cannot see which instance landed here cannot pin it.
/// </remarks>
public TimeProvider Clock => _time;
/// <summary>
/// Memoized grant walks for the request-rate seams (#520 stage 6). See
/// <see cref="SessionAclDecisionCache"/> for what it is and is not allowed to cache.