544a6ddb77
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.
256 lines
12 KiB
C#
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);
|
|
}
|