Files
scadaproj/tools/LdapProbe/ProbeSettings.cs
T
Joseph Doherty 4e01cbc2c3 feat(tools): ldapprobe -- NAM AD auth + group discovery probe
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.
2026-08-12 21:27:14 -04:00

202 lines
10 KiB
C#

using ZB.MOM.WW.Auth.Abstractions.Ldap;
namespace ZB.MOM.WW.Tools.LdapProbe;
/// <summary>
/// Effective probe configuration. Defaults come from <c>scadaproj/ldap_setup.md</c>
/// (the audited Ignition NAM profile), so a bare run already points at production.
/// </summary>
public sealed class ProbeSettings
{
// --- directory / connection -------------------------------------------------
public string Server { get; set; } = "uswap-vdc-nam1.nam.zimmer.com";
public List<string> Fallbacks { get; set; } = ["uswap-vdc-nam2.nam.zimmer.com"];
public int? PortOverride { get; set; }
public LdapTransport Transport { get; set; } = LdapTransport.Ldaps;
public bool Insecure { get; set; }
public int TimeoutMs { get; set; } = 15_000;
/// <summary>Port actually used: explicit override, else 636 for LDAPS and 389 otherwise.</summary>
public int Port => PortOverride ?? (Transport == LdapTransport.Ldaps ? 636 : 389);
// --- search -----------------------------------------------------------------
public string SearchBase { get; set; } = "DC=nam,DC=zimmer,DC=com";
public string UserNameAttribute { get; set; } = "sAMAccountName";
public string DisplayNameAttribute { get; set; } = "name";
public string GroupAttribute { get; set; } = "memberOf";
// --- identities -------------------------------------------------------------
/// <summary>Service-account bind identity. AD accepts a UPN here for a simple bind.</summary>
public string ServiceAccount { get; set; } = "svc_igntnapp@nam.zimmer.com";
public string? ServiceAccountPassword { get; set; }
/// <summary>
/// When true the probe binds as the end user for the raw dump instead of the service
/// account. Lets you get group data without holding the svc_igntnapp password — at the
/// cost of skipping Part A, which structurally requires a service account.
/// </summary>
public bool SelfBind { get; set; }
/// <summary>UPN suffix appended to a bare username for the self-bind and the UPN probe.</summary>
public string UpnSuffix { get; set; } = "@nam.zimmer.com";
// --- behaviour --------------------------------------------------------------
public string? Username { get; set; }
public bool SweepTransports { get; set; }
/// <summary>
/// When set, additionally enumerates every group in the directory whose <c>cn</c> matches
/// this wildcard pattern (e.g. <c>UG_WW_*</c>). Answers "what groups exist", which a
/// membership dump structurally cannot — it only ever shows the groups one account is in.
/// </summary>
public string? ListGroupsPattern { get; set; }
/// <summary>
/// Base for the group enumeration. Defaults to the domain root derived from
/// <see cref="SearchBase"/>, because groups are spread across several containers and a
/// narrower base under-reports silently.
/// </summary>
public string? GroupBase { get; set; }
public bool ShowCertificate { get; set; } = true;
public bool Verbose { get; set; }
/// <summary>Builds the options object the shared library will be driven with.</summary>
public LdapOptions ToLdapOptions(RemoteCertificateValidationCallbackHolder certs) => new()
{
Enabled = true,
Server = Server,
Port = Port,
FallbackServers = Fallbacks.ToArray(),
Transport = Transport,
AllowInsecure = Insecure,
SearchBase = SearchBase,
ServiceAccountDn = ServiceAccount,
ServiceAccountPassword = ServiceAccountPassword ?? "",
UserNameAttribute = UserNameAttribute,
DisplayNameAttribute = DisplayNameAttribute,
GroupAttribute = GroupAttribute,
ConnectionTimeoutMs = TimeoutMs,
ServerCertificateValidationCallback = certs.Callback,
};
public static ProbeSettings Parse(string[] args, out bool showHelp)
{
var s = new ProbeSettings();
showHelp = false;
string Next(ref int i, string flag) =>
++i < args.Length ? args[i] : throw new ArgumentException($"{flag} requires a value.");
for (var i = 0; i < args.Length; i++)
{
switch (args[i].ToLowerInvariant())
{
case "-h" or "--help": showHelp = true; break;
case "-u" or "--user": s.Username = Next(ref i, "--user"); break;
case "-s" or "--server": s.Server = Next(ref i, "--server"); break;
case "--fallback":
var fb = Next(ref i, "--fallback");
s.Fallbacks = fb.Equals("none", StringComparison.OrdinalIgnoreCase)
? []
: [.. fb.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
break;
case "-p" or "--port": s.PortOverride = int.Parse(Next(ref i, "--port")); break;
case "-t" or "--transport":
var t = Next(ref i, "--transport");
s.Transport = t.ToLowerInvariant() switch
{
"ldaps" => LdapTransport.Ldaps,
"starttls" => LdapTransport.StartTls,
"none" or "plain" => LdapTransport.None,
_ => throw new ArgumentException($"Unknown transport '{t}' (ldaps|starttls|none).")
};
break;
case "--insecure": s.Insecure = true; break;
case "--sweep": s.SweepTransports = true; break;
case "-b" or "--base": s.SearchBase = Next(ref i, "--base"); break;
case "--user-attr": s.UserNameAttribute = Next(ref i, "--user-attr"); break;
case "--group-attr": s.GroupAttribute = Next(ref i, "--group-attr"); break;
case "--display-attr": s.DisplayNameAttribute = Next(ref i, "--display-attr"); break;
case "--svc": s.ServiceAccount = Next(ref i, "--svc"); break;
case "--self-bind": s.SelfBind = true; break;
case "--upn-suffix": s.UpnSuffix = Next(ref i, "--upn-suffix"); break;
case "--timeout": s.TimeoutMs = int.Parse(Next(ref i, "--timeout")); break;
case "-g" or "--list-groups": s.ListGroupsPattern = Next(ref i, "--list-groups"); break;
case "--group-base": s.GroupBase = Next(ref i, "--group-base"); break;
case "-v" or "--verbose": s.Verbose = true; break;
default:
if (args[i].StartsWith('-'))
throw new ArgumentException($"Unknown option '{args[i]}'.");
s.Username ??= args[i];
break;
}
}
// Env fallback for the service-account secret so it never has to sit in shell history.
s.ServiceAccountPassword ??= Environment.GetEnvironmentVariable("ZB_LDAP_SVC_PASSWORD");
return s;
}
public const string Help = """
ldapprobe — verify ZB / NAM Active Directory auth and dump a user's group membership.
USAGE
dotnet run --project tools/LdapProbe -- [options] [username]
The probe runs two independent halves:
Part A the REAL ZB.MOM.WW.Auth.Ldap library, so its verdict is exactly what
ScadaBridge / OtOpcUa / MxGateway / HistorianGateway will produce.
Part B a direct query that answers what the library cannot: full group DNs,
nested (transitive) groups, the primary group, and referral behaviour.
CONNECTION
-s, --server <host> primary DC (default uswap-vdc-nam1.nam.zimmer.com)
--fallback <a,b> backup DCs, or 'none' (default uswap-vdc-nam2.nam.zimmer.com)
-p, --port <n> default 636 for ldaps, else 389
-t, --transport <mode> ldaps | starttls | none (default ldaps)
--insecure accept any server certificate. Needed off-domain: the DC
certs chain to the internal 'zimmer-rootca', which is not
in this machine's trust store.
--sweep try all three transports and report which connect
--timeout <ms> default 15000
SEARCH
-b, --base <dn> default DC=nam,DC=zimmer,DC=com
--user-attr <a> login attribute (default sAMAccountName)
--group-attr <a> group attribute (default memberOf)
--display-attr <a> display name (default name)
GROUP ENUMERATION
-g, --list-groups <pat> also list EVERY group in the directory whose cn matches a
wildcard pattern, e.g. 'UG_WW_*'. A membership dump only
shows groups the test account is in; this shows what exists.
--group-base <dn> base for that search (default: the domain root derived from
--base, since groups sit in several containers)
IDENTITY
--svc <upn> service account (default svc_igntnapp@nam.zimmer.com)
--self-bind bind as the end user instead of the service account.
Use when you do not have the svc_igntnapp password;
Part A is skipped because it requires one.
--upn-suffix <s> default @nam.zimmer.com
-u, --user <name> sAMAccountName to test (otherwise prompted)
Passwords are always prompted for, never taken from the command line.
The service-account password may instead come from $ZB_LDAP_SVC_PASSWORD.
EXAMPLES
# first run off-domain, no service account to hand:
dotnet run --project tools/LdapProbe -- --insecure --self-bind jdoe
# full fidelity check, service account password from the environment:
export ZB_LDAP_SVC_PASSWORD='...'
dotnet run --project tools/LdapProbe -- --insecure jdoe
# which transports actually work from here?
dotnet run --project tools/LdapProbe -- --sweep --insecure --self-bind jdoe
# what UG_WW_* groups exist in the whole directory?
dotnet run --project tools/LdapProbe -- --insecure --self-bind -g 'UG_WW_*' jdoe
""";
}