feat(ldap): FallbackServers option + boot-time validation
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user