Compare commits

...

3 Commits

Author SHA1 Message Date
Joseph Doherty 818775b311 docs(index): Auth 0.2.1 published -- AD referral fix + NAM group findings
CLAUDE.md: feed now carries 0.2.1; record the referral defect and that every
app is still exposed until it bumps off 0.1.5 (a functional fix, not
housekeeping), plus the new tools/LdapProbe.

ldap_setup.md: findings from probing the live NAM directory --

- LDAPS 636, StartTLS 389 and plain 389 all connect on both DCs. The only TLS
  chain error is UntrustedRoot, so installing zimmer-rootca is sufficient; the
  RSA 1024-bit key is rejected only by OpenSSL at SECLEVEL=2, i.e. Debian .NET
  containers, not by macOS or Windows hosts.
- The UG_WW_* Wonderware group convention, 39 groups enumerated directory-wide.
  This partly overturns this doc's earlier "no group->role convention exists"
  claim: that was true of Ignition, which was the only source audited, and
  false of the domain. Three shapes with a consistent role suffix (ADMIN, RO,
  OPERATOR, ENGINEER, SUPERVISOR, TESTER) across environment and site axes.
- A proposed mapping onto CANONICAL-ROLES, with the three gaps left open
  rather than guessed: SUPERVISOR/TESTER have no canonical equivalent, nothing
  maps to Deployer, and the DEV/QA/PROD axis is orthogonal to role so it must
  ride in the per-project scope payload or the role set triples.
- The referral defect and its fix.
2026-08-12 21:27:23 -04:00
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
Joseph Doherty 1f8ab8e3b3 fix(auth): skip AD continuation referrals in LDAP search (0.2.1)
Active Directory answers any search based at a domain root with continuation
references (CN=Configuration, DC=ForestDnsZones, ...). With referral following
disabled -- the default, and what we want, since chasing one would re-bind
somewhere we never authenticated -- Novell surfaces each by throwing
LdapReferralException from Next().

That escaped NovellLdapConnection.Search, and LdapAuthService maps a failed
search to ServiceAccountBindFailed. So every login against
SearchBase = DC=nam,DC=zimmer,DC=com failed system-side on BOTH DCs in ~546 ms
while the directory, the credentials and the service account were all healthy.
Identical on each endpoint, so failover could not help either.

A continuation reference is a pointer to another naming context, never a
statement that this search failed, so the loop now skips them and continues --
returning exactly the entries in the naming context we bound to. Extracted as
ReadEntries(ILdapSearchResults) so the handling is unit-testable through the
Novell seam without a live directory. A 1000-referral cap guards a client that
throws without advancing, which would otherwise spin forever inside a
fail-closed auth path.

Non-referral faults still propagate untouched: swallowing those would turn
"the directory is broken" into "no such user", which is the wrong answer to
give about someone's credentials.

7 tests, 6 verified red against the pre-fix code; the seventh is the
non-referral control that must stay green either way. Suite 257/0, 2 skipped.

Live-proven against the ZB NAM domain root before and after.
2026-08-12 21:27:05 -04:00
13 changed files with 1923 additions and 10 deletions
+4 -1
View File
@@ -141,7 +141,7 @@ each project's **code-verified current state**, and the **gaps** between. See
| Component | Status | Goal | Design | Implementation |
|---|---|---|---|---|
| Auth (login / identity / authz) | Adopted (**feed now carries `0.2.0`**; all four apps still pin `0.1.5` = `0.1.4` + the SQLitePCLRaw transitive security pin; merged to **local default** main/master + **pushed to origin** (gitea)). **`0.2.0` (2026-08-10) adds LDAP backup-DC sticky failover** — see the Auth narrative below; app pin-bump is a follow-up. | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) |
| Auth (login / identity / authz) | Adopted (**feed now carries `0.2.1`**; all four apps still pin `0.1.5` = `0.1.4` + the SQLitePCLRaw transitive security pin; merged to **local default** main/master + **pushed to origin** (gitea)). **`0.2.0` (2026-08-10) adds LDAP backup-DC sticky failover**, **`0.2.1` (2026-08-12) fixes a defect that broke AD logins outright** — an AD continuation referral escaped `NovellLdapConnection.Search`, so any `SearchBase` at a domain root failed every login as `ServiceAccountBindFailed` against a perfectly healthy directory. Live-proven against `DC=nam,DC=zimmer,DC=com`. **Every app is still exposed until it bumps** — see the Auth narrative below; app pin-bump is a follow-up. | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) |
| UI Theme (layout / tokens / components) | Adopted (feed `0.2.0`–`0.4.1`). **`0.4.0` (2026-08-11) upstreamed the Bootstrap `.btn`/`.btn-sm` sizing block** that all **five** consumers had independently written into their own stylesheets (Bootstrap hard-codes `--bs-btn-font-size: 1rem`, oversized against the kit's 0.9rem body) — sized by `--bs-btn-*` variable override **only**, never by redeclaring box properties, because `.btn-group`'s seam/radius machinery reads those same variables and a literal `font-size` at equal specificity silently flattens `.btn-sm` into a padding-only difference (a real defect found in mxgw's local copy). Same release added the `rail-btn-block` full-width rail-foot modifier; **`0.4.1` fixes it** — 0.4.0 shipped `width: auto`, which fills for an `<a>` but shrink-wraps a `<button>` (measured 114px short in the same rail), now `calc(100% - 1.2rem)` to clear `.rail-btn`'s side margins. **Consumers are five, not four** — the ignitionoee router joined. **All five consumers are on `0.4.1` and all five are PUSHED** (2026-08-11): ignitionoee `main @ 38aa5c1`, OtOpcUa `master @ e82881c1`, mxgw `main @ 0a9715d`. The two whose sweeps were not deploy-ready pushed to **branches**, deliberately leaving their default branch untouched: HistorianGateway `dashboard-ui-sweep-theme-0.4.1 @ df7b879` (`main` stays `e9c31da`) and ScadaBridge `ui/central-ui-cleanup-sweep @ 9e243493` (`main` was then `b6f383a2`). **ScadaBridge's caveat is now CLOSED and its sweep is merged, deployed and live** (2026-08-11): the branch landed on `main`, and the two follow-ups `d14e0ee4` + `3eb7df74` fixed exactly the four detail-modal surfaces the caveat named — the modal is now gated on **user intent (the held id)**, never on the row still resolving, so a background refresh that drops the row leaves it mounted and says why the summary is missing instead of unmounting the subtree and disposing Close's own handler mid-click. That disposal *was* the intermittent `GetRequiredEventBindingEntry`, and `NotificationReportDetailModalTests.Modal_StaysOpen_WhenItsRowLeavesThePage` now pins it. **Deployed to wonder-app-vd03: OtOpcUa `e82881c1`, and ScadaBridge `3eb7df74` (2026-08-11 — theme.css/layout.css/nav-state.js served from `_content/ZB.MOM.WW.Theme` and referenced by the pages; all six changed routes 200). HistorianGateway is still `e9c31da` = 0.3.1 — its 0.4.1 remains on the branch.** **On bump, TRIM the local `.btn` block, don't delete it** — four of the five were pure sizing, but mxgw's also carried `border-radius`/`font-weight`/`white-space` that a blind delete would have dropped. | Shared `ZB.MOM.WW.Theme` RCL | [`components/ui-theme/`](components/ui-theme/) | [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) |
| Health (readiness / liveness / active-node) | **Adopted** (feed carries `0.1.0`–`0.3.0`, current lib version **`0.3.0`**; **all 4 apps** reference it + wire `MapZbHealth`). **`0.2.0` (2026-07-24)** added an optional per-entry `data` object to the canonical health JSON and made `AkkaClusterHealthCheck` publish its cluster view (`leader`/`selfAddress`/`selfRoles`/`memberCount`/`unreachableCount`) — additive, so pre-0.2.0 payloads still parse. **`0.2.1` (2026-07-24) is a behaviour fix with a live consumer impact:** the active tier now returns **Unhealthy (503)** on a role-member-but-not-leader node instead of `Degraded`, which the spec maps to 200. The old value made `/health/active` answer 200 on *every* node, so it could not distinguish active from standby over HTTP at all — and OtOpcUa's Traefik routes the admin UI by exactly this probe, so its leader-pinning had silently never worked ([`lmxopcua#494`](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/494)). **`0.3.0` (2026-07-24) finished that fix and ENDED a family-wide duplication.** 0.2.1 made the tier a real 503 but still selected by `RoleLeader`; on a rebuilt rig the two orderings diverge live (akka leader `central-1`, oldest admin `central-2` — the singleton host), so it would have pinned Traefik to the node *not* hosting the work. **Leadership is address-ordered (host, then port) and has no relationship to time; singleton placement is age-ordered.** They agree on a freshly-formed cluster — which is why single-node and happy-path tests never caught it — and diverge permanently after any restart. Both Akka apps had already discovered this independently and each written a private replacement rather than use the shared check (ScadaBridge `OldestNodeActiveHealthCheck`, OtOpcUa `ClusterPrimaryHealthCheck`), so the package's entire active-node surface had **zero consumers — not unused but avoided, twice, for the same reason.** 0.3.0 promotes those two into one `ClusterActiveNode` primitive (oldest `Up` member, optional role scope, plus an ordered role-preference so a fused node answers for the role its singletons are pinned to); `ActiveNodeHealthCheck` and `AkkaActiveNodeGate` both delegate to it, and results now carry `activeRole`/`selfAddress`/`activeNode` in the 0.2.0 `data` object so a **standby reports who IS active**. Two further behaviour changes: a node holding **none** of the preferred roles is now **Unhealthy**, not Healthy — the old "not applicable ⇒ Healthy" is exactly what made the tier answer 200 everywhere, and the case is reachable (OtOpcUa's `RoleParser` admits `dev`-only and `cluster-`only nodes); and identity compares `UniqueAddress`, so a node restarted on the same host:port is correctly a different member. **Both apps migrated and their private copies deleted** — OtOpcUa (`feat/health-0.3.0-active-node` @ `88adec04`) also routes `RedundancyStateActor.SelectOldestUpMemberOfRole` through the shared primitive, so the tier and the OPC UA `ServiceLevel` 250/240 split cannot drift; ScadaBridge (`feat/site-node-health` @ `5d4853a1`) routes `ActiveNodeEvaluator` through it, unifying the S&F delivery gate, the heartbeat `IsActive` stamp, the inbound-API gate and the health tier on one rule. ScadaBridge keeps `SitePairActiveNodeHealthCheck` deliberately — it is a thin adapter over the site's own role-scoped `IClusterNodeProvider` (itself now backed by the shared rule), not a second copy of it. mxgw/HistorianGateway have no Akka cluster and are unaffected. **Both app branches are MERGED and PUSHED** (verified 2026-08-07): OtOpcUa `88adec04` is on `origin/master`; ScadaBridge merged via `b3dc17a5` (2026-07-24) on `origin/main`, private-copy deletions included. | Shared `ZB.MOM.WW.Health` lib | [`components/health/`](components/health/) | [`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) |
| Observability (metrics / traces / logs) | **Adopted** (libs `0.1.0`, on the feed; **all 4 apps** call `AddZbTelemetry`; `AddZbSerilog` in all but ScadaBridge, which keeps its own factory by design) | Shared `ZB.MOM.WW.Telemetry` lib + `.Serilog` | [`components/observability/`](components/observability/) | [`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/) |
@@ -171,6 +171,9 @@ canonical-six role vocabulary (with ScadaBridge's accepted auditor/admin SoD col
Build/test from `ZB.MOM.WW.Auth/`: `dotnet test`. Consumer matrix: OtOpcUa → Abstractions+Ldap+AspNetCore;
MxAccessGateway & ScadaBridge → all four (ApiKeys not used by OtOpcUa).
**`0.1.4` (2026-07-09, archreview G-2):** optional `ApiKeyRecord.ExpiresUtc` (nullable = never-expires), `ApiKeyFailure.KeyExpired`, `ApiKeyVerifier` expiry enforcement (inclusive, injected clock), a `CreateKeyAsync(expiresUtc)` overload, and a SQLite **schema v3** additive `expires_utc` column (idempotent `ALTER`; donor v2 DBs upgrade in place, no key invalidated). **All four Auth pkgs published to the Gitea feed at `0.1.4`** (2026-07-09). **HistorianGateway adopted the expiry surface at `0.1.4`** — `apikey create --expires` + dashboard Expires column (Task-10 done). **`0.1.5` (2026-07-18)** is `0.1.4` plus the `SQLitePCLRaw.lib.e_sqlite3 2.1.12` transitive security pin (GHSA-2m69-gcr7-jv3q), no API change; all four apps now pin it (see consumer pins above). **`0.2.0` (2026-08-10) — LDAP backup-DC sticky failover** (plan `docs/plans/2026-08-10-ldap-backup-dc-failover.md`, design `…-design.md`; **4 pkgs published to the feed, restore-verified**; family `Directory.Build.props` now `0.2.0`): one additive `LdapOptions.FallbackServers` list (`"host"` or `"host:port"`, empty default = failover off, single-endpoint behaviour byte-unchanged) makes `LdapAuthService` walk an ordered endpoint list (primary + fallbacks) on **system-side failures only** — connect/TLS, service-account bind, or search. A user-side answer from a healthy directory (`Success`/`UserNotFound`/`AmbiguousUser`/`BadCredentials`/`GroupLookupFailed`) is **final and never retried elsewhere** (re-asking a second DC about a rejected password would multiply one bad-password event into several against the account lockout policy). Endpoint preference is **sticky** — the last endpoint that gave *any* authoritative answer (success **or** a user-side failure) is tried first next time — so a primary outage costs its connect timeout once, not per login; an exhausted walk leaves the preference untouched. Fail-closed contract preserved per-endpoint; boot validation rejects a malformed `FallbackServers` entry naming the field; optional `ILogger<LdapAuthService>` emits a Warning per failed hop (endpoint + stage, no secrets) and an Information on sticky change; wired via `AddZbLdapAuth` (`GetService`, so logging is optional). Motivation: the ZB production directory is NAM AD with two DCs. Unit suite covers the walk/stickiness (14 tests) + env-gated live GLAuth failover test (`ZB_LDAP_IT=1`). **No `Abstractions` contract change beyond the new option; no `LdapAuthFailure` enum change.** App adoption (pin bump to `0.2.0` + authoring `FallbackServers`) is a follow-up.
**`0.2.1` (2026-08-12) — AD continuation referrals no longer fail the search** (**4 pkgs published to the feed, restore-verified**; the assembly restored *from the feed* carries the fix and `0.2.0` does not — checked as a positive control, not by trusting the push). Active Directory answers **any search based at a domain root** with continuation references (to `CN=Configuration`, `DC=ForestDnsZones`, …). With referral following disabled — the default, and what we want, since chasing one would re-bind somewhere we never authenticated — Novell surfaces each by throwing `LdapReferralException` from `Next()`. That escaped `NovellLdapConnection.Search`, and `LdapAuthService` maps a failed search to `ServiceAccountBindFailed`, so **every login against `SearchBase = DC=nam,DC=zimmer,DC=com` failed system-side on both DCs while the directory, the credentials and the service account were all healthy** — ~546 ms, identical on each endpoint, so failover could not help either. Found by the new `tools/LdapProbe` (below) and live-proven fixed against the ZB NAM domain root. The search loop now skips referrals (`ReadEntries(ILdapSearchResults)`, extracted so it is unit-testable through the Novell seam without a live directory) and continues; a 1000-referral cap guards a client that throws without advancing, since that would spin forever inside a fail-closed auth path. **Non-referral faults still propagate** — swallowing those would turn "the directory is broken" into "no such user", which is the wrong answer to give about someone's credentials. 7 new tests (`NovellSearchReferralTests`), **6 verified red against the pre-fix code**, the seventh being the non-referral control that must stay green; suite 257 pass / 2 skip / 0 fail. **No `Abstractions` change; no config change.** **Consumer impact: an app on `0.1.5` cannot authenticate against an AD domain root at all**, so the pin bump is a functional fix, not housekeeping — and it is a four-minor jump that also lands `0.2.0`'s `FallbackServers`.
**`tools/LdapProbe`** (`ldapprobe`, not packable, outside the Auth solution) is the diagnostic that found the above: it drives the **real** `LdapAuthService` so its verdict is what the four apps produce, then separately queries the directory for what the library cannot report — full group DNs, **nested** groups via `LDAP_MATCHING_RULE_IN_CHAIN`, the **primary group** (which AD omits from `memberOf`, so it can never be a role group), group type (security vs distribution), decoded `userAccountControl`, referral counts, and the TLS chain with key sizes. `--list-groups '<pat>'` enumerates every group in the directory matching a pattern, which a membership dump structurally cannot do. Findings recorded in [`ldap_setup.md`](ldap_setup.md), including the **`UG_WW_*` Wonderware group convention (39 groups)** that partly overturns that doc's earlier "no group→role convention exists" claim — it was true of Ignition, false of the domain.
The UI-theme component is fully populated: a normalized [`spec`](components/ui-theme/spec/SPEC.md),
a [`design-tokens`](components/ui-theme/spec/DESIGN-TOKENS.md) reference, a
+4 -1
View File
@@ -5,7 +5,10 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<Version>0.2.0</Version>
<!-- 0.2.1: NovellLdapConnection.Search now skips AD continuation referrals instead of
letting them fail the search stage. Without it, a SearchBase at a domain root made
every login fail as ServiceAccountBindFailed against a healthy directory. -->
<Version>0.2.1</Version>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
@@ -99,23 +99,73 @@ internal sealed class NovellLdapConnection : ILdapConnection
attributes.ToArray(),
typesOnly: false);
return ReadEntries(results);
}
/// <summary>
/// Drains a result set into entries, <b>skipping continuation referrals</b>.
/// </summary>
/// <remarks>
/// <para>
/// Active Directory answers any search based at a domain root with one or more
/// continuation references (to <c>CN=Configuration</c>, <c>DC=ForestDnsZones</c> and
/// friends). With referral following disabled — the default, and what we want, since
/// chasing a referral would re-bind somewhere we did not authenticate — Novell surfaces
/// each one by throwing <see cref="LdapReferralException"/> from <see cref="ILdapSearchResults.Next"/>.
/// </para>
/// <para>
/// A continuation reference is a <em>pointer to another naming context</em>, not an error:
/// it says "there may be more results over there", never that this search failed. Letting
/// it escape made the whole search stage fail, which <c>LdapAuthService</c> maps to
/// <see cref="Abstractions.Ldap.LdapAuthFailure.ServiceAccountBindFailed"/> — so against a
/// real AD domain root, every login failed with a system-side error while the directory,
/// the credentials and both DCs were perfectly healthy. Verified against
/// <c>DC=nam,DC=zimmer,DC=com</c> on 2026-08-12.
/// </para>
/// <para>
/// Skipping is the correct behaviour rather than a workaround: the entries we can see are
/// exactly the ones in the naming context we bound to, which is the set the caller asked
/// about. Internal so the referral handling can be unit-tested through the
/// <see cref="ILdapSearchResults"/> seam without a live directory.
/// </para>
/// </remarks>
internal static List<LdapSearchEntry> ReadEntries(ILdapSearchResults results)
{
var entries = new List<LdapSearchEntry>();
while (results.HasMore())
var referrals = 0;
while (true)
{
var entry = results.Next();
var attrs = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase);
foreach (LdapAttribute attr in entry.GetAttributeSet())
try
{
attrs[attr.Name] = attr.StringValueArray.ToList();
}
if (!results.HasMore()) break;
entries.Add(new LdapSearchEntry(entry.Dn, attrs));
var entry = results.Next();
var attrs = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase);
foreach (LdapAttribute attr in entry.GetAttributeSet())
{
attrs[attr.Name] = attr.StringValueArray.ToList();
}
entries.Add(new LdapSearchEntry(entry.Dn, attrs));
}
catch (LdapException ex) when (ex is LdapReferralException || ex.ResultCode == LdapException.Referral)
{
// Novell dequeues the reference before throwing, so the enumeration advances and
// this terminates. The cap is a belt-and-braces guard: a client that threw without
// advancing would otherwise spin forever inside a fail-closed auth path, which is a
// far worse failure than under-reporting an implausible number of referrals.
if (++referrals > MaxReferrals) break;
}
}
return entries;
}
/// <summary>Upper bound on continuation references tolerated in one search — see <see cref="ReadEntries"/>.</summary>
private const int MaxReferrals = 1_000;
/// <inheritdoc/>
public void Dispose()
{
@@ -0,0 +1,179 @@
using Novell.Directory.Ldap;
using Xunit;
using ZB.MOM.WW.Auth.Ldap.Internal;
namespace ZB.MOM.WW.Auth.Ldap.Tests;
/// <summary>
/// Covers <see cref="NovellLdapConnection.ReadEntries"/>' handling of AD continuation
/// referrals. Active Directory returns one for any search based at a domain root, and
/// Novell surfaces it by throwing from <c>Next()</c>. Before this was handled, the
/// exception escaped the search stage and every login against
/// <c>DC=nam,DC=zimmer,DC=com</c> failed as <c>ServiceAccountBindFailed</c> while the
/// directory and credentials were healthy.
/// </summary>
public sealed class NovellSearchReferralTests
{
[Fact]
public void Referral_before_the_only_entry_does_not_lose_it()
{
var results = new ScriptedResults(
Step.Referral(),
Step.Entry("CN=dohertj2,OU=Office Users,OU=Warsaw,DC=nam,DC=zimmer,DC=com"));
var entries = NovellLdapConnection.ReadEntries(results);
var entry = Assert.Single(entries);
Assert.Equal("CN=dohertj2,OU=Office Users,OU=Warsaw,DC=nam,DC=zimmer,DC=com", entry.Dn);
}
[Fact]
public void Referral_after_the_entry_does_not_discard_what_was_already_read()
{
var results = new ScriptedResults(
Step.Entry("CN=a,DC=nam,DC=zimmer,DC=com"),
Step.Referral());
var entries = NovellLdapConnection.ReadEntries(results);
Assert.Equal("CN=a,DC=nam,DC=zimmer,DC=com", Assert.Single(entries).Dn);
}
/// <summary>
/// The real shape observed against the NAM domain root: several references interleaved
/// with the one user entry. All three of AD's usual continuation points are present.
/// </summary>
[Fact]
public void Multiple_interleaved_referrals_still_yield_every_entry()
{
var results = new ScriptedResults(
Step.Referral(),
Step.Entry("CN=a,DC=nam,DC=zimmer,DC=com"),
Step.Referral(),
Step.Referral(),
Step.Entry("CN=b,DC=nam,DC=zimmer,DC=com"));
var entries = NovellLdapConnection.ReadEntries(results);
Assert.Equal(["CN=a,DC=nam,DC=zimmer,DC=com", "CN=b,DC=nam,DC=zimmer,DC=com"],
entries.Select(e => e.Dn));
}
/// <summary>
/// A referral-only result set is a legitimate "nothing here" answer, and must come back
/// empty rather than throwing — <c>LdapAuthService</c> reads empty as <c>UserNotFound</c>,
/// which is the honest verdict, where an exception would become a system-side failure.
/// </summary>
[Fact]
public void Referrals_only_returns_empty_rather_than_throwing()
{
var results = new ScriptedResults(Step.Referral(), Step.Referral());
Assert.Empty(NovellLdapConnection.ReadEntries(results));
}
/// <summary>
/// A base <see cref="LdapException"/> carrying the Referral result code is treated the
/// same as the derived type — the catch filter must not depend on the exact class.
/// </summary>
[Fact]
public void Referral_result_code_on_a_base_exception_is_also_skipped()
{
var results = new ScriptedResults(
Step.Throwing(new LdapException("referral", LdapException.Referral, null)),
Step.Entry("CN=a,DC=nam,DC=zimmer,DC=com"));
Assert.Single(NovellLdapConnection.ReadEntries(results));
}
/// <summary>
/// Only referrals are swallowed. A genuine directory fault must still propagate so the
/// caller maps it to a system-side failure — silently returning a short result set would
/// turn "the directory is broken" into "no such user", which is the wrong answer to give
/// about someone's credentials.
/// </summary>
[Fact]
public void A_non_referral_fault_still_propagates()
{
var results = new ScriptedResults(
Step.Throwing(new LdapException("busy", LdapException.Busy, null)),
Step.Entry("CN=a,DC=nam,DC=zimmer,DC=com"));
var ex = Assert.Throws<LdapException>(() => NovellLdapConnection.ReadEntries(results));
Assert.Equal(LdapException.Busy, ex.ResultCode);
}
/// <summary>
/// A pathological client that throws a referral without advancing must not spin forever
/// inside a fail-closed authentication path.
/// </summary>
[Fact]
public void An_endless_referral_stream_terminates()
{
var results = new EndlessReferrals();
var entries = NovellLdapConnection.ReadEntries(results);
Assert.Empty(entries);
Assert.InRange(results.Calls, 1, 2_000);
}
// ---------------------------------------------------------------------
private sealed record Step(LdapEntry? Value, Exception? Throw)
{
public static Step Entry(string dn) => new(new LdapEntry(dn, new LdapAttributeSet()), null);
public static Step Referral() => new(null, new LdapReferralException("continuation reference"));
public static Step Throwing(Exception ex) => new(null, ex);
}
/// <summary>Replays a fixed script of entries and faults through the Novell result seam.</summary>
private sealed class ScriptedResults : ILdapSearchResults
{
private readonly Step[] _steps;
private int _index;
public ScriptedResults(params Step[] steps) => _steps = steps;
public int Count => _steps.Length;
public LdapControl[] ResponseControls => [];
public bool HasMore() => _index < _steps.Length;
public LdapEntry Next()
{
var step = _steps[_index++];
if (step.Throw is not null) throw step.Throw;
return step.Value!;
}
// The production reader drives HasMore()/Next() directly; enumeration is part of the
// interface but not part of the path under test.
public IEnumerator<LdapEntry> GetEnumerator() => throw new NotSupportedException();
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}
/// <summary>Always claims more results and always throws a referral, never advancing.</summary>
private sealed class EndlessReferrals : ILdapSearchResults
{
public int Calls { get; private set; }
public int Count => int.MaxValue;
public LdapControl[] ResponseControls => [];
public bool HasMore() => true;
public LdapEntry Next()
{
Calls++;
throw new LdapReferralException("continuation reference");
}
public IEnumerator<LdapEntry> GetEnumerator() => throw new NotSupportedException();
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}
}
+202
View File
@@ -55,6 +55,12 @@ existing pattern) or define new AD groups deliberately; standard AD attributes
(`memberOf`, filter `(&(objectClass=group)(member={0}))`) are available if we
choose to.
> **Superseded in part (2026-08-12).** The statement above is correct *about Ignition*,
> which was the only source audited. It is wrong as a claim about the domain: a
> `UG_WW_*` Wonderware group convention does exist, already scoped by environment and
> site — see [`UG_WW_*`](#ug_ww_--an-existing-wonderware-group-convention-observed-2026-08-12).
> Read that before choosing an authorization design.
## Behavioral notes from the working config
- Users are **populated on demand**: a local record is created on first
@@ -102,3 +108,199 @@ Implications for new .NET services:
- Hostnames resolve from the dev network: nam1 = `10.201.216.2`,
nam2 = `10.201.138.78`.
## Probe tool
[`tools/LdapProbe`](tools/LdapProbe/) (`ldapprobe`) tests these settings interactively and
dumps a user's group membership. It drives the real `ZB.MOM.WW.Auth.Ldap` library, so its
verdict is what the four apps will produce, and separately queries the directory for the
things the library cannot report — nested groups, the primary group, group types and
referral behaviour.
```bash
dotnet run --project tools/LdapProbe -- --insecure --self-bind <sAMAccountName>
dotnet run --project tools/LdapProbe -- --sweep --insecure # connectivity only
```
### Re-verified 2026-08-12 (from the dev Mac, via ldapprobe `--sweep`)
All six endpoint/transport combinations connect: **LDAPS 636, StartTLS 389 and plain 389
on both DCs** (~210–235 ms TLS, ~32 ms plaintext).
Two refinements to the TLS notes above, from the actual chain a .NET client sees:
- The only validation error is **`UntrustedRoot`** — `SslPolicyErrors` is
`RemoteCertificateChainErrors`, and both the leaf and `zimmer-issueca1` report `ok`.
So **installing `zimmer-rootca` is sufficient** to make LDAPS work without any
validation bypass.
- **.NET on macOS accepts the RSA 1024-bit key.** The weak-key rejection is specific to
OpenSSL at `SECLEVEL=2`, i.e. Debian-based .NET *containers* — it is not a
cross-platform blocker, and a Windows host is unaffected too. That matters because
three of the four apps deploy to Windows on `wonder-app-vd03`, where this hazard does
not apply at all; only containerized deployments need the SECLEVEL workaround or a
reissued 2048-bit cert.
Cert observed on nam1: `CN=USWAP-VDC-NAM1.nam.zimmer.com`, SAN matches, sha256RSA,
valid 2026-03-20 → 2027-03-20, chain `leaf ← zimmer-issueca1 ← zimmer-rootca`.
### `UG_WW_*` — an existing Wonderware group convention (observed 2026-08-12)
**This partly overturns the "Groups / roles" section above.** That section is accurate
about *Ignition* — it ignores AD groups and assigns roles per-user. But the Wonderware
estate does have a group convention, and it is already environment- and site-scoped.
**Full directory inventory, 39 groups** — enumerated 2026-08-12 with
`ldapprobe -g 'UG_WW_*'` against `DC=nam,DC=zimmer,DC=com` (not one account's membership;
16 of these are held by nobody on the probing account). **Every one is a `universal
security` group** — exactly the shape `ZB.MOM.WW.Auth.Ldap` consumes, with no nesting
problem and no distribution-group trap. Member counts are as observed.
**Platform, environment-scoped** — one set per `DEV`/`QA`/`PROD`:
| Group | Members | Notes |
|---|--:|---|
| `UG_WW_APP_ADMIN_{DEV,QA,PROD}` | 7 / 6 / 5 | Application Server admin |
| `UG_WW_APP_IDE_{DEV,QA,PROD}` | 6 / 6 / 4 | ArchestrA IDE — Galaxy authoring |
| `UG_WW_APP_IDE_RO_{DEV,QA,PROD}` | 2 / 1 / 1 | **read-only** IDE tier |
| `UG_WW_HIST_ADMIN_{DEV,QA,PROD}` | 7 / 6 / 4 | Historian admin |
| `UG_WW_CITRIX_{DEV,QA}` | 6 / 12 | Citrix access; no PROD group exists |
| `UG_WW_BTMGR_BE_{QA,PROD}` | 3 / 5 | Batch Manager, plant BE |
| `UG_WW_BTMGR_MC_{QA,PROD}` | 2 / 5 | Batch Manager, plant MC |
| `UG_WW_BTMGR_RO_{QA,PROD}` | **EMPTY** / 4 | read-only Batch Manager |
**Site admin** — `UG_WW_SITE_ADMIN_<SITE>`, 6 members each, sites
`BER GAL PAR PON SHN WN0A WW05 WW19`.
**Plant-floor, per site and area** — this is the tier with real operator populations:
| Group | Members |
|---|--:|
| `UG_WW_B19_SMS_OPERATOR` | 34 |
| `UG_WW_B19_SMS_TESTER` | 13 |
| `UG_WW_B19_VitE_ENGINEER` | 4 |
| `UG_WW_B19_VitE_OPERATOR` | 10 |
| `UG_WW_PON_PRESS_ENGINEER` | 14 |
| `UG_WW_PON_PRESS_OPERATOR` | 21 |
| `UG_WW_WN_ARCOM_OPERATOR` | 5 |
| `UG_WW_WN_ARCOMXL_OPERATOR` | 2 |
| `UG_WW_WN_SOLAR_OPERATOR` | 10 |
| `UG_WW_WN_STERIS_OPERATOR` | 5 |
| `UG_WW_WN_STERIS_SUPERVISOR` | 6 |
Decoded convention — three shapes, and a consistent **role suffix**:
```
UG_WW_<FUNCTION>[_RO]_<ENV> APP_ADMIN | APP_IDE | HIST_ADMIN | BTMGR | CITRIX
UG_WW_SITE_ADMIN_<SITE> BER GAL PAR PON SHN WN0A WW05 WW19
UG_WW_<SITE>_<AREA>_<ROLE> sites B19 PON WN; roles below
```
Role suffixes in use: `ADMIN`, `RO` (read-only), `OPERATOR`, `ENGINEER`, `SUPERVISOR`,
`TESTER`.
### Mapping onto the canonical role set
The suffixes line up closely with
[`CANONICAL-ROLES.md`](components/auth/spec/CANONICAL-ROLES.md), so
`IGroupRoleMapper<CanonicalRole>` can map onto this rather than inventing a parallel scheme:
| AD suffix / family | Canonical role | Rationale |
|---|---|---|
| `_RO_` | **Viewer** | `OBSERVE` only |
| `_OPERATOR` | **Operator** | `OBSERVE` + `OPERATE` |
| `_ENGINEER` | **Engineer** | adds `TUNE` |
| `APP_IDE_*` | **Designer** | the ArchestrA IDE *is* the authoring surface — `AUTHOR` |
| `APP_ADMIN_*`, `SITE_ADMIN_*` | **Administrator** | |
| `HIST_ADMIN_*` | **Administrator**, historian-scoped | |
| `_SUPERVISOR`, `_TESTER` | **undecided** | see below |
Three gaps to settle before this is a design rather than a sketch:
- **`_SUPERVISOR` and `_TESTER` have no obvious canonical equivalent.** `SUPERVISOR` sits
above `OPERATOR` but whether it means `TUNE` (⇒ Engineer) or approval authority is not
derivable from the name. `TESTER` appears only in QA context. Ask the owners; do not guess.
- **Nothing maps to `Deployer`.** The canonical set separates `DEPLOY` from `AUTHOR`; this
directory does not. In practice `APP_ADMIN` holds deploy rights, which collapses the
separation — the same accepted SoD collapse ScadaBridge already carries.
- The environment axis (`DEV`/`QA`/`PROD`) is **orthogonal to role** and has no
representation in `CanonicalRole`. It has to ride in the per-project scope payload, not
the role — otherwise the role set multiplies by three.
**Caveats:**
- Group **container placement is inconsistent** (`OU=Universal,OU=Groups,OU=Warsaw`,
`OU=Groups,OU=Warsaw`, and `OU=Groups` at the domain root). Harmless for
`memberOf`-based authorization — only the *user* search base matters — but any
group-enumeration search must be based at the domain root.
- `UG_WW_BTMGR_RO_QA` is **empty**. Mapping a role to it grants nobody anything today.
- Descriptions are ServiceNow ticket ids (`TASK0638550` etc.), so these groups are
**provisioned by ticket** — requesting new ones has an established process, and the
ticket id is the audit trail for why a group exists.
- No group here is specific to OPC UA, ScadaBridge or this family; they describe the
existing Wonderware estate. Reusing them means accepting their semantics.
### Library defect: a domain-root search base failed on AD referrals — FIXED in Auth `0.2.1`
**Found and fixed 2026-08-12.** All four Auth packages are **published to the Gitea feed at
`0.2.1`** and restore-verified from a scratch consumer (the assembly restored *from the
feed* carries the fix; `0.2.0` does not). **The four apps still pin `0.1.5`, so nothing is
fixed for them until each bumps** — a pin bump is the remaining step, and until it happens
every app still fails a domain-root search with `ServiceAccountBindFailed`.
Authenticating with `SearchBase = DC=nam,DC=zimmer,DC=com` fails on **both DCs** with
`ServiceAccountBindFailed` in ~546 ms. It is not connectivity, not the service account,
and not failover — both endpoints fail identically at the `search` stage:
```
! LDAP endpoint uswap-vdc-nam1.nam.zimmer.com:636 failed system-side at search
! LDAP endpoint uswap-vdc-nam2.nam.zimmer.com:636 failed system-side at search
✗ FAILED — ServiceAccountBindFailed
```
Cause: AD returns a **continuation referral** for a search based at the domain root, and
`NovellLdapConnection.Search` (in `ZB.MOM.WW.Auth.Ldap`) iterates `results.Next()` without
catching `LdapReferralException`. The exception escapes the search stage, and
`LdapAuthService` maps it to the system-side bucket. The identical search from ldapprobe's
own `RawDirectory.Search`, which catches and counts referrals, succeeds against the same
base — which is what isolates it to the library rather than the directory.
**The fix (`0.2.1`):** `NovellLdapConnection.Search` now delegates to an internal
`ReadEntries(ILdapSearchResults)` that catches `LdapReferralException` — and any
`LdapException` carrying `ResultCode == Referral` — inside the result loop and continues.
A continuation reference is a pointer to another naming context, never a statement that
this search failed, so skipping it returns exactly the entries in the naming context we
bound to. A referral counter caps the loop at 1000 as a spin guard, since a client that
threw without advancing would otherwise hang inside a fail-closed auth path.
Non-referral faults still propagate untouched — swallowing those would turn "the directory
is broken" into "no such user", which is the wrong thing to say about someone's
credentials. Covered by `NovellSearchReferralTests` (7 tests through the
`ILdapSearchResults` seam, so no live directory is needed; 6 of the 7 verified red against
the pre-fix code, the seventh being the non-referral control).
**Until `0.2.1` is adopted**, the only workaround is to narrow `SearchBase` below the
domain root — a landmine, because the base must contain every user who will ever log in
and a user in another OU silently gets `UserNotFound`. Do **not** use the container
ldapprobe's auto-retry reports: that is the *test user's own* OU (e.g.
`OU=Office Users,OU=Warsaw,…`), not a production value.
This also constrained group *enumeration*: `UG_WW_*` groups are spread across three
containers including the domain root, so a full group listing hit the same referral.
ldapprobe's `--list-groups` carries its own referral-tolerant reader and was never
affected.
### The zero-group rule is still the risk
`ZB.MOM.WW.Auth.Ldap` refuses a **zero-group** login: a user whose password verifies but
whose `memberOf` is empty comes back as `GroupLookupFailed`, not success. The `UG_WW_*`
finding above makes this less likely for Wonderware-estate staff, but it is not disproven
— the account probed held 176 groups, which is not representative. **Test a real operator
or service account before designing around this.** Two further traps the probe checks for
explicitly:
- **the primary group is invisible.** AD omits it from `memberOf`, so a user whose only
membership is `Domain Users` can never satisfy the library's rule.
- **nested groups are invisible.** The library reads `memberOf`, which holds direct edges
only — mapping a role to a group the users reach *through* another group would grant
nobody anything. The probe resolves the transitive set separately and flags the
difference.
+124
View File
@@ -0,0 +1,124 @@
using System.Net.Security;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
namespace ZB.MOM.WW.Tools.LdapProbe;
/// <summary>
/// Wraps the TLS server-certificate validation callback so the probe can *report* the
/// presented chain rather than silently accept or reject it.
/// </summary>
/// <remarks>
/// This exists because <c>ldap_setup.md</c> records two distinct TLS hazards that both
/// present as a generic connect failure: the DC certs chain to the internal
/// <c>zimmer-rootca</c> (absent from a dev machine's trust store), and they carry
/// <b>RSA 1024-bit</b> keys, which OpenSSL at its default security level rejects outright.
/// Printing the subject, key size and the exact <see cref="SslPolicyErrors"/> turns
/// "connection refused" into a diagnosis.
/// </remarks>
public sealed class RemoteCertificateValidationCallbackHolder
{
private readonly bool _accept;
private readonly bool _report;
private int _reported;
public RemoteCertificateValidationCallbackHolder(bool acceptAnything, bool report)
{
_accept = acceptAnything;
_report = report;
}
/// <summary>The last chain observed, for the summary at the end of a run.</summary>
public string? Summary { get; private set; }
public RemoteCertificateValidationCallback Callback => Validate;
private bool Validate(object sender, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors errors)
{
// Report once per process: a failover walk opens several connections and the
// chain is the same each time.
if (_report && Interlocked.Exchange(ref _reported, 1) == 0)
Describe(certificate, chain, errors);
return _accept || errors == SslPolicyErrors.None;
}
private void Describe(X509Certificate? certificate, X509Chain? chain, SslPolicyErrors errors)
{
var lines = new List<string>();
if (certificate is null)
{
lines.Add("server presented NO certificate");
}
else
{
using var cert = new X509Certificate2(certificate);
lines.Add($"subject {cert.Subject}");
lines.Add($"issuer {cert.Issuer}");
lines.Add($"valid {cert.NotBefore:yyyy-MM-dd} -> {cert.NotAfter:yyyy-MM-dd}"
+ (cert.NotAfter < DateTime.Now ? " *** EXPIRED ***" : ""));
lines.Add($"sig alg {cert.SignatureAlgorithm.FriendlyName}");
var (algorithm, size) = KeyInfo(cert);
var weak = size is > 0 and < 2048;
lines.Add($"key {algorithm} {size}-bit" + (weak
? " *** WEAK — OpenSSL SECLEVEL>=2 (Debian .NET containers) rejects this ***"
: ""));
var san = cert.Extensions
.OfType<X509SubjectAlternativeNameExtension>()
.SelectMany(e => e.EnumerateDnsNames())
.ToArray();
if (san.Length > 0)
lines.Add($"SAN {string.Join(", ", san)}");
}
lines.Add($"policy {(errors == SslPolicyErrors.None ? "None (chain validated cleanly)" : errors.ToString())}");
if (chain is not null)
{
foreach (var element in chain.ChainElements)
{
var status = element.ChainElementStatus.Length == 0
? "ok"
: string.Join("; ", element.ChainElementStatus.Select(s => s.Status.ToString()));
lines.Add($" chain {Shorten(element.Certificate.Subject)} [{status}]");
}
}
lines.Add(_accept
? "verdict ACCEPTED because --insecure was passed (diagnostic only — do not ship this)"
: errors == SslPolicyErrors.None
? "verdict ACCEPTED by the OS trust store"
: "verdict REJECTED — rerun with --insecure to see past this, or install zimmer-rootca");
Summary = string.Join(Environment.NewLine, lines.Select(l => " " + l));
Ui.Section("TLS server certificate");
Console.WriteLine(Summary);
Console.WriteLine();
}
private static (string Algorithm, int Size) KeyInfo(X509Certificate2 cert)
{
try
{
using var rsa = cert.GetRSAPublicKey();
if (rsa is not null) return ("RSA", rsa.KeySize);
using var ecdsa = cert.GetECDsaPublicKey();
if (ecdsa is not null) return ("ECDSA", ecdsa.KeySize);
}
catch (CryptographicException)
{
// An unreadable key is itself worth reporting rather than crashing the probe.
}
return (cert.PublicKey.Oid.FriendlyName ?? "unknown", 0);
}
private static string Shorten(string dn)
{
var cn = dn.Split(',').FirstOrDefault(p => p.TrimStart().StartsWith("CN=", StringComparison.OrdinalIgnoreCase));
return cn?.Trim() ?? dn;
}
}
+32
View File
@@ -0,0 +1,32 @@
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.Tools.LdapProbe;
/// <summary>
/// Minimal logger so the shared library's failover diagnostics are visible during a probe.
/// <c>LdapAuthService</c> logs a Warning per failed endpoint hop and an Information line when
/// its sticky preference moves — exactly the events a DC-failover test needs to observe.
/// </summary>
public sealed class ConsoleLogger<T> : ILogger<T>
{
private readonly bool _verbose;
public ConsoleLogger(bool verbose) => _verbose = verbose;
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => _verbose || logLevel >= LogLevel.Information;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel)) return;
var message = formatter(state, exception);
if (logLevel >= LogLevel.Warning) Ui.Warn($"[library] {message}");
else Ui.Info($"[library] {message}");
if (exception is not null && _verbose)
Ui.Info($" {RawDirectory.Describe(exception)}");
}
}
+33
View File
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<RootNamespace>ZB.MOM.WW.Tools.LdapProbe</RootNamespace>
<AssemblyName>ldapprobe</AssemblyName>
<!-- Deliberately outside the ZB.MOM.WW.Auth CPM tree: this is a throwaway
diagnostic, not a shipped package. Versions are pinned inline. -->
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<IsPackable>false</IsPackable>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<!-- Part A runs the REAL shared library, so what the probe proves is what the
four apps will actually do. Project references (not the 0.2.0 feed package)
so the probe tracks current source. -->
<ProjectReference Include="..\..\ZB.MOM.WW.Auth\src\ZB.MOM.WW.Auth.Ldap\ZB.MOM.WW.Auth.Ldap.csproj" />
<ProjectReference Include="..\..\ZB.MOM.WW.Auth\src\ZB.MOM.WW.Auth.Abstractions\ZB.MOM.WW.Auth.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Part B talks to the directory directly, so it can ask questions the library
does not (transitive groups, primary group, referral behaviour). Must match
the version the library resolves. -->
<PackageReference Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
</ItemGroup>
</Project>
+201
View File
@@ -0,0 +1,201 @@
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
""";
}
+538
View File
@@ -0,0 +1,538 @@
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();
}
+100
View File
@@ -0,0 +1,100 @@
# ldapprobe — ZB / NAM Active Directory auth + group probe
A throwaway diagnostic for pinning down the LDAP settings the four ZB.MOM.WW apps need
against the Zimmer NAM domain, and for answering the open question in
[`../../ldap_setup.md`](../../ldap_setup.md): **which AD groups should we map to app roles?**
The NAM directory has no established group→role convention (the Ignition gateway assigns
roles per-user inside the application and ignores AD groups entirely), while
`ZB.MOM.WW.Auth.Ldap` **fails closed on zero groups** — a user with a perfectly good
password is refused if `memberOf` comes back empty. This probe exists to close that gap
with data rather than guesswork.
## Running it
```bash
cd ~/Desktop/scadaproj
# no service-account password to hand — bind as the user under test:
dotnet run --project tools/LdapProbe -- --insecure --self-bind <sAMAccountName>
# full fidelity, service account from the environment:
export ZB_LDAP_SVC_PASSWORD='...'
dotnet run --project tools/LdapProbe -- --insecure <sAMAccountName>
# which transports actually reach the DCs from here:
dotnet run --project tools/LdapProbe -- --sweep --insecure -u x
# what UG_WW_* groups exist across the whole directory:
dotnet run --project tools/LdapProbe -- --insecure --self-bind -g 'UG_WW_*' <sAMAccountName>
dotnet run --project tools/LdapProbe -- --help
```
Quote the pattern — an unquoted `UG_WW_*` is glob-expanded by the shell.
Passwords are always prompted for and never echoed. They cannot be passed on the command
line — that would put them in shell history. The service-account password may come from
`$ZB_LDAP_SVC_PASSWORD` instead.
Requires the Zimmer VPN to be up: the DCs are `uswap-vdc-nam1` (10.201.216.2) and
`uswap-vdc-nam2` (10.201.138.78).
## What it reports
**Part A** drives the real `ZB.MOM.WW.Auth.LdapAuthService`, so its verdict is exactly
what ScadaBridge / OtOpcUa / MxGateway / HistorianGateway will produce — same bind-then-
search-then-bind flow, same options validator, same failure enum, same fail-closed rules.
Its five failure values are each explained in place, because `ServiceAccountBindFailed`
in particular collapses three very different causes (connect/TLS, service-account bind,
and the search itself) into one value.
**Part B** asks the directory things the library does not, which is where the group
answer comes from:
| | why it matters |
|---|---|
| full user entry | `userAccountControl` decoded, so a disabled / locked / password-expired account is named rather than showing up as "bad credentials" |
| **direct `memberOf`** | the *only* set the shared library sees |
| **primary group** | AD omits it from `memberOf` — so it can never be used as a role group, and a user whose only group is `Domain Users` will always fail |
| **transitive groups** | resolved server-side via `LDAP_MATCHING_RULE_IN_CHAIN`; a group reachable only by nesting is flagged, because mapping one would silently grant nobody anything |
| group type | `security` vs `distribution`, and scope — decoded from `groupType` |
| referral count | a search based at the domain root makes AD return continuation referrals — the cause of the `ServiceAccountBindFailed` fixed in Auth `0.2.1` |
**`--list-groups <pattern>`** adds a third, independent section: every group in the
directory whose `cn` matches, not just the ones the test account belongs to. A membership
dump structurally cannot answer "what groups exist to choose from" — it only ever shows
one person's memberships, which for a long-tenured account skews heavily toward admin
groups. Each row shows the group type, its member count (`EMPTY` and `N+` for AD's ranged
1500-value cap are both called out) and whether the probed account holds it.
It searches from the **domain root** by default, derived from the resolved user DN, because
groups here live in at least three different containers and a narrower base under-reports
without saying so.
It closes with a ready-to-paste `appsettings` block, and — when the library failed
system-side while a direct query succeeded — automatically retries Part A against the
user's own container to test whether the domain-root search base is the culprit.
## `--insecure`
The DC certificates chain to the internal `zimmer-rootca`, which is not in a dev
machine's trust store, so an honest run fails TLS validation. `--insecure` bypasses
validation **for diagnosis only** and the probe says so on every run. The certificate
inspector still prints the real chain, key size and `SslPolicyErrors` either way, so you
can see precisely why validation failed rather than guessing.
Note the two hazards are separate: an untrusted root affects every client, whereas the
**RSA 1024-bit** DC keys are rejected only by OpenSSL at its default security level —
i.e. by Debian-based .NET *containers*, not by macOS or Windows hosts.
Before deploying, fix trust properly: install `zimmer-rootca` (+ `zimmer-issueca1`) into
the host/container trust store, or pin the chain via
`LdapOptions.ServerCertificateValidationCallback`. The emitted config block always sets
`AllowInsecure: false` for that reason.
## Not shipped
This is a diagnostic, deliberately outside the `ZB.MOM.WW.Auth` solution and its central
package management: `IsPackable=false`, no CI, no tests. It references the Auth projects
directly rather than the `0.2.0` feed package so it always tracks current source.
+363
View File
@@ -0,0 +1,363 @@
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));
}
}
+85
View File
@@ -0,0 +1,85 @@
namespace ZB.MOM.WW.Tools.LdapProbe;
/// <summary>Console formatting and credential prompting.</summary>
public static class Ui
{
public static void Section(string title)
{
Console.WriteLine();
Write(ConsoleColor.Cyan, $"── {title} " + new string('─', Math.Max(0, 66 - title.Length)));
}
public static void Ok(string message) => Write(ConsoleColor.Green, " ✓ " + message);
public static void Bad(string message) => Write(ConsoleColor.Red, " ✗ " + message);
public static void Warn(string message) => Write(ConsoleColor.Yellow, " ! " + message);
public static void Info(string message) => Console.WriteLine(" " + message);
public static void Field(string name, string? value) =>
Console.WriteLine($" {name,-22} {value ?? "(not set)"}");
private static void Write(ConsoleColor color, string message)
{
var previous = Console.ForegroundColor;
try
{
Console.ForegroundColor = color;
Console.WriteLine(message);
}
finally
{
Console.ForegroundColor = previous;
}
}
public static string Prompt(string label, string? preset = null)
{
if (!string.IsNullOrWhiteSpace(preset)) return preset;
Console.Write($" {label}: ");
return Console.ReadLine()?.Trim() ?? "";
}
/// <summary>
/// Reads a password without echoing it. Falls back to a plain read when input is
/// redirected (a pipe has no key events), so the probe still works non-interactively.
/// </summary>
public static string PromptSecret(string label)
{
Console.Write($" {label}: ");
if (Console.IsInputRedirected)
{
var piped = Console.ReadLine() ?? "";
Console.WriteLine("(read from stdin)");
return piped;
}
var buffer = new Stack<char>();
while (true)
{
var key = Console.ReadKey(intercept: true);
switch (key.Key)
{
case ConsoleKey.Enter:
Console.WriteLine();
return new string(buffer.Reverse().ToArray());
case ConsoleKey.Backspace when buffer.Count > 0:
buffer.Pop();
Console.Write("\b \b");
break;
case ConsoleKey.Backspace:
break;
case ConsoleKey.Escape:
Console.WriteLine();
return "";
default:
if (!char.IsControl(key.KeyChar))
{
buffer.Push(key.KeyChar);
Console.Write('*');
}
break;
}
}
}
}