feat(ldap): failover observability — warn per hop, info on sticky change

This commit is contained in:
Joseph Doherty
2026-08-10 16:38:38 -04:00
parent 174ad952dc
commit 92ff6a0d8c
6 changed files with 158 additions and 18 deletions
@@ -49,11 +49,16 @@ public static class ServiceCollectionExtensions
// Fail fast at startup on a misconfigured directory rather than on first login.
services.AddSingleton<IValidateOptions<LdapOptions>, LdapOptionsValidator>();
// LdapAuthService is stateless: it holds only a snapshot of LdapOptions and a stateless
// connection factory, and opens/disposes a connection per call. It is not IDisposable.
// Singleton is correct; TryAdd mirrors the pattern in AddZbApiKeyAuth (idempotency).
// LdapAuthService opens/disposes a connection per call and is not IDisposable. Its only
// mutable state is the sticky endpoint preference used by FallbackServers failover —
// which is precisely why the instance must be shared, so a primary-DC outage is paid
// for once rather than on every login. Singleton is correct; TryAdd mirrors the pattern
// in AddZbApiKeyAuth (idempotency). The logger is resolved with GetService, not
// GetRequiredService, so a host without logging configured still resolves the service.
services.TryAddSingleton<ILdapAuthService>(sp =>
new LdapAuthService(sp.GetRequiredService<IOptions<LdapOptions>>().Value));
new LdapAuthService(
sp.GetRequiredService<IOptions<LdapOptions>>().Value,
sp.GetService<Microsoft.Extensions.Logging.ILogger<LdapAuthService>>()));
return services;
}
@@ -1,3 +1,4 @@
using Microsoft.Extensions.Logging;
using Novell.Directory.Ldap;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Ldap.Internal;
@@ -43,6 +44,7 @@ public sealed class LdapAuthService : ILdapAuthService
private readonly LdapOptions _options;
private readonly ILdapConnectionFactory _connectionFactory;
private readonly IReadOnlyList<LdapEndpoint> _endpoints;
private readonly ILogger<LdapAuthService>? _logger;
/// <summary>
/// Index into <see cref="_endpoints"/> of the endpoint tried first. Written on any
@@ -58,9 +60,15 @@ public sealed class LdapAuthService : ILdapAuthService
/// <see cref="LdapOptions.AllowInsecure"/> bypass) is carried into the factory so each
/// connection is built with it.
/// </summary>
public LdapAuthService(LdapOptions options)
/// <param name="options">The directory connection and search settings.</param>
/// <param name="logger">
/// Optional. When supplied, endpoint failover is made visible: a Warning per failed
/// endpoint hop and an Information line when the sticky preference moves. Omitting it
/// (the default) leaves the service silent — hosts without logging still work.
/// </param>
public LdapAuthService(LdapOptions options, ILogger<LdapAuthService>? logger = null)
: this(options, new NovellLdapConnectionFactory(
options.AllowInsecure, options.ServerCertificateValidationCallback))
options.AllowInsecure, options.ServerCertificateValidationCallback), logger)
{
}
@@ -69,10 +77,14 @@ public sealed class LdapAuthService : ILdapAuthService
/// so the bind/search logic can be exercised without a live directory. Internal
/// because the connection seam is an implementation detail.
/// </summary>
internal LdapAuthService(LdapOptions options, ILdapConnectionFactory connectionFactory)
internal LdapAuthService(
LdapOptions options,
ILdapConnectionFactory connectionFactory,
ILogger<LdapAuthService>? logger = null)
{
_options = options;
_connectionFactory = connectionFactory;
_logger = logger;
_endpoints = BuildEndpoints(options);
}
@@ -158,7 +170,10 @@ public sealed class LdapAuthService : ILdapAuthService
// Updating only on Succeeded would re-pay the dead primary's connect timeout on
// every mistyped password for the whole outage.
if (index != preferred)
{
Volatile.Write(ref _preferredIndex, index);
_logger?.LogInformation("LDAP sticky preference moved to {Endpoint}.", _endpoints[index]);
}
return result;
}
@@ -291,13 +306,19 @@ public sealed class LdapAuthService : ILdapAuthService
}
catch (StageFailure stage)
{
// A stage mapped its own failure; surface it as a structured result.
// A stage mapped its own failure; surface it as a structured result. Log at Warning
// so a silent failover — the failure mode this feature exists to survive — is
// visible in the host's log rather than only in a latency graph.
_logger?.LogWarning(
"LDAP endpoint {Endpoint} failed system-side at {Stage}; treating as no answer.",
endpoint, stage.Stage);
return LdapAuthResult.Fail(stage.Failure);
}
catch
catch (Exception ex)
{
// Belt-and-braces: ANY unexpected exception fails closed to the most conservative
// system-side bucket rather than propagating to the caller.
_logger?.LogWarning(ex, "LDAP endpoint {Endpoint} failed system-side with an unexpected error.", endpoint);
return LdapAuthResult.Fail(LdapAuthFailure.ServiceAccountBindFailed);
}
}
@@ -22,6 +22,7 @@
<ItemGroup>
<PackageReference Include="Novell.Directory.Ldap.NETStandard" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>