From 8997a46b748dd51887c4eb9baf3cddfba7da463b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 10 Aug 2026 16:33:32 -0400 Subject: [PATCH] feat(ldap): FallbackServers option + boot-time validation --- .../Ldap/LdapContracts.cs | 11 ++++++++ .../LdapOptionsValidator.cs | 22 +++++++++++++-- .../LdapOptionsValidatorTests.cs | 28 +++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs index bc89047..f143205 100644 --- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs @@ -19,6 +19,17 @@ public sealed record LdapOptions public string GroupAttribute { get; init; } = "memberOf"; public int ConnectionTimeoutMs { get; init; } = 10_000; + /// + /// Ordered fallback LDAP endpoints tried when the primary (and any earlier fallback) + /// fails with a system-side error (connect/TLS, service-account bind, or search). + /// Each entry is "host" (adopts ) or "host:port". + /// Empty (the default) disables failover entirely — single-endpoint behavior is + /// unchanged. The TLS validation policy ( + /// / ) applies to every endpoint. Endpoint preference is + /// sticky: the service keeps using the last endpoint that answered until it fails. + /// + public IReadOnlyList FallbackServers { get; init; } = Array.Empty(); + /// /// Optional hook to harden (or, in dev, relax) TLS server-certificate validation for the /// / transports. When diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapOptionsValidator.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapOptionsValidator.cs index 71102b0..c5fdf08 100644 --- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapOptionsValidator.cs +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapOptionsValidator.cs @@ -10,7 +10,7 @@ namespace ZB.MOM.WW.Auth.Ldap; /// /// /// Validation is skipped entirely when is false -/// (a disabled provider's connection fields are inert). When enabled, four conditions +/// (a disabled provider's connection fields are inert). When enabled, five conditions /// are enforced: /// /// plaintext transport () is rejected unless @@ -19,7 +19,10 @@ namespace ZB.MOM.WW.Auth.Ldap; /// must be specified (the DN root every /// search runs against); /// must be specified — an empty value -/// would bind anonymously, defeating the search-then-bind authentication flow. +/// would bind anonymously, defeating the search-then-bind authentication flow; +/// every entry must parse as +/// "host" or "host:port" — a typo'd backup DC must not lie dormant +/// until the primary outage it exists to survive. /// /// public sealed class LdapOptionsValidator : IValidateOptions @@ -68,6 +71,21 @@ public sealed class LdapOptionsValidator : IValidateOptions "(e.g. \"cn=svc,dc=example,dc=com\")."); } + // Fallback endpoints are walked by LdapAuthService on system-side failures. A + // malformed entry would be silently skipped there (the walk degrades to the + // parseable subset rather than throwing), so it must be caught at boot instead — + // otherwise an operator's typo'd backup DC would only surface as a failed + // failover during the primary outage it was meant to survive. + foreach (var entry in options.FallbackServers) + { + if (!Internal.LdapEndpointParser.TryParse(entry, options.Port, out _, out var error)) + { + return ValidateOptionsResult.Fail( + $"{nameof(LdapOptions.FallbackServers)} contains an invalid entry — {error}. " + + "Each entry must be \"host\" or \"host:port\" (port 1–65535)."); + } + } + return ValidateOptionsResult.Success; } } diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapOptionsValidatorTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapOptionsValidatorTests.cs index 6535bff..144a64e 100644 --- a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapOptionsValidatorTests.cs +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapOptionsValidatorTests.cs @@ -88,4 +88,32 @@ public class LdapOptionsValidatorTests ServiceAccountDn = "", }) .Failed); + + [Fact] + public void Validator_Succeeds_WithValidFallbackServers() => + Assert.False(new LdapOptionsValidator() + .Validate(null, Opts() with { FallbackServers = new[] { "dc2", "dc3:3894" } }) + .Failed); + + [Theory] + [InlineData("")] + [InlineData(":636")] + [InlineData("dc2:0")] + [InlineData("dc2:notaport")] + public void Validator_Fails_OnMalformedFallbackServer(string entry) + { + var result = new LdapOptionsValidator() + .Validate(null, Opts() with { FallbackServers = new[] { entry } }); + + Assert.True(result.Failed); + Assert.Contains(nameof(LdapOptions.FallbackServers), result.FailureMessage); + } + + [Fact] + public void Validator_Skips_FallbackServers_WhenDisabled() => + // Same inert-when-disabled rule as every other connection field: a disabled + // provider never opens a connection, so its fallback list is never walked. + Assert.False(new LdapOptionsValidator() + .Validate(null, Opts() with { Enabled = false, FallbackServers = new[] { ":::" } }) + .Failed); }