fix: honor LdapOptions.Enabled at runtime; dedupe ILdapAuthService registration; +SearchBase test, doc fix
v2-ci / build (push) Failing after 41s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped

This commit is contained in:
Joseph Doherty
2026-06-01 23:03:12 -04:00
parent d3ab2bfbaf
commit 2844180865
5 changed files with 34 additions and 4 deletions
@@ -10,8 +10,9 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
/// TCP port; when disabled — or when <c>DevStubMode</c> bypasses the real bind — all checks are
/// skipped. <c>ServiceAccountDn</c>/<c>Password</c> are
/// intentionally not required — an empty pair selects the direct-bind path (see
/// <see cref="LdapOptions.ServiceAccountDn"/>). Failure messages carry the real <c>"Ldap:"</c>
/// section prefix matching the bound configuration section.
/// <see cref="LdapOptions.ServiceAccountDn"/>). Failure messages use <c>"Ldap:"</c> as a
/// human-readable field prefix — not the literal bound section path, which is
/// <c>Security:Ldap</c> (see <see cref="LdapOptions.SectionName"/>).
/// </summary>
public sealed class LdapOptionsValidator : OptionsValidatorBase<LdapOptions>
{
+4 -1
View File
@@ -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<IScriptedAlarmEvaluator>(sp => sp.GetRequiredService<RoslynScriptedAlarmEvaluator>());
builder.Services.AddValidatedOptions<LdapOptions, LdapOptionsValidator>(builder.Configuration, LdapOptions.SectionName);
builder.Services.AddSingleton<ILdapAuthService, LdapAuthService>();
// 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<ILdapAuthService, LdapAuthService>();
builder.Services.AddSingleton<IOpcUaUserAuthenticator, LdapOpcUaUserAuthenticator>();
// Bind + validate the OPC UA host options the same way (fail-fast at start via ValidateOnStart)
@@ -21,6 +21,11 @@ public sealed class LdapAuthService(IOptions<LdapOptions> options, ILogger<LdapA
/// <param name="ct">A cancellation token to observe while waiting for the operation to complete.</param>
public async Task<LdapAuthResult> 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))
@@ -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<JwtTokenService>();
// Singleton — LdapAuthService is stateless (creates an LdapConnection per call) and
// must be consumable by the Singleton LdapOpcUaUserAuthenticator on driver-role nodes.
services.AddSingleton<ILdapAuthService, LdapAuthService>();
// 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<ILdapAuthService, LdapAuthService>();
services.AddDataProtection()
.PersistKeysToDbContext<OtOpcUaConfigDbContext>()
@@ -83,6 +83,24 @@ public sealed class LdapOptionsValidatorTests
result.Failures.ShouldContain("Ldap:Server is required when LDAP login is enabled.");
}
/// <summary>Enabled with a blank search base reports the required-search-base failure.</summary>
[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.");
}
/// <summary>Enabled with port 0 reports the port-range failure using the shared primitive wording.</summary>
[Fact]
public void Enabled_with_zero_port_fails()