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.
364 lines
15 KiB
C#
364 lines
15 KiB
C#
using System.Text;
|
|
using Novell.Directory.Ldap;
|
|
|
|
namespace ZB.MOM.WW.Tools.LdapProbe;
|
|
|
|
/// <summary>One directory entry, keeping the raw attributes so binary values stay readable.</summary>
|
|
public sealed record RawEntry(string Dn, Dictionary<string, LdapAttribute> Attributes)
|
|
{
|
|
public string? First(string name) =>
|
|
Attributes.TryGetValue(name, out var a) ? a.StringValueArray.FirstOrDefault() : null;
|
|
|
|
public string[] All(string name) =>
|
|
Attributes.TryGetValue(name, out var a) ? a.StringValueArray : [];
|
|
|
|
public byte[]? Bytes(string name) =>
|
|
Attributes.TryGetValue(name, out var a) ? a.ByteValue : null;
|
|
}
|
|
|
|
/// <summary>Outcome of one search, including how many continuation referrals it hit.</summary>
|
|
public sealed record SearchOutcome(List<RawEntry> Entries, int Referrals, string? Error);
|
|
|
|
/// <summary>
|
|
/// Direct LDAP queries that the shared library does not make. The library answers
|
|
/// "may this person log in"; this answers "who is this person and what are they in",
|
|
/// which is what picking authorization groups requires.
|
|
/// </summary>
|
|
public sealed class RawDirectory : IDisposable
|
|
{
|
|
/// <summary>AD's LDAP_MATCHING_RULE_IN_CHAIN — walks nested group membership server-side.</summary>
|
|
private const string InChain = "1.2.840.113556.1.4.1941";
|
|
|
|
private readonly LdapConnection _conn;
|
|
private readonly ProbeSettings _settings;
|
|
|
|
private RawDirectory(LdapConnection conn, ProbeSettings settings)
|
|
{
|
|
_conn = conn;
|
|
_settings = settings;
|
|
}
|
|
|
|
public static RawDirectory Connect(ProbeSettings settings, string host, int port,
|
|
RemoteCertificateValidationCallbackHolder certs, string bindDn, string bindPassword)
|
|
{
|
|
var options = new LdapConnectionOptions()
|
|
.ConfigureRemoteCertificateValidationCallback(certs.Callback);
|
|
var conn = new LdapConnection(options) { ConnectionTimeout = settings.TimeoutMs };
|
|
|
|
if (settings.Transport == Auth.Abstractions.Ldap.LdapTransport.Ldaps)
|
|
conn.SecureSocketLayer = true;
|
|
|
|
conn.Connect(host, port);
|
|
|
|
if (settings.Transport == Auth.Abstractions.Ldap.LdapTransport.StartTls)
|
|
{
|
|
conn.StartTls();
|
|
if (!conn.Tls) throw new InvalidOperationException("StartTLS did not produce an encrypted session.");
|
|
}
|
|
|
|
conn.Bind(bindDn, bindPassword);
|
|
return new RawDirectory(conn, settings);
|
|
}
|
|
|
|
/// <summary>True once the session is encrypted (either transport).</summary>
|
|
public bool IsEncrypted => _conn.Tls || _conn.SecureSocketLayer;
|
|
|
|
// ------------------------------------------------------------------------
|
|
// search
|
|
// ------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Runs a subtree search, tolerating the continuation referrals that AD returns for
|
|
/// any search based at a domain root. Referrals are counted rather than thrown,
|
|
/// because whether they occur is itself a finding — the shared library's search does
|
|
/// not catch <see cref="LdapReferralException"/>, so a referral there surfaces as a
|
|
/// system-side failure.
|
|
/// </summary>
|
|
public SearchOutcome Search(string filter, string[] attributes, string? baseDn = null)
|
|
{
|
|
var entries = new List<RawEntry>();
|
|
var referrals = 0;
|
|
|
|
try
|
|
{
|
|
var results = _conn.Search(
|
|
baseDn ?? _settings.SearchBase,
|
|
LdapConnection.ScopeSub,
|
|
filter,
|
|
attributes.Length == 0 ? null : attributes,
|
|
typesOnly: false);
|
|
|
|
while (true)
|
|
{
|
|
try
|
|
{
|
|
if (!results.HasMore()) break;
|
|
var entry = results.Next();
|
|
var map = new Dictionary<string, LdapAttribute>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (LdapAttribute attr in entry.GetAttributeSet())
|
|
map[attr.Name] = attr;
|
|
entries.Add(new RawEntry(entry.Dn, map));
|
|
}
|
|
catch (LdapReferralException)
|
|
{
|
|
// A pointer to another naming context, not an error. Keep reading.
|
|
referrals++;
|
|
}
|
|
catch (LdapException ex) when (ex.ResultCode == LdapException.Referral)
|
|
{
|
|
referrals++;
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new SearchOutcome(entries, referrals, Describe(ex));
|
|
}
|
|
|
|
return new SearchOutcome(entries, referrals, null);
|
|
}
|
|
|
|
/// <summary>Finds the user by the configured login attribute — the same lookup the library performs.</summary>
|
|
public SearchOutcome FindUser(string username) => Search(
|
|
$"({_settings.UserNameAttribute}={Escape(username)})",
|
|
[
|
|
"distinguishedName", "sAMAccountName", "userPrincipalName", "name", "displayName", "cn",
|
|
"mail", "telephoneNumber", "mobile", "department", "title",
|
|
"memberOf", "primaryGroupID", "objectSid", "userAccountControl",
|
|
"whenCreated", "lastLogonTimestamp", "pwdLastSet", "objectClass",
|
|
_settings.GroupAttribute, _settings.DisplayNameAttribute
|
|
]);
|
|
|
|
/// <summary>
|
|
/// Every group the user is in <b>including nested ones</b>, resolved server-side.
|
|
/// This is the set that matters when choosing an authorization group: a user is
|
|
/// commonly a member of a role group only by way of a team group, and <c>memberOf</c>
|
|
/// shows only the direct edge.
|
|
/// </summary>
|
|
public SearchOutcome TransitiveGroups(string userDn) => Search(
|
|
$"(&(objectClass=group)(member:{InChain}:={Escape(userDn)}))",
|
|
["distinguishedName", "cn", "name", "description", "groupType", "objectSid"]);
|
|
|
|
/// <summary>
|
|
/// Resolves the primary group, which AD deliberately omits from <c>memberOf</c>.
|
|
/// Its SID is the user's own SID with the last sub-authority replaced by
|
|
/// <c>primaryGroupID</c>, so it is derived rather than looked up by name.
|
|
/// </summary>
|
|
public RawEntry? PrimaryGroup(RawEntry user)
|
|
{
|
|
var sid = user.Bytes("objectSid");
|
|
var idText = user.First("primaryGroupID");
|
|
if (sid is null || sid.Length < 8 || !uint.TryParse(idText, out var rid)) return null;
|
|
|
|
var groupSid = (byte[])sid.Clone();
|
|
BitConverter.GetBytes(rid).CopyTo(groupSid, groupSid.Length - 4);
|
|
|
|
var outcome = Search($"(objectSid={EscapeBinary(groupSid)})",
|
|
["distinguishedName", "cn", "name", "description", "groupType", "objectSid"]);
|
|
return outcome.Entries.FirstOrDefault();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enumerates every group whose <c>cn</c> matches a wildcard pattern — the directory-wide
|
|
/// view, as opposed to the one account's membership every other query here reports.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Deliberately based at the <b>domain root</b> by default: groups here are spread across at
|
|
/// least three containers (<c>OU=Universal,OU=Groups,OU=Warsaw</c>, <c>OU=Groups,OU=Warsaw</c>
|
|
/// and <c>OU=Groups</c> at the root), so anything narrower silently under-reports.
|
|
/// </remarks>
|
|
public SearchOutcome ListGroups(string cnPattern, string baseDn) => Search(
|
|
$"(&(objectClass=group)(cn={EscapePattern(cnPattern)}))",
|
|
["distinguishedName", "cn", "name", "description", "groupType", "member"],
|
|
baseDn);
|
|
|
|
/// <summary>
|
|
/// Counts a group's members, reporting whether AD ranged the attribute.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// AD caps a multi-valued attribute at 1500 values per response and renames the attribute
|
|
/// to <c>member;range=0-1499</c> when it does. Reporting a flat count off that would
|
|
/// understate a large group, so the ranged case is surfaced rather than hidden — the count
|
|
/// is only ever used here to tell a populated group from an empty one.
|
|
/// </remarks>
|
|
public static (int Count, bool Ranged) MemberCount(RawEntry group)
|
|
{
|
|
var attribute = group.Attributes
|
|
.FirstOrDefault(kv => kv.Key.StartsWith("member", StringComparison.OrdinalIgnoreCase));
|
|
|
|
if (attribute.Key is null) return (0, false);
|
|
|
|
return (attribute.Value.StringValueArray.Length,
|
|
attribute.Key.Contains("range=", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
/// <summary>Derives the domain root (the <c>DC=</c> components) from any DN.</summary>
|
|
public static string DomainRoot(string dn)
|
|
{
|
|
var parts = dn.Split(',')
|
|
.Select(p => p.Trim())
|
|
.SkipWhile(p => !p.StartsWith("DC=", StringComparison.OrdinalIgnoreCase))
|
|
.ToArray();
|
|
|
|
return parts.Length > 0 ? string.Join(",", parts) : dn;
|
|
}
|
|
|
|
/// <summary>Resolves group DNs to entries so distribution vs security groups can be told apart.</summary>
|
|
public Dictionary<string, RawEntry> DescribeGroups(IEnumerable<string> dns)
|
|
{
|
|
var map = new Dictionary<string, RawEntry>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var dn in dns.Distinct(StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
var outcome = Search($"(distinguishedName={Escape(dn)})",
|
|
["distinguishedName", "cn", "name", "description", "groupType", "objectSid"]);
|
|
if (outcome.Entries.Count > 0) map[dn] = outcome.Entries[0];
|
|
}
|
|
|
|
return map;
|
|
}
|
|
|
|
// ------------------------------------------------------------------------
|
|
// formatting helpers
|
|
// ------------------------------------------------------------------------
|
|
|
|
/// <summary>RFC 4515 filter-value escaping.</summary>
|
|
public static string Escape(string value)
|
|
{
|
|
var sb = new StringBuilder(value.Length);
|
|
foreach (var c in value)
|
|
{
|
|
sb.Append(c switch
|
|
{
|
|
'\\' => "\\5c",
|
|
'*' => "\\2a",
|
|
'(' => "\\28",
|
|
')' => "\\29",
|
|
'\0' => "\\00",
|
|
_ => c.ToString()
|
|
});
|
|
}
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Escapes a filter value while <b>preserving <c>*</c> as a wildcard</b>. Used only for
|
|
/// operator-supplied search patterns; ordinary values must go through <see cref="Escape"/>,
|
|
/// where a literal <c>*</c> in a name would otherwise silently become a wildcard.
|
|
/// </summary>
|
|
public static string EscapePattern(string pattern)
|
|
{
|
|
var sb = new StringBuilder(pattern.Length);
|
|
foreach (var c in pattern)
|
|
{
|
|
sb.Append(c switch
|
|
{
|
|
'\\' => "\\5c",
|
|
'(' => "\\28",
|
|
')' => "\\29",
|
|
'\0' => "\\00",
|
|
_ => c.ToString()
|
|
});
|
|
}
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>Binary filter-value form: every byte as <c>\XX</c>.</summary>
|
|
public static string EscapeBinary(byte[] bytes)
|
|
{
|
|
var sb = new StringBuilder(bytes.Length * 3);
|
|
foreach (var b in bytes) sb.Append('\\').Append(b.ToString("x2"));
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>Renders a binary SID in the familiar S-1-5-21-… form.</summary>
|
|
public static string FormatSid(byte[]? sid)
|
|
{
|
|
if (sid is null || sid.Length < 8) return "(none)";
|
|
var revision = sid[0];
|
|
var subAuthorityCount = sid[1];
|
|
ulong authority = 0;
|
|
for (var i = 2; i < 8; i++) authority = (authority << 8) | sid[i];
|
|
|
|
var sb = new StringBuilder($"S-{revision}-{authority}");
|
|
for (var i = 0; i < subAuthorityCount && 8 + i * 4 + 4 <= sid.Length; i++)
|
|
sb.Append('-').Append(BitConverter.ToUInt32(sid, 8 + i * 4));
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>Decodes the <c>groupType</c> bitmask into scope + security/distribution.</summary>
|
|
public static string FormatGroupType(string? raw)
|
|
{
|
|
if (!int.TryParse(raw, out var value)) return "unknown";
|
|
var scope = (value & 0x08) != 0 ? "universal"
|
|
: (value & 0x04) != 0 ? "domain-local"
|
|
: (value & 0x02) != 0 ? "global"
|
|
: "builtin";
|
|
var kind = (value & unchecked((int)0x80000000)) != 0 ? "security" : "distribution";
|
|
return $"{scope} {kind}";
|
|
}
|
|
|
|
/// <summary>Decodes the userAccountControl flags that explain a failed or fragile login.</summary>
|
|
public static string FormatUac(string? raw)
|
|
{
|
|
if (!int.TryParse(raw, out var value)) return "unknown";
|
|
var flags = new List<string>();
|
|
if ((value & 0x0002) != 0) flags.Add("*** DISABLED ***");
|
|
if ((value & 0x0010) != 0) flags.Add("*** LOCKED OUT ***");
|
|
if ((value & 0x0020) != 0) flags.Add("password-not-required");
|
|
if ((value & 0x0200) != 0) flags.Add("normal-account");
|
|
if ((value & 0x10000) != 0) flags.Add("password-never-expires");
|
|
if ((value & 0x800000) != 0) flags.Add("*** PASSWORD EXPIRED ***");
|
|
if ((value & 0x400000) != 0) flags.Add("no-preauth-required");
|
|
return flags.Count == 0 ? $"0x{value:x}" : $"0x{value:x} ({string.Join(", ", flags)})";
|
|
}
|
|
|
|
/// <summary>Short name from a DN: the value of its first RDN.</summary>
|
|
public static string ShortName(string dn)
|
|
{
|
|
var first = dn.Split(',')[0];
|
|
var eq = first.IndexOf('=');
|
|
return eq >= 0 ? first[(eq + 1)..] : first;
|
|
}
|
|
|
|
public static string Describe(Exception ex) => ex switch
|
|
{
|
|
LdapException l => $"LDAP {l.ResultCode} {l.ResultCode switch
|
|
{
|
|
LdapException.InvalidCredentials => "(invalid credentials)",
|
|
LdapException.NoSuchObject => "(no such object — check the search base)",
|
|
LdapException.InsufficientAccessRights => "(insufficient access rights)",
|
|
LdapException.OperationsError => "(operations error — often 'bind first' or a referral issue)",
|
|
LdapException.ConnectError => "(connect error)",
|
|
LdapException.SizeLimitExceeded => "(size limit exceeded)",
|
|
_ => ""
|
|
}} — {l.Message}{(string.IsNullOrWhiteSpace(l.LdapErrorMessage) ? "" : $" | {l.LdapErrorMessage}")}",
|
|
_ => $"{ex.GetType().Name}: {ex.Message}"
|
|
};
|
|
|
|
/// <summary>
|
|
/// Bounded teardown. Novell can block indefinitely closing a TLS-upgraded connection, so
|
|
/// the disconnect runs on a worker and is abandoned after a short grace period rather than
|
|
/// hanging a probe whose useful output has already been printed.
|
|
/// </summary>
|
|
public void Dispose()
|
|
{
|
|
var closing = Task.Run(() =>
|
|
{
|
|
try
|
|
{
|
|
if (_conn.Connected) _conn.Disconnect();
|
|
_conn.Dispose();
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// Nothing useful to do while tearing a diagnostic connection down.
|
|
}
|
|
});
|
|
|
|
closing.Wait(TimeSpan.FromSeconds(3));
|
|
}
|
|
}
|