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

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

- LDAPS 636, StartTLS 389 and plain 389 all connect on both DCs. The only TLS
  chain error is UntrustedRoot, so installing zimmer-rootca is sufficient; the
  RSA 1024-bit key is rejected only by OpenSSL at SECLEVEL=2, i.e. Debian .NET
  containers, not by macOS or Windows hosts.
- The UG_WW_* Wonderware group convention, 39 groups enumerated directory-wide.
  This partly overturns this doc's earlier "no group->role convention exists"
  claim: that was true of Ignition, which was the only source audited, and
  false of the domain. Three shapes with a consistent role suffix (ADMIN, RO,
  OPERATOR, ENGINEER, SUPERVISOR, TESTER) across environment and site axes.
- A proposed mapping onto CANONICAL-ROLES, with the three gaps left open
  rather than guessed: SUPERVISOR/TESTER have no canonical equivalent, nothing
  maps to Deployer, and the DEV/QA/PROD axis is orthogonal to role so it must
  ride in the per-project scope payload or the role set triples.
- The referral defect and its fix.
2026-08-12 21:27:23 -04:00

16 KiB
Raw Blame History

Zimmer NAM — LDAP / Active Directory Auth Setup

Reference for configuring services to authenticate against the Zimmer NAM domain. Extracted from the production Ignition 8.1 gateway Ignition-IGNTN-APP-VP01 (config.idb, NAM AD-Hybrid user source), audited 2026-08-05. Full audit: ~/Desktop/config-idb-users-groups.md.

Directory / connection

Setting Value
Domain nam.zimmer.com
Primary DC uswap-vdc-nam1:389
Secondary DC uswap-vdc-nam2:389
Encryption None — plain LDAP on 389 (no LDAPS/StartTLS in the Ignition config)
SASL DIGEST-MD5, CRAM-MD5 offered; QoP auth-conf,auth-int,auth; strength high,medium,low; no mutual auth
Referrals FOLLOW
Read timeout 60 000 ms
Paging 1000 entries/page
Anonymous bind Disallowed

Bind identity (UPN, not DN)

  • Service account: svc_igntnapp
  • Binds by UPN: svc_igntnapp@nam.zimmer.com — there is no bind DN/CN anywhere in the working config; end users also authenticate as <sAMAccountName>@nam.zimmer.com (automatic suffix).
  • Password: held encrypted by the gateway; obtain from the credential owner — never commit it (in this repo's services use a ${secret:NAME} reference).

User lookup

Setting Value
Search base domain root DC=nam,DC=zimmer,DC=com (Ignition field left empty)
User search filter (&(objectClass=user)(sAMAccountName={0}))
User enumeration filter (&(objectClass=user)(!(objectClass=computer)))
Username attribute sAMAccountName
Display name name
Email mail
Phone / SMS telephoneNumber / mobile

Groups / roles

The working setup does not use LDAP groups at all. The Ignition profile is AD-Hybrid: AD answers who you are, while what you may do (roles Administrator, Designer, Engineer, Maintenance, Management, Mfg_Tech, Operator) is assigned manually per user inside the application. AD security-group membership grants nothing.

Implication for new services: don't assume a memberOf-based authorization scheme exists to mirror — there is no established group→role convention in this domain's SCADA stack. Either keep app-local role assignment (matching the 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_*. 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 successful AD login (with no roles until granted).
  • Failover: soft failover to the local internal user source, 60 s credential-cache validation — cached logins keep working through a DC outage.
  • Lockout: 5 failed attempts / 15-minute window.

Cautions

  • Port 389 without TLS means simple binds transit cleartext unless SASL confidentiality is negotiated — for new .NET services prefer LDAPS (636) or StartTLS against the same DCs.

TLS probe results (verified 2026-08-10, from the dev Mac)

Both DCs were live-probed; LDAPS and StartTLS both work on both DCs.

Check uswap-vdc-nam1 uswap-vdc-nam2
Ports open 389, 636, 3268, 3269 389, 636, 3268, 3269
LDAPS (636) handshake TLSv1.3 OK TLSv1.3 OK
StartTLS on 389 OK OK
Anonymous rootDSE over LDAPS result: 0 Success result: 0 Success
Cert subject/SAN USWAP-VDC-NAM1.nam.zimmer.com (matches FQDN) USWAP-VDC-NAM2.nam.zimmer.com (matches FQDN)
Cert validity 2026-03-21 → 2027-03-21 2026-03-20 → 2027-03-20
Chain leaf ← zimmer-issueca1 ← zimmer-rootca (intermediate served, root not) same
Key size RSA 1024-bit (sha256 sig) RSA 1024-bit (sha256 sig)

Implications for new .NET services:

  • Use LDAPS 636 (or StartTLS) — no reason to accept cleartext binds.
  • Trust: zimmer-rootca is an internal CA, not in default OS/container trust stores. Either install the root (+ intermediate) into the host/container trust store, or pin the chain via LdapOptions.ServerCertificateValidationCallback (code-only seam in ZB.MOM.WW.Auth.Ldap).
  • ⚠️ RSA 1024-bit DC certs: OpenSSL at default security level (SECLEVEL=2 — what Debian-based .NET containers ship) rejects 1024-bit keys (EE certificate key too weak, observed in this probe), so a Linux/container client may fail the TLS handshake even with the CA trusted. Windows/Schannel hosts accept them. Options: get the DC cert template reissued at 2048+ (right fix, needs AD PKI owner), or lower SECLEVEL / use a custom validation callback in affected containers (workaround). Certs expire 2027-03 — a reissue opportunity.
  • Hostnames resolve from the dev network: nam1 = 10.201.216.2, nam2 = 10.201.138.78.

Probe tool

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.

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, 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.