diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs index c662e96c..0d3f0377 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs @@ -10,8 +10,9 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Configuration; /// TCP port; when disabled — or when DevStubMode bypasses the real bind — all checks are /// skipped. ServiceAccountDn/Password are /// intentionally not required — an empty pair selects the direct-bind path (see -/// ). Failure messages carry the real "Ldap:" -/// section prefix matching the bound configuration section. +/// ). Failure messages use "Ldap:" as a +/// human-readable field prefix — not the literal bound section path, which is +/// Security:Ldap (see ). /// public sealed class LdapOptionsValidator : OptionsValidatorBase { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 482e6ddb..82dcf61b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -1,4 +1,5 @@ using Akka.Hosting; +using Microsoft.Extensions.DependencyInjection.Extensions; using Serilog; using ZB.MOM.WW.OtOpcUa.AdminUI; using ZB.MOM.WW.OtOpcUa.AdminUI.Clients; @@ -100,7 +101,9 @@ if (hasDriver) builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddValidatedOptions(builder.Configuration, LdapOptions.SectionName); - builder.Services.AddSingleton(); + // TryAdd so a fused admin+driver node (where AddOtOpcUaAuth also registers this) ends up + // with exactly one descriptor; on a driver-only node this is the sole registration. + builder.Services.TryAddSingleton(); builder.Services.AddSingleton(); // Bind + validate the OPC UA host options the same way (fail-fast at start via ValidateOnStart) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthService.cs index f09e4292..bf3e4ea7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthService.cs @@ -21,6 +21,11 @@ public sealed class LdapAuthService(IOptions options, ILoggerA cancellation token to observe while waiting for the operation to complete. public async Task AuthenticateAsync(string username, string password, CancellationToken ct = default) { + // Enabled is the master switch and wins over DevStubMode — when LDAP auth is turned off, + // refuse to authenticate at all (no bind, no dev-stub bypass). + if (!_options.Enabled) + return new(false, null, null, [], [], "LDAP authentication is disabled."); + if (string.IsNullOrWhiteSpace(username)) return new(false, null, null, [], [], "Username is required"); if (string.IsNullOrWhiteSpace(password)) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs index 9709b78c..fa715648 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ZB.MOM.WW.OtOpcUa.Configuration; @@ -36,7 +37,9 @@ public static class ServiceCollectionExtensions services.AddSingleton(); // Singleton — LdapAuthService is stateless (creates an LdapConnection per call) and // must be consumable by the Singleton LdapOpcUaUserAuthenticator on driver-role nodes. - services.AddSingleton(); + // TryAdd so a fused admin+driver node (which also registers it in Program.cs for the + // driver path) ends up with exactly one descriptor regardless of registration order. + services.TryAddSingleton(); services.AddDataProtection() .PersistKeysToDbContext() diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsValidatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsValidatorTests.cs index 99f8d5ad..1de27607 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsValidatorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsValidatorTests.cs @@ -83,6 +83,24 @@ public sealed class LdapOptionsValidatorTests result.Failures.ShouldContain("Ldap:Server is required when LDAP login is enabled."); } + /// Enabled with a blank search base reports the required-search-base failure. + [Fact] + public void Enabled_with_blank_search_base_fails() + { + var options = new LdapOptions + { + Enabled = true, + Server = "ldap", + SearchBase = string.Empty, + Port = 389, + }; + + var result = Sut.Validate(null, options); + + result.Failed.ShouldBeTrue(); + result.Failures.ShouldContain("Ldap:SearchBase is required when LDAP login is enabled."); + } + /// Enabled with port 0 reports the port-range failure using the shared primitive wording. [Fact] public void Enabled_with_zero_port_fails()