feat(ldap): FallbackServers option + boot-time validation

This commit is contained in:
Joseph Doherty
2026-08-10 16:33:32 -04:00
parent 5fbf53601c
commit 8997a46b74
3 changed files with 59 additions and 2 deletions
@@ -19,6 +19,17 @@ public sealed record LdapOptions
public string GroupAttribute { get; init; } = "memberOf";
public int ConnectionTimeoutMs { get; init; } = 10_000;
/// <summary>
/// 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 <c>"host"</c> (adopts <see cref="Port"/>) or <c>"host:port"</c>.
/// Empty (the default) disables failover entirely — single-endpoint behavior is
/// unchanged. The TLS validation policy (<see cref="ServerCertificateValidationCallback"/>
/// / <see cref="AllowInsecure"/>) applies to every endpoint. Endpoint preference is
/// sticky: the service keeps using the last endpoint that answered until it fails.
/// </summary>
public IReadOnlyList<string> FallbackServers { get; init; } = Array.Empty<string>();
/// <summary>
/// Optional hook to harden (or, in dev, relax) TLS server-certificate validation for the
/// <see cref="LdapTransport.Ldaps"/> / <see cref="LdapTransport.StartTls"/> transports. When
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.Auth.Ldap;
/// </summary>
/// <remarks>
/// Validation is skipped entirely when <see cref="LdapOptions.Enabled"/> is <c>false</c>
/// (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:
/// <list type="bullet">
/// <item>plaintext transport (<see cref="LdapTransport.None"/>) is rejected unless
@@ -19,7 +19,10 @@ namespace ZB.MOM.WW.Auth.Ldap;
/// <item><see cref="LdapOptions.SearchBase"/> must be specified (the DN root every
/// search runs against);</item>
/// <item><see cref="LdapOptions.ServiceAccountDn"/> must be specified — an empty value
/// would bind anonymously, defeating the search-then-bind authentication flow.</item>
/// would bind anonymously, defeating the search-then-bind authentication flow;</item>
/// <item>every <see cref="LdapOptions.FallbackServers"/> entry must parse as
/// <c>"host"</c> or <c>"host:port"</c> — a typo'd backup DC must not lie dormant
/// until the primary outage it exists to survive.</item>
/// </list>
/// </remarks>
public sealed class LdapOptionsValidator : IValidateOptions<LdapOptions>
@@ -68,6 +71,21 @@ public sealed class LdapOptionsValidator : IValidateOptions<LdapOptions>
"(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;
}
}
@@ -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);
}