4e01cbc2c3
Diagnostic for pinning down the LDAP settings the four ZB.MOM.WW apps need against the Zimmer NAM domain, and for answering which AD groups can be mapped to app roles. Found the referral defect fixed in 0.2.1. Two independent halves in one run: Part A drives the REAL ZB.MOM.WW.Auth.LdapAuthService -- same bind-then-search- then-bind, same options validator, same failure enum -- so its verdict IS what the apps produce rather than an approximation of it. Part B asks the directory what the library cannot, which is where the group answer comes from: full group DNs, nested groups via LDAP_MATCHING_RULE_IN_CHAIN, the primary group (AD omits it from memberOf, so it can never be a role group), security vs distribution type, decoded userAccountControl, referral counts, and the TLS chain with key sizes. --list-groups '<pat>' enumerates every matching group in the directory, which a membership dump structurally cannot do -- it only ever shows one account's memberships. Passwords are prompted for and never echoed; they cannot be passed as arguments, which would put them in shell history. --self-bind lets the user under test stand in for the service account, so group discovery does not require the svc_igntnapp secret. Not packable, outside the Auth solution and its central package management, referencing the Auth projects directly so it tracks current source.
539 lines
23 KiB
C#
539 lines
23 KiB
C#
using System.Diagnostics;
|
|
using Novell.Directory.Ldap;
|
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
|
using ZB.MOM.WW.Auth.Ldap;
|
|
using ZB.MOM.WW.Tools.LdapProbe;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// arguments
|
|
// ---------------------------------------------------------------------------
|
|
ProbeSettings settings;
|
|
try
|
|
{
|
|
settings = ProbeSettings.Parse(args, out var showHelp);
|
|
if (showHelp)
|
|
{
|
|
Console.WriteLine(ProbeSettings.Help);
|
|
return 0;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Ui.Bad(ex.Message);
|
|
Console.WriteLine();
|
|
Console.WriteLine("Run with --help for usage.");
|
|
return 2;
|
|
}
|
|
|
|
Console.WriteLine();
|
|
Ui.Section("effective settings");
|
|
Ui.Field("primary DC", $"{settings.Server}:{settings.Port}");
|
|
Ui.Field("fallback DCs", settings.Fallbacks.Count == 0 ? "(none)" : string.Join(", ", settings.Fallbacks));
|
|
Ui.Field("transport", settings.Transport.ToString() + (settings.Insecure ? " [--insecure: certificate NOT validated]" : ""));
|
|
Ui.Field("search base", settings.SearchBase);
|
|
Ui.Field("login attribute", settings.UserNameAttribute);
|
|
Ui.Field("group attribute", settings.GroupAttribute);
|
|
Ui.Field("display attribute", settings.DisplayNameAttribute);
|
|
Ui.Field("bind identity", settings.SelfBind ? "(self-bind: the user under test)" : settings.ServiceAccount);
|
|
|
|
var certs = new RemoteCertificateValidationCallbackHolder(settings.Insecure, settings.ShowCertificate);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// optional transport sweep — answers "what actually connects from here"
|
|
// ---------------------------------------------------------------------------
|
|
if (settings.SweepTransports)
|
|
{
|
|
Ui.Section("transport sweep");
|
|
foreach (var host in new[] { settings.Server }.Concat(settings.Fallbacks))
|
|
{
|
|
foreach (var (transport, port) in new (LdapTransport, int)[]
|
|
{ (LdapTransport.Ldaps, 636), (LdapTransport.StartTls, 389), (LdapTransport.None, 389) })
|
|
{
|
|
var sw = Stopwatch.StartNew();
|
|
|
|
// Each attempt runs on its own worker under a hard wall-clock budget. Novell's
|
|
// teardown of a StartTLS-upgraded connection can block indefinitely, which would
|
|
// otherwise strand the sweep on its second probe and report nothing at all.
|
|
var attempt = Task.Run(() =>
|
|
{
|
|
var options = new LdapConnectionOptions().ConfigureRemoteCertificateValidationCallback(certs.Callback);
|
|
var conn = new LdapConnection(options) { ConnectionTimeout = settings.TimeoutMs };
|
|
try
|
|
{
|
|
if (transport == LdapTransport.Ldaps) conn.SecureSocketLayer = true;
|
|
conn.Connect(host, port);
|
|
if (transport == LdapTransport.StartTls) conn.StartTls();
|
|
return conn.Tls || conn.SecureSocketLayer ? " (encrypted)" : " (CLEARTEXT)";
|
|
}
|
|
finally
|
|
{
|
|
// Abandoned deliberately if it blocks: the probe is about to exit anyway.
|
|
_ = Task.Run(() => { try { conn.Dispose(); } catch { /* teardown only */ } });
|
|
}
|
|
});
|
|
|
|
var label = $"{host,-34} {transport,-9} :{port,-5}";
|
|
if (!attempt.Wait(settings.TimeoutMs))
|
|
Ui.Bad($"{label} timed out after {settings.TimeoutMs} ms");
|
|
else if (attempt.IsCompletedSuccessfully)
|
|
Ui.Ok($"{label} connected in {sw.ElapsedMilliseconds} ms{attempt.Result}");
|
|
else
|
|
Ui.Bad($"{label} {RawDirectory.Describe(attempt.Exception?.GetBaseException() ?? new Exception("unknown"))}");
|
|
}
|
|
}
|
|
|
|
// A sweep with no username named is a connectivity check on its own — don't then
|
|
// demand credentials for an auth test that was never asked for.
|
|
if (string.IsNullOrWhiteSpace(settings.Username))
|
|
{
|
|
Console.WriteLine();
|
|
Ui.Info("Sweep complete. Pass a username (or -u <name>) to also run the auth + group probe.");
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// credentials
|
|
// ---------------------------------------------------------------------------
|
|
Ui.Section("credentials");
|
|
var username = Ui.Prompt($"username ({settings.UserNameAttribute})", settings.Username);
|
|
if (string.IsNullOrWhiteSpace(username))
|
|
{
|
|
Ui.Bad("No username supplied.");
|
|
return 2;
|
|
}
|
|
|
|
var password = Ui.PromptSecret($"password for {username}");
|
|
if (password.Length == 0)
|
|
{
|
|
Ui.Bad("No password supplied.");
|
|
return 2;
|
|
}
|
|
|
|
// In self-bind mode the user's own credentials stand in for the service account. AD accepts a
|
|
// UPN for a simple bind, and an authenticated user can read the directory — so the full
|
|
// library path still runs, just without needing the svc_igntnapp secret.
|
|
string bindIdentity;
|
|
string bindPassword;
|
|
if (settings.SelfBind)
|
|
{
|
|
bindIdentity = username.Contains('@') ? username : username + settings.UpnSuffix;
|
|
bindPassword = password;
|
|
Ui.Info($"self-bind: searching as {bindIdentity}");
|
|
}
|
|
else
|
|
{
|
|
bindIdentity = settings.ServiceAccount;
|
|
bindPassword = settings.ServiceAccountPassword
|
|
?? Ui.PromptSecret($"password for service account {settings.ServiceAccount}");
|
|
if (bindPassword.Length == 0)
|
|
{
|
|
Ui.Bad("No service-account password. Supply one, set $ZB_LDAP_SVC_PASSWORD, or use --self-bind.");
|
|
return 2;
|
|
}
|
|
}
|
|
|
|
settings.ServiceAccount = bindIdentity;
|
|
settings.ServiceAccountPassword = bindPassword;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PART A — the real shared library
|
|
// ---------------------------------------------------------------------------
|
|
Ui.Section("PART A — ZB.MOM.WW.Auth.Ldap (what the apps will do)");
|
|
|
|
var logger = new ConsoleLogger<LdapAuthService>(settings.Verbose);
|
|
var result = await RunLibraryAsync(settings, certs, logger, username, password);
|
|
|
|
ReportLibrary(result);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PART B — direct queries the library does not make
|
|
// ---------------------------------------------------------------------------
|
|
Ui.Section("PART B — directory detail (group discovery)");
|
|
|
|
RawEntry? user = null;
|
|
List<string> directGroups = [];
|
|
List<RawEntry> transitiveGroups = [];
|
|
RawEntry? primaryGroup = null;
|
|
|
|
try
|
|
{
|
|
using var directory = RawDirectory.Connect(
|
|
settings, settings.Server, settings.Port, certs, bindIdentity, bindPassword);
|
|
|
|
Ui.Ok($"bound as {bindIdentity} ({(directory.IsEncrypted ? "encrypted" : "CLEARTEXT — simple bind sent in the clear")})");
|
|
|
|
var found = directory.FindUser(username);
|
|
if (found.Error is not null) Ui.Bad($"user search failed: {found.Error}");
|
|
if (found.Referrals > 0)
|
|
Ui.Info($"({found.Referrals} continuation referral(s) skipped for a search based at "
|
|
+ $"{settings.SearchBase} — handled since ZB.MOM.WW.Auth.Ldap 0.2.1; an app still "
|
|
+ "pinned below that will fail this search with ServiceAccountBindFailed)");
|
|
|
|
if (found.Entries.Count == 0)
|
|
{
|
|
Ui.Bad($"no entry matched ({settings.UserNameAttribute}={username}) under {settings.SearchBase}");
|
|
}
|
|
else if (found.Entries.Count > 1)
|
|
{
|
|
Ui.Bad($"{found.Entries.Count} entries matched — the library rejects this as AmbiguousUser:");
|
|
foreach (var e in found.Entries) Ui.Info(e.Dn);
|
|
}
|
|
else
|
|
{
|
|
user = found.Entries[0];
|
|
ReportUser(user);
|
|
|
|
directGroups = [.. user.All(settings.GroupAttribute)];
|
|
var described = directory.DescribeGroups(directGroups);
|
|
ReportDirectGroups(directGroups, described);
|
|
|
|
primaryGroup = directory.PrimaryGroup(user);
|
|
ReportPrimaryGroup(primaryGroup, user);
|
|
|
|
var transitive = directory.TransitiveGroups(user.Dn);
|
|
if (transitive.Error is not null) Ui.Bad($"transitive group search failed: {transitive.Error}");
|
|
transitiveGroups = transitive.Entries;
|
|
ReportTransitiveGroups(transitiveGroups, directGroups);
|
|
}
|
|
|
|
// Directory-wide group inventory. Independent of the user above: it answers "what groups
|
|
// exist to choose from", which no membership dump can, because a membership dump only ever
|
|
// shows the groups the one test account happens to be in.
|
|
if (!string.IsNullOrWhiteSpace(settings.ListGroupsPattern))
|
|
{
|
|
var groupBase = settings.GroupBase
|
|
?? RawDirectory.DomainRoot(user?.Dn ?? settings.SearchBase);
|
|
var listed = directory.ListGroups(settings.ListGroupsPattern, groupBase);
|
|
ReportGroupInventory(settings.ListGroupsPattern, groupBase, listed,
|
|
user is null ? [] : [.. user.All(settings.GroupAttribute)]);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Ui.Bad($"raw directory probe failed: {RawDirectory.Describe(ex)}");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// If the library failed system-side but the raw probe worked, the search base is the
|
|
// prime suspect. Retry Part A narrowed to the user's own container and report.
|
|
// ---------------------------------------------------------------------------
|
|
if (result.Failure == LdapAuthFailure.ServiceAccountBindFailed && user is not null)
|
|
{
|
|
var parent = ParentDn(user.Dn);
|
|
if (parent is not null && !parent.Equals(settings.SearchBase, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
Ui.Section("PART A retry — narrowed search base");
|
|
Ui.Info($"The library failed system-side while a direct query succeeded, so the search base");
|
|
Ui.Info($"is the likeliest cause. Retrying against the user's own container:");
|
|
Ui.Info($" {parent}");
|
|
|
|
var narrowed = new ProbeSettings
|
|
{
|
|
Server = settings.Server, Fallbacks = settings.Fallbacks, PortOverride = settings.Port,
|
|
Transport = settings.Transport, Insecure = settings.Insecure, TimeoutMs = settings.TimeoutMs,
|
|
SearchBase = parent, UserNameAttribute = settings.UserNameAttribute,
|
|
DisplayNameAttribute = settings.DisplayNameAttribute, GroupAttribute = settings.GroupAttribute,
|
|
ServiceAccount = bindIdentity, ServiceAccountPassword = bindPassword,
|
|
};
|
|
|
|
var retry = await RunLibraryAsync(narrowed, certs, logger, username, password);
|
|
ReportLibrary(retry);
|
|
if (retry.Succeeded)
|
|
{
|
|
Ui.Ok($"CONFIRMED: set SearchBase to a container below the domain root. "
|
|
+ "The root-level search is what fails.");
|
|
settings.SearchBase = parent;
|
|
result = retry;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// assessment + ready-to-paste configuration
|
|
// ---------------------------------------------------------------------------
|
|
Assess(result, user, directGroups, transitiveGroups, primaryGroup, settings);
|
|
EmitConfig(settings, result);
|
|
|
|
Console.Out.Flush();
|
|
|
|
// Hard exit: an abandoned LDAP teardown worker would otherwise keep the process alive
|
|
// after every useful line has been printed.
|
|
Environment.Exit(result.Succeeded ? 0 : 1);
|
|
return 0;
|
|
|
|
// ===========================================================================
|
|
// helpers
|
|
// ===========================================================================
|
|
|
|
static async Task<LdapAuthResult> RunLibraryAsync(ProbeSettings settings,
|
|
RemoteCertificateValidationCallbackHolder certs, ConsoleLogger<LdapAuthService> logger,
|
|
string username, string password)
|
|
{
|
|
var options = settings.ToLdapOptions(certs);
|
|
|
|
var validation = new LdapOptionsValidator().Validate(null, options);
|
|
if (validation.Failed)
|
|
{
|
|
Ui.Bad($"options rejected by the library's own boot validator: {validation.FailureMessage}");
|
|
return LdapAuthResult.Fail(LdapAuthFailure.Disabled);
|
|
}
|
|
|
|
var service = new LdapAuthService(options, logger);
|
|
var sw = Stopwatch.StartNew();
|
|
var result = await service.AuthenticateAsync(username, password, CancellationToken.None);
|
|
Ui.Info($"completed in {sw.ElapsedMilliseconds} ms");
|
|
return result;
|
|
}
|
|
|
|
static void ReportLibrary(LdapAuthResult result)
|
|
{
|
|
if (result.Succeeded)
|
|
{
|
|
Ui.Ok("AUTHENTICATED");
|
|
Ui.Field("username", result.Username);
|
|
Ui.Field("display name", result.DisplayName);
|
|
Ui.Field("groups seen", result.Groups.Count.ToString());
|
|
foreach (var g in result.Groups) Ui.Info($" • {g}");
|
|
return;
|
|
}
|
|
|
|
Ui.Bad($"FAILED — {result.Failure}");
|
|
Ui.Info(result.Failure switch
|
|
{
|
|
LdapAuthFailure.BadCredentials =>
|
|
"The directory answered: the password did not verify (or the username is empty).",
|
|
LdapAuthFailure.UserNotFound =>
|
|
"The search ran but matched no entry. Suspect the search base or the login attribute.",
|
|
LdapAuthFailure.AmbiguousUser =>
|
|
"The search matched more than one entry. The filter is not selective enough.",
|
|
LdapAuthFailure.GroupLookupFailed =>
|
|
"The password VERIFIED, but the user resolved to zero groups. The library refuses to "
|
|
+ "admit a zero-group success, so this is the known NAM blocker, not a credential problem.",
|
|
LdapAuthFailure.ServiceAccountBindFailed =>
|
|
"System-side: connect/TLS, the service-account bind, or the search itself. The library "
|
|
+ "collapses all three into this one value — Part B below separates them.",
|
|
LdapAuthFailure.Disabled => "The provider was disabled.",
|
|
_ => ""
|
|
});
|
|
}
|
|
|
|
static void ReportUser(RawEntry user)
|
|
{
|
|
Ui.Ok($"found {user.Dn}");
|
|
Ui.Field("sAMAccountName", user.First("sAMAccountName"));
|
|
Ui.Field("userPrincipalName", user.First("userPrincipalName"));
|
|
Ui.Field("name", user.First("name"));
|
|
Ui.Field("displayName", user.First("displayName"));
|
|
Ui.Field("mail", user.First("mail"));
|
|
Ui.Field("department", user.First("department"));
|
|
Ui.Field("title", user.First("title"));
|
|
Ui.Field("objectSid", RawDirectory.FormatSid(user.Bytes("objectSid")));
|
|
Ui.Field("userAccountControl", RawDirectory.FormatUac(user.First("userAccountControl")));
|
|
Ui.Field("whenCreated", user.First("whenCreated"));
|
|
}
|
|
|
|
static void ReportDirectGroups(List<string> dns, Dictionary<string, RawEntry> described)
|
|
{
|
|
Console.WriteLine();
|
|
Ui.Section($"direct groups — the {dns.Count} the shared library sees (memberOf)");
|
|
if (dns.Count == 0)
|
|
{
|
|
Ui.Bad("none. The library treats zero groups as GroupLookupFailed, so login is refused "
|
|
+ "even with a correct password.");
|
|
return;
|
|
}
|
|
|
|
foreach (var dn in dns.OrderBy(RawDirectory.ShortName, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
var type = described.TryGetValue(dn, out var e) ? RawDirectory.FormatGroupType(e.First("groupType")) : "?";
|
|
var description = described.TryGetValue(dn, out var e2) ? e2.First("description") : null;
|
|
Ui.Info($" • {RawDirectory.ShortName(dn),-42} [{type}]");
|
|
Ui.Info($" {dn}");
|
|
if (!string.IsNullOrWhiteSpace(description)) Ui.Info($" \"{description}\"");
|
|
}
|
|
}
|
|
|
|
static void ReportPrimaryGroup(RawEntry? group, RawEntry user)
|
|
{
|
|
Console.WriteLine();
|
|
Ui.Section("primary group — present in AD, ABSENT from memberOf");
|
|
if (group is null)
|
|
{
|
|
Ui.Info($"could not resolve (primaryGroupID={user.First("primaryGroupID") ?? "?"})");
|
|
return;
|
|
}
|
|
|
|
Ui.Info($" • {RawDirectory.ShortName(group.Dn),-42} [{RawDirectory.FormatGroupType(group.First("groupType"))}]");
|
|
Ui.Info($" {group.Dn}");
|
|
Ui.Warn("Do NOT use this for authorization: AD omits the primary group from memberOf, so the "
|
|
+ "shared library will never see it.");
|
|
}
|
|
|
|
static void ReportTransitiveGroups(List<RawEntry> groups, List<string> directDns)
|
|
{
|
|
Console.WriteLine();
|
|
Ui.Section($"effective groups — {groups.Count} including nested (LDAP_MATCHING_RULE_IN_CHAIN)");
|
|
if (groups.Count == 0)
|
|
{
|
|
Ui.Info("none resolved.");
|
|
return;
|
|
}
|
|
|
|
var direct = new HashSet<string>(directDns, StringComparer.OrdinalIgnoreCase);
|
|
var nestedOnly = 0;
|
|
|
|
foreach (var g in groups.OrderBy(g => RawDirectory.ShortName(g.Dn), StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
var isDirect = direct.Contains(g.Dn);
|
|
if (!isDirect) nestedOnly++;
|
|
var marker = isDirect ? "direct " : "NESTED ";
|
|
Ui.Info($" • [{marker}] {RawDirectory.ShortName(g.Dn),-40} [{RawDirectory.FormatGroupType(g.First("groupType"))}]");
|
|
if (!isDirect) Ui.Info($" {g.Dn}");
|
|
}
|
|
|
|
if (nestedOnly > 0)
|
|
{
|
|
Console.WriteLine();
|
|
Ui.Warn($"{nestedOnly} group(s) are reachable only through nesting. The shared library reads "
|
|
+ "memberOf, which holds direct edges ONLY — so choosing one of these as a role group "
|
|
+ "would silently grant nobody anything.");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Prints the directory-wide inventory for a group-name pattern, marking which entries the
|
|
/// probed account already holds so an existing convention can be read off directly.
|
|
/// </summary>
|
|
static void ReportGroupInventory(string pattern, string baseDn, SearchOutcome listed,
|
|
List<string> heldByUser)
|
|
{
|
|
Console.WriteLine();
|
|
Ui.Section($"group inventory — cn={pattern}");
|
|
Ui.Info($"searched {baseDn}");
|
|
|
|
if (listed.Error is not null)
|
|
{
|
|
Ui.Bad($"group enumeration failed: {listed.Error}");
|
|
return;
|
|
}
|
|
|
|
if (listed.Referrals > 0)
|
|
Ui.Info($"({listed.Referrals} continuation referral(s) skipped)");
|
|
|
|
if (listed.Entries.Count == 0)
|
|
{
|
|
Ui.Warn($"no group matched cn={pattern}. Check the pattern and that {baseDn} is broad enough.");
|
|
return;
|
|
}
|
|
|
|
var held = new HashSet<string>(heldByUser, StringComparer.OrdinalIgnoreCase);
|
|
Ui.Ok($"{listed.Entries.Count} group(s) match — 'you' marks the ones the probed account holds");
|
|
Console.WriteLine();
|
|
|
|
foreach (var g in listed.Entries.OrderBy(g => RawDirectory.ShortName(g.Dn), StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
var (count, ranged) = RawDirectory.MemberCount(g);
|
|
var members = count == 0 ? "EMPTY" : ranged ? $"{count}+" : count.ToString();
|
|
Ui.Info($" {(held.Contains(g.Dn) ? "you" : " ")} {RawDirectory.ShortName(g.Dn),-40} "
|
|
+ $"{members,6} members [{RawDirectory.FormatGroupType(g.First("groupType"))}]");
|
|
|
|
var description = g.First("description");
|
|
if (!string.IsNullOrWhiteSpace(description)) Ui.Info($" \"{description}\"");
|
|
}
|
|
|
|
var empty = listed.Entries.Count(g => RawDirectory.MemberCount(g).Count == 0);
|
|
if (empty > 0)
|
|
{
|
|
Console.WriteLine();
|
|
Ui.Warn($"{empty} of these are EMPTY. An empty group is a valid role target only if "
|
|
+ "someone will populate it — mapping a role to one grants nobody anything today.");
|
|
}
|
|
|
|
Console.WriteLine();
|
|
Ui.Info("'N+' means AD ranged the member attribute at its 1500-value cap, so the real count is higher.");
|
|
}
|
|
|
|
static void Assess(LdapAuthResult result, RawEntry? user, List<string> direct,
|
|
List<RawEntry> transitive, RawEntry? primary, ProbeSettings settings)
|
|
{
|
|
Ui.Section("assessment");
|
|
|
|
if (result.Succeeded)
|
|
Ui.Ok("Authentication works with these settings. They can go straight into the apps.");
|
|
else if (result.Failure == LdapAuthFailure.GroupLookupFailed)
|
|
Ui.Warn("Credentials are correct and the directory is reachable — only the zero-group rule "
|
|
+ "blocks login. Grant this user a group, or map an existing one below.");
|
|
else if (user is not null)
|
|
Ui.Warn("The directory is reachable and the user resolves, but the library still refuses. "
|
|
+ "Compare Part A's failure with the detail above.");
|
|
else
|
|
Ui.Bad("The probe could not resolve the user. Fix connectivity/base/attribute first.");
|
|
|
|
if (!settings.Insecure && settings.Transport != LdapTransport.None)
|
|
Ui.Info("TLS was validated against this machine's trust store — the chain is genuinely trusted here.");
|
|
else if (settings.Insecure)
|
|
Ui.Warn("--insecure was used: certificate validation was BYPASSED. Before deploying, either "
|
|
+ "install zimmer-rootca into the host trust store or pin the chain via "
|
|
+ "LdapOptions.ServerCertificateValidationCallback.");
|
|
if (settings.Transport == LdapTransport.None)
|
|
Ui.Warn("Transport=None sends the simple bind — including the password — in cleartext. "
|
|
+ "The DCs accept LDAPS on 636 and StartTLS on 389; prefer one of those.");
|
|
|
|
// The point of the whole exercise: which groups are actually usable as role groups.
|
|
var usable = transitive.Count > 0
|
|
? transitive.Where(g => new HashSet<string>(direct, StringComparer.OrdinalIgnoreCase).Contains(g.Dn)).ToList()
|
|
: [];
|
|
if (usable.Count > 0)
|
|
{
|
|
Console.WriteLine();
|
|
Ui.Info("Candidate role groups — direct AND security-enabled, i.e. the ones the shared library");
|
|
Ui.Info("will actually see in memberOf:");
|
|
foreach (var g in usable.Where(g => RawDirectory.FormatGroupType(g.First("groupType")).Contains("security")))
|
|
Ui.Info($" \"{RawDirectory.ShortName(g.Dn)}\"");
|
|
}
|
|
|
|
if (primary is not null && direct.Count == 0)
|
|
Ui.Warn($"This user's ONLY group is the primary group ({RawDirectory.ShortName(primary.Dn)}), "
|
|
+ "which memberOf never returns. That is exactly why the login fails.");
|
|
}
|
|
|
|
static void EmitConfig(ProbeSettings settings, LdapAuthResult result)
|
|
{
|
|
Ui.Section("appsettings block for these settings");
|
|
var fallbacks = settings.Fallbacks.Count == 0
|
|
? "[]"
|
|
: "[ " + string.Join(", ", settings.Fallbacks.Select(f => $"\"{f}\"")) + " ]";
|
|
|
|
Console.WriteLine($$"""
|
|
"Ldap": {
|
|
"Enabled": true,
|
|
"Server": "{{settings.Server}}",
|
|
"Port": {{settings.Port}},
|
|
"FallbackServers": {{fallbacks}},
|
|
"Transport": "{{settings.Transport}}",
|
|
"AllowInsecure": false,
|
|
"SearchBase": "{{settings.SearchBase}}",
|
|
"ServiceAccountDn": "svc_igntnapp@nam.zimmer.com",
|
|
"ServiceAccountPassword": "${secret:LDAP_SVC_PASSWORD}",
|
|
"UserNameAttribute": "{{settings.UserNameAttribute}}",
|
|
"DisplayNameAttribute": "{{settings.DisplayNameAttribute}}",
|
|
"GroupAttribute": "{{settings.GroupAttribute}}",
|
|
"ConnectionTimeoutMs": {{settings.TimeoutMs}}
|
|
}
|
|
""");
|
|
|
|
Console.WriteLine();
|
|
Ui.Info("Notes: the service-account password belongs in the Secrets store as a ${secret:} reference,");
|
|
Ui.Info("never inline. AllowInsecure is deliberately false here even if the probe used --insecure —");
|
|
Ui.Info("trust must be established properly before deployment.");
|
|
if (!result.Succeeded)
|
|
Ui.Warn("These settings did NOT produce a successful login in this run.");
|
|
}
|
|
|
|
static string? ParentDn(string dn)
|
|
{
|
|
var comma = dn.IndexOf(',');
|
|
return comma < 0 || comma + 1 >= dn.Length ? null : dn[(comma + 1)..].Trim();
|
|
}
|