# 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 `@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_*`](#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 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`](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 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_`, 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_[_RO]_ APP_ADMIN | APP_IDE | HIST_ADMIN | BTMGR | CITRIX UG_WW_SITE_ADMIN_ BER GAL PAR PON SHN WN0A WW05 WW19 UG_WW___ 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` 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). **ADOPTED by all four apps 2026-08-13** — OtOpcUa `master @ 08e33fc7`, ScadaBridge `main @ d28824d9`, MxAccessGateway `main @ 1cb14d2`, HistorianGateway `e42ff31` (on its `dashboard-ui-sweep-theme-0.4.1` branch) — each built + tested green, all pushed to origin. `FallbackServers` failover was live-gated the same day against the local redundant GLAuth pair ([`infra/glauth-redundant/`](infra/glauth-redundant/)): HistorianGateway dashboard login drill PASS (sticky failover both directions). 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.