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

147 lines
7.4 KiB
Markdown
Raw Permalink 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 — 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
/// <summary>Ordered fallback LDAP endpoints, "host" or "host:port"; a bare host uses <see cref="Port"/>.</summary>
public IReadOnlyList<string> FallbackServers { get; init; } = Array.Empty<string>();
```
```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<LdapAuthService>?` 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).