Files
scadaproj/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs
T
Joseph Doherty 544a6ddb77 Fix all baseline code-review findings across the six shared libraries
Resolves the 35 findings from the 2026-06-01 baseline (commit 26ba1c7),
test-first for every behavioral change. +51 tests (331 -> 382 passing, 0 failed).

- Telemetry-001 (HIGH): RedactionEnricher now honours property removal, so a
  redactor that drops a key actually scrubs the secret from the event.
- Auth: LDAP validator ValidateOnStart; API-key verify no longer fails on a
  best-effort MarkUsed write or a corrupt scopes column (fail-closed); LDAP cert
  validation hook; KeyPrefix persistence aligned; README algorithm corrected.
- Health: Akka checks return Degraded (not throw) when the cluster isn't up yet;
  GrpcDependencyHealthCheck catch-all; null 'description' rendered; composite
  endpoint builder; XML docs shipped.
- Audit: CompositeAuditWriter no longer re-throws OperationCanceledException;
  TruncatingAuditRedactor over-redact scrubs Target + safe negative max; options
  record; XML docs shipped.
- Configuration: TryAddEnumerable idempotent registration; consistent port
  quoting; strict invariant port parsing; XML docs + README packaged.
- Theme: mobile toggle is now CSS-only (no Bootstrap JS); token/CSS hygiene;
  XML docs on the public parameter surface.

Shared-contract/spec docs updated where the code was the source of truth
(observability service.instance.id, MapZbMetrics, redactor reach). All changes
additive/back-compatible at v0.1.0. code-reviews bookkeeping follows separately.
2026-06-01 11:22:14 -04:00

256 lines
12 KiB
C#

using Novell.Directory.Ldap;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Ldap.Internal;
namespace ZB.MOM.WW.Auth.Ldap;
/// <summary>
/// Authenticates a user against an LDAP directory using the bind-then-search idiom:
/// bind as the service account, search for the user entry, then re-bind as the user
/// to verify their password. Connection mechanics are delegated to an
/// <see cref="ILdapConnection"/> so the logic is unit-testable without a live server.
/// </summary>
/// <remarks>
/// Fully fail-closed: authentication never throws to the caller — every error, expected
/// or unexpected, is mapped to a structured <see cref="LdapAuthResult.Fail(LdapAuthFailure)"/>.
/// A success is only returned for a user that resolved to exactly one entry, whose password
/// verified, AND who has at least one group (zero groups is never admitted as success).
/// Service-account bind failures (<see cref="LdapAuthFailure.ServiceAccountBindFailed"/>) are
/// kept distinct from end-user bind failures (<see cref="LdapAuthFailure.BadCredentials"/>) so
/// a system misconfiguration is not mistaken for bad user input.
/// </remarks>
public sealed class LdapAuthService : ILdapAuthService
{
private readonly LdapOptions _options;
private readonly ILdapConnectionFactory _connectionFactory;
/// <summary>
/// Production constructor: binds against a live directory via the real
/// Novell-backed connection factory. The TLS server-certificate validation policy
/// (<see cref="LdapOptions.ServerCertificateValidationCallback"/> or the
/// <see cref="LdapOptions.AllowInsecure"/> bypass) is carried into the factory so each
/// connection is built with it.
/// </summary>
public LdapAuthService(LdapOptions options)
: this(options, new NovellLdapConnectionFactory(
options.AllowInsecure, options.ServerCertificateValidationCallback))
{
}
/// <summary>
/// Test/seam constructor: accepts an injected <see cref="ILdapConnectionFactory"/>
/// 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)
{
_options = options;
_connectionFactory = connectionFactory;
}
/// <inheritdoc />
/// <remarks>
/// Fail-closed contract: this method never throws to the caller. Every stage of the
/// bind-then-search-then-bind flow is wrapped so that any error — expected or unexpected —
/// is mapped to a structured <see cref="LdapAuthResult.Fail(LdapAuthFailure)"/>. A
/// <c>Succeeded == true</c> result is only ever returned when the user resolved to exactly
/// one entry, their password verified, AND at least one group was extracted.
/// </remarks>
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct)
{
// The Novell calls behind ILdapConnection are synchronous and blocking, so the token
// cannot interrupt an in-progress operation; it is only observed here at entry, before
// any work begins.
ct.ThrowIfCancellationRequested();
return Task.FromResult(Authenticate(username, password));
}
private LdapAuthResult Authenticate(string username, string password)
{
// 1. Feature gate: an explicitly disabled provider must never touch the network.
if (!_options.Enabled)
return LdapAuthResult.Fail(LdapAuthFailure.Disabled);
// 2. Reject a missing username before anything else — guarding here means a null
// username can't NRE into the catch-all and surface as a system-side failure.
if (string.IsNullOrWhiteSpace(username))
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
// 3. Normalise once, up front, so the same canonical value flows into the LDAP
// filter and the returned result (avoids two identities for one person).
username = username.Trim();
// The whole flow runs inside an outer fail-closed guard: a StageFailure carries an
// already-mapped failure out of a stage, and any OTHER unexpected exception defaults to
// the most conservative system-side bucket. Either way the caller gets a structured result.
try
{
using var conn = _connectionFactory.Create();
// 4. Open the connection (transport/TLS handling lives in the adapter). The
// per-operation timeout (ConnectionTimeoutMs) is applied by the adapter here.
// A failure to connect/upgrade means the directory is unreachable — a
// system-side fault, not the user's, so map it to ServiceAccountBindFailed.
// NOTE: the LdapAuthFailure enum has no dedicated DirectoryUnavailable value;
// ServiceAccountBindFailed is the closest system-side bucket. A future
// Abstractions change could add DirectoryUnavailable to disambiguate.
try
{
conn.Connect(
_options.Server,
_options.Port,
_options.Transport,
_options.AllowInsecure,
_options.ConnectionTimeoutMs,
_options.ServerCertificateValidationCallback);
}
catch (LdapException)
{
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed);
}
// 5. Service-account bind so we can search for the user's DN. A bind failure
// here is a service-account misconfiguration — DISTINCT from a user-credential
// failure — so it maps to ServiceAccountBindFailed.
try
{
conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword);
}
catch (LdapException)
{
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed);
}
// 6. Search for the user entry by the configured username attribute. A search
// failure is infrastructure (directory unreachable / unhealthy) — system-side.
// NOTE: same enum limitation as Connect above; ServiceAccountBindFailed is the
// closest system-side bucket until a DirectoryUnavailable value exists.
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);
}
// 7. Require exactly one match. Zero -> UserNotFound; two or more -> AmbiguousUser.
// We never attempt a user bind against an ambiguous DN.
if (entries.Count == 0)
return LdapAuthResult.Fail(LdapAuthFailure.UserNotFound);
if (entries.Count >= 2)
return LdapAuthResult.Fail(LdapAuthFailure.AmbiguousUser);
var entry = entries[0];
// 8. User bind: re-bind as the resolved DN to verify the password. A bind failure
// here is the end user's bad credentials.
try
{
conn.Bind(entry.Dn, password);
}
catch (LdapException)
{
throw new StageFailure(LdapAuthFailure.BadCredentials);
}
// 9. Group extraction. Fail closed: an empty/missing group set, or any error while
// extracting groups, is a GroupLookupFailed — never a zero-group success.
IReadOnlyList<string> groups;
try
{
groups = ExtractGroups(entry);
}
catch
{
throw new StageFailure(LdapAuthFailure.GroupLookupFailed);
}
if (groups.Count == 0)
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
// 10. Success — and only here, with a verified password and >= 1 group.
var displayName = ExtractDisplayName(entry, username);
return LdapAuthResult.Success(username, displayName, groups);
}
catch (StageFailure stage)
{
// A stage mapped its own failure; surface it as a structured result.
return LdapAuthResult.Fail(stage.Failure);
}
catch
{
// Belt-and-braces: ANY unexpected exception fails closed to the most conservative
// system-side bucket rather than propagating to the caller.
return LdapAuthResult.Fail(LdapAuthFailure.ServiceAccountBindFailed);
}
}
/// <summary>
/// Internal control-flow exception that carries an already-mapped <see cref="LdapAuthFailure"/>
/// out of a stage to the single fail-closed catch site. Never escapes this type.
/// </summary>
private sealed class StageFailure : Exception
{
public StageFailure(LdapAuthFailure failure) => Failure = failure;
public LdapAuthFailure Failure { get; }
}
/// <summary>
/// Builds the distinct attribute list requested from the directory. The display-name
/// and group attributes are de-duplicated so we never request the same attribute twice
/// when an operator configures them to the same value.
/// </summary>
private IReadOnlyList<string> BuildSearchAttributes()
{
if (string.Equals(_options.DisplayNameAttribute, _options.GroupAttribute, StringComparison.OrdinalIgnoreCase))
return new[] { _options.DisplayNameAttribute };
return new[] { _options.DisplayNameAttribute, _options.GroupAttribute };
}
/// <summary>
/// Returns the first value of the configured display-name attribute, falling back to
/// the (already normalised) username when the directory entry has no such attribute.
/// </summary>
private string ExtractDisplayName(LdapSearchEntry entry, string username)
{
if (entry.Attributes.TryGetValue(_options.DisplayNameAttribute, out var values) && values.Count > 0)
return values[0];
return username;
}
/// <summary>
/// Extracts group short names from the configured group attribute. Each value is a
/// group DN (e.g. <c>cn=Engineers,ou=g,dc=x</c>); the first RDN's value is returned
/// (e.g. <c>Engineers</c>), RFC 4514 escape-aware so an escaped comma in the CN is
/// preserved rather than truncating the name.
/// </summary>
private IReadOnlyList<string> ExtractGroups(LdapSearchEntry entry)
{
if (!entry.Attributes.TryGetValue(_options.GroupAttribute, out var values) || values.Count == 0)
return Array.Empty<string>();
var groups = new List<string>(values.Count);
foreach (var value in values)
groups.Add(ToGroupShortName(value));
return groups;
}
/// <summary>
/// Yields a group's short name from its DN by returning the value of the first RDN
/// (e.g. <c>cn=Engineers,ou=g,dc=x</c> → <c>Engineers</c>). The extraction is RFC 4514
/// escape-aware (<see cref="LdapEscaping.FirstRdnValue"/>), so a CN that legitimately
/// contains an escaped comma — <c>cn=Eng\,ineers,...</c> or <c>cn=A\2cB,...</c> — is
/// returned intact rather than truncated at the escaped comma. Values with no <c>=</c>
/// are returned unchanged.
/// </summary>
private static string ToGroupShortName(string groupDn)
=> LdapEscaping.FirstRdnValue(groupDn);
}