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 string GroupAttribute { get; init; } = "memberOf";
|
||||||
public int ConnectionTimeoutMs { get; init; } = 10_000;
|
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>
|
/// <summary>
|
||||||
/// Optional hook to harden (or, in dev, relax) TLS server-certificate validation for the
|
/// Optional hook to harden (or, in dev, relax) TLS server-certificate validation for the
|
||||||
/// <see cref="LdapTransport.Ldaps"/> / <see cref="LdapTransport.StartTls"/> transports. When
|
/// <see cref="LdapTransport.Ldaps"/> / <see cref="LdapTransport.StartTls"/> transports. When
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.Auth.Ldap;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Validation is skipped entirely when <see cref="LdapOptions.Enabled"/> is <c>false</c>
|
/// 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:
|
/// are enforced:
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item>plaintext transport (<see cref="LdapTransport.None"/>) is rejected unless
|
/// <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
|
/// <item><see cref="LdapOptions.SearchBase"/> must be specified (the DN root every
|
||||||
/// search runs against);</item>
|
/// search runs against);</item>
|
||||||
/// <item><see cref="LdapOptions.ServiceAccountDn"/> must be specified — an empty value
|
/// <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>
|
/// </list>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class LdapOptionsValidator : IValidateOptions<LdapOptions>
|
public sealed class LdapOptionsValidator : IValidateOptions<LdapOptions>
|
||||||
@@ -68,6 +71,21 @@ public sealed class LdapOptionsValidator : IValidateOptions<LdapOptions>
|
|||||||
"(e.g. \"cn=svc,dc=example,dc=com\").");
|
"(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;
|
return ValidateOptionsResult.Success;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,4 +88,32 @@ public class LdapOptionsValidatorTests
|
|||||||
ServiceAccountDn = "",
|
ServiceAccountDn = "",
|
||||||
})
|
})
|
||||||
.Failed);
|
.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