Files
scadaproj/docs/plans/2026-08-10-ldap-backup-dc-failover.md
T

901 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# LDAP Backup-DC Failover Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Add sticky-preference multi-endpoint failover to `ZB.MOM.WW.Auth.Ldap` so a login survives a primary-DC outage (design: [`2026-08-10-ldap-backup-dc-failover-design.md`](2026-08-10-ldap-backup-dc-failover-design.md)).
**Architecture:** One additive `LdapOptions.FallbackServers` list; `LdapAuthService` (already a singleton) walks an ordered endpoint list on system-side failures only, remembering the last endpoint that gave an authoritative answer in a `volatile int`. Per-attempt connections come from the existing `ILdapConnectionFactory` (one fresh connection per endpoint attempt — Novell connections are not safely re-connectable after failure). New optional `ILogger<LdapAuthService>` makes failover visible.
**Tech Stack:** .NET 10, Novell.Directory.Ldap.NETStandard, xunit (+ Xunit.SkippableFact for the GLAuth integration suite), central package management (`Directory.Packages.props`), family version in `Directory.Build.props`.
**Repo/branch:** All work in `~/Desktop/scadaproj` (the Auth lib is a plain directory of this repo — do NOT `git init` anything). Create branch `feat/auth-ldap-failover` off `main`; merge back + push at the end (Task 8).
**Working directory for all builds/tests:** `/Users/dohertj2/Desktop/scadaproj/ZB.MOM.WW.Auth`
**Test command:** `dotnet test ZB.MOM.WW.Auth.slnx` (baseline: 215 passed + 1 skipped, 0 warnings — `TreatWarningsAsErrors` conventions apply; verify baseline before starting).
**One deliberate refinement over the design doc** (record in the design doc in Task 4): sticky preference updates on ANY authoritative answer (success *or* user-side failure like `UserNotFound`/`BadCredentials`), not only on success — a DC that answers is healthy, and updating only on success would re-pay the primary's connect timeout on every failed-password attempt during an outage.
---
### Task 1: `LdapEndpoint` + `LdapEndpointParser`
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 3
**Files:**
- Create: `ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/LdapEndpoint.cs`
- Test: `ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapEndpointParserTests.cs` (create)
**Step 1: Write the failing tests**
```csharp
using ZB.MOM.WW.Auth.Ldap.Internal;
namespace ZB.MOM.WW.Auth.Ldap.Tests;
public class LdapEndpointParserTests
{
[Theory]
[InlineData("dc2.example.com", 636, "dc2.example.com", 636)] // bare host -> default port
[InlineData("dc2.example.com:3894", 636, "dc2.example.com", 3894)] // explicit port wins
[InlineData(" dc2.example.com : 3894 ", 636, "dc2.example.com", 3894)] // whitespace tolerated
public void TryParse_ValidEntry_Succeeds(string entry, int defaultPort, string host, int port)
{
Assert.True(LdapEndpointParser.TryParse(entry, defaultPort, out var ep, out _));
Assert.Equal(host, ep.Host);
Assert.Equal(port, ep.Port);
}
[Theory]
[InlineData("")] // empty
[InlineData(" ")] // whitespace
[InlineData(":636")] // empty host
[InlineData("dc2:0")] // port below range
[InlineData("dc2:65536")] // port above range
[InlineData("dc2:abc")] // non-numeric port
[InlineData("2001:db8::1:636")] // IPv6 literal (unsupported, >1 colon)
public void TryParse_InvalidEntry_Fails(string entry)
{
Assert.False(LdapEndpointParser.TryParse(entry, 636, out _, out var error));
Assert.NotEmpty(error);
}
[Fact]
public void Endpoint_ToString_IsHostColonPort() =>
Assert.Equal("dc2:3894", new LdapEndpoint("dc2", 3894).ToString());
}
```
**Step 2: Run to verify failure**
Run: `dotnet test ZB.MOM.WW.Auth.slnx --filter "FullyQualifiedName~LdapEndpointParserTests"`
Expected: FAIL to compile (`LdapEndpoint` not defined).
**Step 3: Implement**
```csharp
namespace ZB.MOM.WW.Auth.Ldap.Internal;
/// <summary>A single LDAP server endpoint resolved from configuration.</summary>
internal sealed record LdapEndpoint(string Host, int Port)
{
public override string ToString() => $"{Host}:{Port}";
}
/// <summary>
/// Parses <c>FallbackServers</c> entries: <c>"host"</c> (adopts the caller's default port)
/// or <c>"host:port"</c>. IPv6 literals are not supported — use a DNS name.
/// </summary>
internal static class LdapEndpointParser
{
public static bool TryParse(string entry, int defaultPort, out LdapEndpoint endpoint, out string error)
{
endpoint = null!;
error = "";
if (string.IsNullOrWhiteSpace(entry))
{
error = "entry is empty or whitespace";
return false;
}
var parts = entry.Trim().Split(':');
if (parts.Length > 2)
{
error = $"entry \"{entry.Trim()}\" contains more than one ':' " +
"(IPv6 literals are not supported — use a DNS name)";
return false;
}
var host = parts[0].Trim();
if (host.Length == 0)
{
error = $"entry \"{entry.Trim()}\" has an empty host";
return false;
}
var port = defaultPort;
if (parts.Length == 2 && (!int.TryParse(parts[1].Trim(), out port) || port is < 1 or > 65535))
{
error = $"entry \"{entry.Trim()}\" has an invalid port (must be 1–65535)";
return false;
}
endpoint = new LdapEndpoint(host, port);
return true;
}
}
```
**Step 4: Run tests, verify pass**
Run: `dotnet test ZB.MOM.WW.Auth.slnx --filter "FullyQualifiedName~LdapEndpointParserTests"`
Expected: 11 PASS.
**Step 5: Commit**
```bash
git add src/ZB.MOM.WW.Auth.Ldap/Internal/LdapEndpoint.cs tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapEndpointParserTests.cs
git commit -m "feat(ldap): LdapEndpoint + FallbackServers entry parser"
```
---
### Task 2: `LdapOptions.FallbackServers` + validator rules
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 3 (NOT Task 1 — uses the parser)
**Files:**
- Modify: `ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs` (add property after `ConnectionTimeoutMs`, line ~20)
- Modify: `ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapOptionsValidator.cs` (new check after the `ServiceAccountDn` check)
- Test: `ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapOptionsValidatorTests.cs` (append)
**Step 1: Write the failing tests** (append to `LdapOptionsValidatorTests` — match its terse `Opts() with {}` style)
```csharp
[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() =>
Assert.False(new LdapOptionsValidator()
.Validate(null, Opts() with { Enabled = false, FallbackServers = new[] { ":::" } })
.Failed);
```
**Step 2: Run to verify failure**
Run: `dotnet test ZB.MOM.WW.Auth.slnx --filter "FullyQualifiedName~LdapOptionsValidatorTests"`
Expected: FAIL to compile (`FallbackServers` not defined).
**Step 3: Implement**
In `LdapContracts.cs`, add to `LdapOptions` after `ConnectionTimeoutMs`:
```csharp
/// <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>();
```
In `LdapOptionsValidator.Validate`, after the `ServiceAccountDn` check (before the final `return`):
```csharp
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).");
}
}
```
Also extend the class `<remarks>` bullet list with a fifth condition (fallback entries must parse).
**Step 4: Run tests, verify pass**
Run: `dotnet test ZB.MOM.WW.Auth.slnx --filter "FullyQualifiedName~LdapOptionsValidatorTests"`
Expected: all PASS (existing + 6 new).
**Step 5: Commit**
```bash
git add src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs src/ZB.MOM.WW.Auth.Ldap/LdapOptionsValidator.cs tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapOptionsValidatorTests.cs
git commit -m "feat(ldap): FallbackServers option + boot-time validation"
```
---
### Task 3: `SequenceLdapConnectionFactory` test double
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 1, Task 2
The existing `FakeLdapConnectionFactory` returns ONE shared fake — useless for failover tests, where each endpoint attempt gets a fresh connection and the fakes must differ per attempt. Do NOT modify `FakeLdapConnection` (its `BindAttempts == 1` service-bind heuristic is per-instance and stays valid because the walk creates a fresh connection per attempt).
**Files:**
- Create: `ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/SequenceLdapConnectionFactory.cs`
**Step 1: Implement (with inline smoke test, mirroring FakeLdapConnection.cs's pattern)**
```csharp
using ZB.MOM.WW.Auth.Ldap.Internal;
namespace ZB.MOM.WW.Auth.Ldap.Tests;
/// <summary>
/// Test double for <see cref="ILdapConnectionFactory"/> that hands out pre-scripted
/// <see cref="FakeLdapConnection"/> instances in order, one per <see cref="Create"/> call —
/// exactly matching the failover walk, which opens a fresh connection per endpoint attempt.
/// Records every created connection for inspection. When the script runs out, returns a
/// bare unscripted fake (which yields user-not-found) so a miscounted test fails loudly
/// rather than reusing a stale fake.
/// </summary>
internal sealed class SequenceLdapConnectionFactory : ILdapConnectionFactory
{
private readonly Queue<FakeLdapConnection> _script;
public SequenceLdapConnectionFactory(params FakeLdapConnection[] fakes)
=> _script = new Queue<FakeLdapConnection>(fakes);
/// <summary>Every connection handed out, in creation order (scripted or overflow).</summary>
public List<FakeLdapConnection> Created { get; } = new();
public ILdapConnection Create()
{
var fake = _script.Count > 0 ? _script.Dequeue() : new FakeLdapConnection();
Created.Add(fake);
return fake;
}
}
public class SequenceLdapConnectionFactorySmokeTests
{
[Fact]
public void Create_ReturnsScriptedFakesInOrder_ThenOverflows()
{
var first = new FakeLdapConnection().ThrowOnConnect();
var second = new FakeLdapConnection();
var factory = new SequenceLdapConnectionFactory(first, second);
Assert.Same(first, factory.Create());
Assert.Same(second, factory.Create());
Assert.NotSame(second, factory.Create()); // overflow: fresh bare fake
Assert.Equal(3, factory.Created.Count);
}
}
```
**Step 2: Run, verify pass**
Run: `dotnet test ZB.MOM.WW.Auth.slnx --filter "FullyQualifiedName~SequenceLdapConnectionFactorySmokeTests"`
Expected: 1 PASS.
**Step 3: Commit**
```bash
git add tests/ZB.MOM.WW.Auth.Ldap.Tests/SequenceLdapConnectionFactory.cs
git commit -m "test(ldap): sequence connection factory for failover tests"
```
---
### Task 4: Endpoint walk + sticky preference in `LdapAuthService`
**Classification:** high-risk (auth-critical logic; the fail-closed contract must survive the restructure)
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on Tasks 1–3)
**Files:**
- Modify: `ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs` (restructure `Authenticate`; keep every stage's behavior byte-identical inside the per-endpoint attempt)
- Test: `ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceFailoverTests.cs` (create)
- Modify (doc note only): `scadaproj/docs/plans/2026-08-10-ldap-backup-dc-failover-design.md` — record the sticky-on-any-authoritative-answer refinement.
NOTE: the `ILogger` parameter arrives in Task 5 — in this task the service compiles WITHOUT logging (leave the `_logger?` lines out; Task 5 adds them). Existing tests in `LdapAuthServiceTests` / `LdapAuthServiceFailureTests` must pass unchanged — they use single-endpoint options and the shared-instance `FakeLdapConnectionFactory`, which still works because a single-endpoint walk calls `Create()` once.
**Step 1: Write the failing tests**
```csharp
using ZB.MOM.WW.Auth.Abstractions.Ldap;
namespace ZB.MOM.WW.Auth.Tests; // match the existing LdapAuthServiceTests namespace — check and mirror it
public class LdapAuthServiceFailoverTests
{
private static LdapOptions Opts(params string[] fallbacks) => new()
{
Enabled = true,
Server = "dc1",
Port = 636,
Transport = LdapTransport.Ldaps,
SearchBase = "dc=x",
ServiceAccountDn = "cn=svc,dc=x",
ServiceAccountPassword = "svcpw",
FallbackServers = fallbacks,
};
private static FakeLdapConnection HealthyWithUser() =>
new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=ops,dc=x" });
// ---- failover triggers (system-side) ----
[Fact]
public async Task ConnectFailure_FailsOverToFallback_AndSucceeds()
{
var factory = new SequenceLdapConnectionFactory(
new FakeLdapConnection().ThrowOnConnect(), HealthyWithUser());
var svc = new LdapAuthService(Opts("dc2:3894"), factory);
var result = await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
Assert.True(result.Succeeded);
Assert.Equal(2, factory.Created.Count);
Assert.Equal("dc1", factory.Created[0].ConnectArgs!.Value.Host);
Assert.Equal("dc2", factory.Created[1].ConnectArgs!.Value.Host);
Assert.Equal(3894, factory.Created[1].ConnectArgs!.Value.Port);
}
[Fact]
public async Task ServiceBindFailure_FailsOverToFallback()
{
var factory = new SequenceLdapConnectionFactory(
new FakeLdapConnection().ThrowOnServiceBind(), HealthyWithUser());
var svc = new LdapAuthService(Opts("dc2"), factory);
var result = await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
Assert.True(result.Succeeded);
Assert.Equal(636, factory.Created[1].ConnectArgs!.Value.Port); // bare host adopts Port
}
// ---- user-side results NEVER fail over ----
[Fact]
public async Task BadUserCredentials_DoesNotFailOver()
{
var factory = new SequenceLdapConnectionFactory(
HealthyWithUser().ThrowOnUserBind());
var svc = new LdapAuthService(Opts("dc2"), factory);
var result = await svc.AuthenticateAsync("alice", "wrong", CancellationToken.None);
Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
Assert.Single(factory.Created);
}
[Fact]
public async Task UserNotFound_DoesNotFailOver()
{
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection());
var svc = new LdapAuthService(Opts("dc2"), factory);
var result = await svc.AuthenticateAsync("ghost", "pw", CancellationToken.None);
Assert.Equal(LdapAuthFailure.UserNotFound, result.Failure);
Assert.Single(factory.Created);
}
[Fact]
public async Task GroupLookupFailed_DoesNotFailOver()
{
var factory = new SequenceLdapConnectionFactory(
new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", Array.Empty<string>()));
var svc = new LdapAuthService(Opts("dc2"), factory);
var result = await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
Assert.Single(factory.Created);
}
// ---- exhaustion ----
[Fact]
public async Task AllEndpointsDown_ReturnsServiceAccountBindFailed()
{
var factory = new SequenceLdapConnectionFactory(
new FakeLdapConnection().ThrowOnConnect(),
new FakeLdapConnection().ThrowOnConnect());
var svc = new LdapAuthService(Opts("dc2"), factory);
var result = await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, result.Failure);
Assert.Equal(2, factory.Created.Count);
}
// ---- stickiness ----
[Fact]
public async Task StickyPreference_SecondLoginStartsAtLastGoodEndpoint()
{
var factory = new SequenceLdapConnectionFactory(
new FakeLdapConnection().ThrowOnConnect(), // login 1 @ dc1: dead
HealthyWithUser(), // login 1 @ dc2: answers
HealthyWithUser()); // login 2: should go straight to dc2
var svc = new LdapAuthService(Opts("dc2"), factory);
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
Assert.Equal(3, factory.Created.Count);
Assert.Equal("dc2", factory.Created[2].ConnectArgs!.Value.Host);
}
[Fact]
public async Task StickyPreference_DriftsBackToPrimary_WhenPreferredFails()
{
var factory = new SequenceLdapConnectionFactory(
new FakeLdapConnection().ThrowOnConnect(), // login 1 @ dc1: dead
HealthyWithUser(), // login 1 @ dc2: answers -> preferred
new FakeLdapConnection().ThrowOnConnect(), // login 2 @ dc2: now dead
HealthyWithUser(), // login 2 @ dc1 (declared order): recovered
HealthyWithUser()); // login 3: should start at dc1 again
var svc = new LdapAuthService(Opts("dc2"), factory);
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
Assert.Equal(5, factory.Created.Count);
Assert.Equal("dc1", factory.Created[3].ConnectArgs!.Value.Host);
Assert.Equal("dc1", factory.Created[4].ConnectArgs!.Value.Host);
}
[Fact]
public async Task StickyPreference_UpdatesOnUserSideAnswer_NotOnlySuccess()
{
var factory = new SequenceLdapConnectionFactory(
new FakeLdapConnection().ThrowOnConnect(), // login 1 @ dc1: dead
new FakeLdapConnection(), // login 1 @ dc2: answers UserNotFound
HealthyWithUser()); // login 2: should start at dc2
var svc = new LdapAuthService(Opts("dc2"), factory);
var first = await svc.AuthenticateAsync("ghost", "pw", CancellationToken.None);
Assert.Equal(LdapAuthFailure.UserNotFound, first.Failure);
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
Assert.Equal("dc2", factory.Created[2].ConnectArgs!.Value.Host);
}
// ---- back-compat ----
[Fact]
public async Task NoFallbacks_SingleAttempt_UnchangedBehavior()
{
var factory = new SequenceLdapConnectionFactory(
new FakeLdapConnection().ThrowOnConnect());
var svc = new LdapAuthService(Opts(/* no fallbacks */), factory);
var result = await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, result.Failure);
Assert.Single(factory.Created);
}
[Fact]
public async Task MalformedFallbackEntry_IsSkippedDefensively()
{
// The validator rejects this at boot; a service constructed without validation
// must degrade to the parseable subset, not throw.
var factory = new SequenceLdapConnectionFactory(HealthyWithUser());
var svc = new LdapAuthService(Opts(":::"), factory);
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
Assert.Single(factory.Created);
}
}
```
Before writing: open `LdapAuthServiceTests.cs`, mirror its exact namespace and the internal-visibility usage (the test project has `InternalsVisibleTo` — `LdapAuthService`'s internal ctor and the fakes are reachable).
**Step 2: Run to verify failure**
Run: `dotnet test ZB.MOM.WW.Auth.slnx --filter "FullyQualifiedName~LdapAuthServiceFailoverTests"`
Expected: FAIL — walk/stickiness not implemented (connect-failure tests fail with a single `Created` entry; sticky tests fail).
**Step 3: Implement** — restructure `LdapAuthService`:
1. New fields + endpoint construction:
```csharp
private readonly IReadOnlyList<LdapEndpoint> _endpoints;
private int _preferredIndex; // index into _endpoints; volatile access; benign races accepted
```
In BOTH constructors' shared path (the internal one; the public one chains to it), set
`_endpoints = BuildEndpoints(options);` and add:
```csharp
/// <summary>
/// Primary first, then fallbacks in declared order. The validator rejects malformed
/// entries at boot; unparseable entries are skipped defensively here so a service
/// constructed without options validation degrades to the parseable subset.
/// </summary>
private static IReadOnlyList<LdapEndpoint> BuildEndpoints(LdapOptions options)
{
var endpoints = new List<LdapEndpoint> { new(options.Server, options.Port) };
foreach (var entry in options.FallbackServers)
{
if (LdapEndpointParser.TryParse(entry, options.Port, out var ep, out _))
endpoints.Add(ep);
}
return endpoints;
}
```
2. `Authenticate` becomes the walk (steps 1–3 — gate, null-username, trim — stay as-is above it):
```csharp
private LdapAuthResult Authenticate(string username, string password)
{
if (!_options.Enabled)
return LdapAuthResult.Fail(LdapAuthFailure.Disabled);
if (string.IsNullOrWhiteSpace(username))
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
username = username.Trim();
var preferred = Volatile.Read(ref _preferredIndex);
if ((uint)preferred >= (uint)_endpoints.Count)
preferred = 0; // defensive: index can never exceed the immutable list, but fail safe
var lastSystemSideFailure = LdapAuthResult.Fail(LdapAuthFailure.ServiceAccountBindFailed);
foreach (var index in WalkOrder(_endpoints.Count, preferred))
{
var result = AuthenticateAgainst(_endpoints[index], username, password);
// ServiceAccountBindFailed is the (sole) system-side bucket: the endpoint gave no
// authoritative answer — try the next one. Every other outcome (success, or a
// user-side failure like UserNotFound/BadCredentials) came from a healthy
// directory and is final.
if (result.Failure == LdapAuthFailure.ServiceAccountBindFailed)
{
lastSystemSideFailure = result;
continue;
}
// Sticky preference: any authoritative answer proves the endpoint healthy —
// updating only on Succeeded would re-pay the dead primary's connect timeout
// on every mistyped password during an outage.
if (index != preferred)
Volatile.Write(ref _preferredIndex, index);
return result;
}
return lastSystemSideFailure;
}
/// <summary>Preferred endpoint first, then the rest in declared order.</summary>
private static IEnumerable<int> WalkOrder(int count, int preferred)
{
yield return preferred;
for (var i = 0; i < count; i++)
{
if (i != preferred)
yield return i;
}
}
```
3. `AuthenticateAgainst(LdapEndpoint endpoint, ...)` is the CURRENT stages 4–10 verbatim, with three mechanical changes: (a) `conn.Connect(endpoint.Host, endpoint.Port, ...)` instead of `_options.Server/_options.Port`; (b) the user-bind and group-extraction failures return directly instead of throwing `StageFailure` (equivalent, simpler); (c) `StageFailure` gains a `string Stage` member (`"connect"` / `"service-bind"` / `"search"`) consumed by Task 5's logging — carry it now so Task 5 is additive:
```csharp
private LdapAuthResult AuthenticateAgainst(LdapEndpoint endpoint, string username, string password)
{
try
{
using var conn = _connectionFactory.Create();
try
{
conn.Connect(endpoint.Host, endpoint.Port, _options.Transport,
_options.AllowInsecure, _options.ConnectionTimeoutMs,
_options.ServerCertificateValidationCallback);
}
catch (LdapException) { throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "connect"); }
try { conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword); }
catch (LdapException) { throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "service-bind"); }
IReadOnlyList<LdapSearchEntry> entries;
try
{
var filter = $"({_options.UserNameAttribute}={LdapEscaping.Filter(username)})";
entries = conn.Search(_options.SearchBase, filter, BuildSearchAttributes());
}
catch (LdapException) { throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "search"); }
if (entries.Count == 0) return LdapAuthResult.Fail(LdapAuthFailure.UserNotFound);
if (entries.Count >= 2) return LdapAuthResult.Fail(LdapAuthFailure.AmbiguousUser);
var entry = entries[0];
try { conn.Bind(entry.Dn, password); }
catch (LdapException) { return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials); }
IReadOnlyList<string> groups;
try { groups = ExtractGroups(entry); }
catch { return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed); }
if (groups.Count == 0) return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
return LdapAuthResult.Success(username, ExtractDisplayName(entry, username), groups);
}
catch (StageFailure stage)
{
return LdapAuthResult.Fail(stage.Failure);
}
catch
{
// Fail closed: any unexpected exception is a system-side no-answer.
return LdapAuthResult.Fail(LdapAuthFailure.ServiceAccountBindFailed);
}
}
```
Update the class-level and method XML docs: the fail-closed contract is unchanged and per-endpoint; describe the walk + stickiness (mirror the design doc's wording). Keep all existing helpers (`BuildSearchAttributes`, `ExtractDisplayName`, `ExtractGroups`, `ToGroupShortName`) untouched.
4. Append the refinement note to the design doc's *Failover semantics* section:
```markdown
> **Refinement (Task 4):** sticky preference updates on ANY authoritative answer
> (success or user-side failure), not only on success — a DC that answers is healthy,
> and success-only stickiness would re-pay the dead primary's connect timeout on every
> mistyped password during an outage.
```
**Step 4: Run the FULL suite (not just the new file)**
Run: `dotnet test ZB.MOM.WW.Auth.slnx`
Expected: all pass, 0 warnings — including the untouched `LdapAuthServiceTests` / `LdapAuthServiceFailureTests` / `LdapEscapingTests` (the behavior-preservation check for the restructure).
**Step 5: Commit**
```bash
git add src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceFailoverTests.cs ../docs/plans/2026-08-10-ldap-backup-dc-failover-design.md
git commit -m "feat(ldap): endpoint walk with sticky-preference failover in LdapAuthService"
```
---
### Task 5: Failover observability (`ILogger`) + DI wiring
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none (depends on Task 4)
**Files:**
- Modify: `ZB.MOM.WW.Auth/Directory.Packages.props` (add to the `<!-- Extensions -->` group: `<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />`)
- Modify: `ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/ZB.MOM.WW.Auth.Ldap.csproj` (add `<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />`)
- Modify: `ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs`
- Modify: `ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.AspNetCore/ServiceCollectionExtensions.cs`
- Test: `ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceFailoverTests.cs` (append)
**Step 1: Write the failing test** (append; include a minimal recording logger in the same file)
```csharp
private sealed class RecordingLogger : Microsoft.Extensions.Logging.ILogger<LdapAuthService>
{
public List<(Microsoft.Extensions.Logging.LogLevel Level, string Message)> Entries { get; } = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
public void Log<TState>(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId,
TState state, Exception? exception, Func<TState, Exception?, string> formatter)
=> Entries.Add((logLevel, formatter(state, exception)));
}
[Fact]
public async Task Failover_LogsWarningWithEndpointAndStage_AndInfoOnStickyChange()
{
var logger = new RecordingLogger();
var factory = new SequenceLdapConnectionFactory(
new FakeLdapConnection().ThrowOnConnect(), HealthyWithUser());
var svc = new LdapAuthService(Opts("dc2:3894"), factory, logger);
await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
Assert.Contains(logger.Entries, e =>
e.Level == Microsoft.Extensions.Logging.LogLevel.Warning
&& e.Message.Contains("dc1:636") && e.Message.Contains("connect"));
Assert.Contains(logger.Entries, e =>
e.Level == Microsoft.Extensions.Logging.LogLevel.Information
&& e.Message.Contains("dc2:3894"));
// Never log secrets:
Assert.DoesNotContain(logger.Entries, e => e.Message.Contains("svcpw") || e.Message.Contains("pw\""));
}
[Fact]
public async Task HappyPath_LogsNothing()
{
var logger = new RecordingLogger();
var factory = new SequenceLdapConnectionFactory(HealthyWithUser());
var svc = new LdapAuthService(Opts("dc2"), factory, logger);
await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
Assert.Empty(logger.Entries);
}
```
**Step 2: Run to verify failure** — FAIL to compile (no 3-arg ctor).
**Step 3: Implement**
- Add `using Microsoft.Extensions.Logging;` and field `private readonly ILogger<LdapAuthService>? _logger;`.
- Public ctor: `public LdapAuthService(LdapOptions options, ILogger<LdapAuthService>? logger = null)` chaining to the internal one; internal ctor gains `ILogger<LdapAuthService>? logger = null`.
- In `AuthenticateAgainst`'s `catch (StageFailure stage)`:
`_logger?.LogWarning("LDAP endpoint {Endpoint} failed system-side at {Stage}; treating as no answer.", endpoint, stage.Stage);`
and in the final `catch` (bind the exception):
`_logger?.LogWarning(ex, "LDAP endpoint {Endpoint} failed system-side with an unexpected error.", endpoint);`
- In `Authenticate`, at the sticky write:
`_logger?.LogInformation("LDAP sticky preference moved to {Endpoint}.", _endpoints[index]);`
- Never log usernames' passwords or `ServiceAccountPassword` (assert covered by test).
- `ServiceCollectionExtensions.AddZbLdapAuth`: pass the logger —
```csharp
services.TryAddSingleton<ILdapAuthService>(sp =>
new LdapAuthService(
sp.GetRequiredService<IOptions<LdapOptions>>().Value,
sp.GetService<Microsoft.Extensions.Logging.ILogger<LdapAuthService>>()));
```
(`GetService`, not `GetRequiredService` — hosts without logging still resolve. Update the surrounding "stateless" comment: the service now holds a sticky endpoint index; singleton remains correct — that state is the point.)
**Step 4: Run the FULL suite**
Run: `dotnet test ZB.MOM.WW.Auth.slnx`
Expected: all pass, 0 warnings.
**Step 5: Commit**
```bash
git add -A
git commit -m "feat(ldap): failover observability — warn per hop, info on sticky change"
```
---
### Task 6: GLAuth failover integration test
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 7 prep (but land before the version bump)
**Files:**
- Modify: `ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/Integration/GLAuthIntegrationTests.cs` (append one `[SkippableFact]`)
**Step 1: Add the test** (mirror the file's existing env-gate + reachability-probe pattern exactly — same env vars, same `Skip.IfNot` / TCP probe)
```csharp
/// <summary>
/// Failover: primary endpoint points at a closed local port (refused immediately),
/// fallback at the real GLAuth. The login must succeed via the fallback, and a second
/// login must also succeed (sticky preference — no re-walk through the dead primary).
/// </summary>
[SkippableFact]
public async Task Authenticate_FailsOverFromDeadPrimary_ToRealGLAuth()
{
// <same opt-in gate, env reads, and reachability probe as the test above>
var options = new LdapOptions
{
Enabled = true,
Server = "127.0.0.1",
Port = 39999, // closed port -> immediate refusal
Transport = LdapTransport.None,
AllowInsecure = true,
SearchBase = baseDn,
ServiceAccountDn = svcDn,
ServiceAccountPassword = svcPw,
UserNameAttribute = userAttr,
GroupAttribute = "memberOf",
ConnectionTimeoutMs = 3000,
FallbackServers = new[] { $"{server}:{port}" },
};
var svc = new LdapAuthService(options);
var first = await svc.AuthenticateAsync(user, pw, CancellationToken.None);
Assert.True(first.Succeeded, $"first login failed: {first.Failure}");
var second = await svc.AuthenticateAsync(user, pw, CancellationToken.None);
Assert.True(second.Succeeded, $"second login failed: {second.Failure}");
}
```
**Step 2: Run offline** — `dotnet test ZB.MOM.WW.Auth.slnx --filter "FullyQualifiedName~GLAuthIntegrationTests"` → both SKIPPED (no `ZB_LDAP_IT=1`).
**Step 3: Run live** against the shared GLAuth on the fixture host (see the file header for the full env set; server is `10.100.0.35:3893`, base `dc=zb,dc=local` — the shared-glauth conventions):
```bash
ZB_LDAP_IT=1 ZB_LDAP_SERVER=10.100.0.35 ZB_LDAP_PORT=3893 ZB_LDAP_BASE=dc=zb,dc=local \
ZB_LDAP_SVC_DN=<svc dn> ZB_LDAP_SVC_PW=<svc pw> ZB_LDAP_USER=multi-role ZB_LDAP_PW=password \
dotnet test ZB.MOM.WW.Auth.slnx --filter "FullyQualifiedName~GLAuthIntegrationTests"
```
Expected: PASS (needs the fixture host reachable; if not, note it and continue — the unit suite carries the logic).
**Step 4: Commit**
```bash
git add tests/ZB.MOM.WW.Auth.Ldap.Tests/Integration/GLAuthIntegrationTests.cs
git commit -m "test(ldap): env-gated failover integration test vs live GLAuth"
```
---
### Task 7: Version 0.2.0, pack, publish, restore-verify
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (last code task)
**Files:**
- Modify: `ZB.MOM.WW.Auth/Directory.Build.props` (`<Version>0.1.5</Version>` → `<Version>0.2.0</Version>`)
**Steps:**
1. Bump the version. Run the full suite one more time: `dotnet test ZB.MOM.WW.Auth.slnx` → all pass, 0 warnings.
2. Merge the branch: from `/Users/dohertj2/Desktop/scadaproj` — `git checkout main && git merge --no-ff feat/auth-ldap-failover -m "feat(auth): LDAP backup-DC sticky failover — family 0.2.0"` then `git push origin main`.
3. Pack all four packages: `dotnet pack ZB.MOM.WW.Auth.slnx -c Release -o artifacts` → 4 nupkgs @ 0.2.0 (Abstractions, Ldap, ApiKeys, AspNetCore — they version together).
4. Publish to the Gitea feed (same feed/key as prior releases — the push source is `https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json`; credentials are already configured in the user's NuGet config from prior publishes):
`dotnet nuget push "artifacts/*.0.2.0.nupkg" --source <gitea feed>`
5. Restore-verify from a scratch consumer (the family's standard check — see memory `gitea-nuget-feed-verification`): scratch console project referencing `ZB.MOM.WW.Auth.Ldap 0.2.0`, `dotnet restore` from the feed, confirm it resolves. Registration URL gotcha: verify via `/api/packages/dohertj2/nuget/registration/...` (NOT `/nuget/v3/registration/` — that 404s for everything).
**Verification gate (before claiming done):** feed shows 4 packages at 0.2.0 AND scratch restore succeeds. Publishing is externally visible — if anything looks off at step 4, stop and surface it.
---
### Task 8: Documentation + index truth
**Classification:** trivial
**Estimated implement time:** ~4 min
**Parallelizable with:** none (after Task 7)
**Files:**
- Modify: `scadaproj/CLAUDE.md` — Auth component row + the Auth narrative section: family is now `0.2.0` on the feed (`0.1.5` still what all four apps pin); one-line description of `FallbackServers` sticky failover and why (NAM AD two-DC deployment).
- Modify: `scadaproj/components/auth/GAPS.md` — note the new capability and that app adoption (pin bump + ZB config) is a follow-up.
- Memory: update `shared-lib-version-matrix.md` (feed now carries Auth 0.2.0; apps still pin 0.1.5) and `zb-nam-ldap-production-auth.md` (failover gap CLOSED at lib level; remaining gaps: roles decision, TLS trust/1024-bit cert, app pin bumps).
**Steps:**
1. Make the edits. CLAUDE.md/GAPS edits go on `main` and get pushed (per the index rule — already on `main` after Task 7's merge).
2. `git add -A && git commit -m "docs(index): Auth 0.2.0 — LDAP sticky backup-DC failover published" && git push origin main`
---
## Explicitly out of scope (per approved design)
- Credential caching (both-DCs-down survival) — rejected, YAGNI.
- Bumping the four apps' Auth pins to 0.2.0 — separate family-alignment task.
- ZB production config authoring (`FallbackServers` values, LDAPS trust for the 1024-bit certs) — deployment-time work tracked in `ldap_setup.md`.