Compare commits
3 Commits
846b77f5b4
...
818775b311
| Author | SHA1 | Date | |
|---|---|---|---|
| 818775b311 | |||
| 4e01cbc2c3 | |||
| 1f8ab8e3b3 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)}");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
""";
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user