using ZB.MOM.WW.Auth.Abstractions.Ldap; namespace ZB.MOM.WW.Tools.LdapProbe; /// /// Effective probe configuration. Defaults come from scadaproj/ldap_setup.md /// (the audited Ignition NAM profile), so a bare run already points at production. /// public sealed class ProbeSettings { // --- directory / connection ------------------------------------------------- public string Server { get; set; } = "uswap-vdc-nam1.nam.zimmer.com"; public List 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; /// Port actually used: explicit override, else 636 for LDAPS and 389 otherwise. 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 ------------------------------------------------------------- /// Service-account bind identity. AD accepts a UPN here for a simple bind. public string ServiceAccount { get; set; } = "svc_igntnapp@nam.zimmer.com"; public string? ServiceAccountPassword { get; set; } /// /// 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. /// public bool SelfBind { get; set; } /// UPN suffix appended to a bare username for the self-bind and the UPN probe. public string UpnSuffix { get; set; } = "@nam.zimmer.com"; // --- behaviour -------------------------------------------------------------- public string? Username { get; set; } public bool SweepTransports { get; set; } /// /// When set, additionally enumerates every group in the directory whose cn matches /// this wildcard pattern (e.g. UG_WW_*). Answers "what groups exist", which a /// membership dump structurally cannot — it only ever shows the groups one account is in. /// public string? ListGroupsPattern { get; set; } /// /// Base for the group enumeration. Defaults to the domain root derived from /// , because groups are spread across several containers and a /// narrower base under-reports silently. /// public string? GroupBase { get; set; } public bool ShowCertificate { get; set; } = true; public bool Verbose { get; set; } /// Builds the options object the shared library will be driven with. 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 primary DC (default uswap-vdc-nam1.nam.zimmer.com) --fallback backup DCs, or 'none' (default uswap-vdc-nam2.nam.zimmer.com) -p, --port default 636 for ldaps, else 389 -t, --transport 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 default 15000 SEARCH -b, --base default DC=nam,DC=zimmer,DC=com --user-attr login attribute (default sAMAccountName) --group-attr group attribute (default memberOf) --display-attr display name (default name) GROUP ENUMERATION -g, --list-groups 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 base for that search (default: the domain root derived from --base, since groups sit in several containers) IDENTITY --svc 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 default @nam.zimmer.com -u, --user 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 """; }