# LDAP backup-DC failover — design (`ZB.MOM.WW.Auth` 0.2.0) **Date:** 2026-08-10 **Status:** Approved **Motivation:** Production servers at ZB authenticate against the Zimmer NAM AD ([`ldap_setup.md`](../../ldap_setup.md)), which has two domain controllers (`uswap-vdc-nam1` / `uswap-vdc-nam2`). `ZB.MOM.WW.Auth.Ldap` takes a single `Server`, so a primary-DC outage blocks every interactive login family-wide (API keys unaffected). The working Ignition gateway has primary + secondary DC failover; this brings the shared library to parity on that axis. ## Decisions (made with the user, 2026-08-10) 1. **Sticky preference** failover — try the last-known-good DC first; on failure walk the ordered list and remember whichever worked. Mirrors the Secrets 0.5.0 `FallbackEndpoints` sticky/no-flapping precedent. Rejected: always-primary-first (pays the full connection timeout on every login during a primary outage), sticky + probe-back (YAGNI for two DCs). 2. **Failover-only** — no Ignition-style credential cache. A both-DCs-down event is rare and correlated with wider outages; API keys already survive it; a credential cache adds credential-at-rest surface. Possible future follow-up. 3. **Approach A: service-level loop** — the walk lives in `LdapAuthService`, which already owns failure classification (user-fault vs system-fault). Rejected: connection-factory-level (only sees `Connect`; a DC that accepts TCP+TLS but fails service-bind/search would never fail over), DNS-level `nam.zimmer.com` (DC certs' SANs carry only their own FQDN → LDAPS hostname validation breaks; no stickiness or timeout control). ## Probe evidence (2026-08-10, recorded in `ldap_setup.md`) Both DCs serve LDAPS (636, TLSv1.3) and StartTLS (389); anonymous rootDSE over LDAPS returns `result: 0 Success` on both. Certs: per-host FQDN SANs, issued by `zimmer-issueca1` ← `zimmer-rootca` (internal CA, root not served), valid to 2027-03. **Caveat: both certs are RSA 1024-bit** — OpenSSL at SECLEVEL=2 (Debian-based .NET containers) rejects the handshake (`EE certificate key too weak`); Windows/Schannel hosts accept. Container deployments need a lowered SECLEVEL, a validation-callback path, or (right fix) a reissued 2048-bit DC cert template. ## Config surface One additive field on `LdapOptions` (`ZB.MOM.WW.Auth.Abstractions`): ```csharp /// Ordered fallback LDAP endpoints, "host" or "host:port"; a bare host uses . public IReadOnlyList FallbackServers { get; init; } = Array.Empty(); ``` ```jsonc "Ldap": { "Server": "uswap-vdc-nam1.nam.zimmer.com", "Port": 636, "Transport": "Ldaps", "FallbackServers": [ "uswap-vdc-nam2.nam.zimmer.com" ] } ``` - Empty (the default) ⇒ behavior byte-identical to today: single endpoint, no loop, no new state. - `ServerCertificateValidationCallback` and `AllowInsecure` apply to every endpoint (one CA family — verified by the probe). - **Validator** (`LdapOptionsValidator`, only when `Enabled`): each entry must have a non-empty host; an explicit port must parse as 1–65535. Malformed entries fail boot with a field-naming message in the validator's existing style. Duplicates are permitted (harmless; de-duplication is not the validator's job). ## Failover semantics Inside `LdapAuthService.Authenticate`, an endpoint walk over an internal `LdapEndpoint(string Host, int Port)` list = `Server:Port` + parsed `FallbackServers`, in declared order: - **Walk order:** sticky-preferred endpoint first, then the remaining endpoints in declared order (skipping the preferred). On success at endpoint *i*, preference := *i*. - **Only system-side failures advance the walk:** connect/TLS-upgrade failure, service-account bind failure, search failure, and any unexpected exception inside an attempt. **User-side results return immediately, never fail over:** `UserNotFound`, `AmbiguousUser`, `BadCredentials` (user bind), `GroupLookupFailed` — those are answers from a healthy directory. - **Exhaustion:** all endpoints failed system-side ⇒ return the last failure (maps to `ServiceAccountBindFailed`, as today — no `LdapAuthFailure` enum change, no `Abstractions` contract change beyond the new option). - **Fail-closed contract preserved:** `AuthenticateAsync` still never throws; each per-endpoint attempt is individually guarded. - Documented trade-offs: - A genuinely wrong service-account password tries every DC per login (fails fast at bind — no timeout cost; 2 endpoints in practice). - Worst-case login latency while the preferred DC is down = `ConnectionTimeoutMs` × walk position, paid once — stickiness then moves preference to the survivor. - Sticky means the primary stays unused after recovery until the current preferred fails; when it does, the walk tries declared order again, so preference drifts back to the primary naturally. > **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. Corollary, also implemented: an *exhausted* walk > (every endpoint system-side-down) leaves the preference untouched, since a total > outage says nothing about which endpoint to prefer once one recovers. ## Stickiness state `volatile int _preferredIndex` on the singleton `LdapAuthService` (registered `TryAddSingleton` in `ZB.MOM.WW.Auth.AspNetCore`). Benign races tolerated — worst case is one redundant failed attempt. No cross-process or persisted state. ## Observability New optional `ILogger?` constructor parameter (null default — existing constructors keep compiling; adds only `Microsoft.Extensions.Logging.Abstractions` to `ZB.MOM.WW.Auth.Ldap`). The AspNetCore registration passes the host logger. Log points: - **Warning** per failover hop: endpoint, stage that failed, next endpoint. - **Information** on sticky-preference change (old → new endpoint). - Nothing on the happy path; never log credentials or passwords. Rationale: silent failover hides a dead primary DC indefinitely — the feature working correctly must be visible to operators. ## Testing - **Unit (FakeLdapConnection seam):** failover triggered by each system-side stage (connect, service bind, search, unexpected exception); NO failover on each user-side result; stickiness updates on success and drifts back after the preferred fails; exhaustion returns `ServiceAccountBindFailed`; empty `FallbackServers` short-circuits to today's exact behavior; endpoint parsing (`host`, `host:port`, malformed). - **Validator tests:** new cases in `LdapOptionsValidatorTests` style. - **Integration (env-gated GLAuth suite):** primary pointed at a closed local port + fallback at the real GLAuth ⇒ login succeeds, preference sticks, and a second login does not retry the dead primary. Fast offline too — a closed local port refuses immediately. ## Versioning / rollout - Auth family `0.1.5` → **`0.2.0`** (additive feature; the four packages version together). Publish all 4 to the Gitea feed, restore-verify. - **Zero consumer code changes** — the four apps adopt by config alone at ZB deploy time. Bumping the apps' pins to 0.2.0 is a follow-up family-alignment task, not a prerequisite. - Dev/GLAuth stacks are unaffected (no `FallbackServers` configured).