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; /// /// Composition of the OPC UA data-plane authentication + authorization seams, extracted from /// Program.cs so the role-conditional branches are reachable from a test. /// /// /// A store decorator registered in one branch of a role conditional is exactly the /// "built but never wired" shape, and Program.cs'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 ApiKeyStoreRegistrationTests. /// public static class DataPlaneAuthRegistration { /// /// Registers the shared clock, the node-scoped ACL gate and the role-appropriate API-key store + /// user authenticator. /// /// The service collection. /// Whether this node carries the admin role (and therefore a ConfigDb). /// Whether this node carries the driver role. /// The same collection, for chaining. 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(sp => new TriePermissionEvaluator(sp.GetRequiredService())); services.AddSingleton(sp => new DataPlaneAuthorizer( sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService>().Value.Enabled, sp.GetRequiredService().CreateLogger(), sp.GetRequiredService())); 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(sp => new BoundedApiKeyStore( new ConfigDbApiKeyStore( sp.GetRequiredService>(), ConfigDbApiKeyStore.ResolveClusterId( sp.GetRequiredService>().Value)), sp.GetRequiredService>(), sp.GetRequiredService().CreateLogger(), sp.GetRequiredService(), // 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()))); services.AddSingleton(); // Minting surface for the AdminUI. Same node condition as the store above: both need a ConfigDb. services.AddSingleton(sp => new ApiKeyAdminService( sp.GetRequiredService>(), sp.GetRequiredService>().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(); services.AddSingleton(); } 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(NullOpcUaUserAuthenticator.Instance); } return services; } }