Files

344 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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_*`](#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 <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`.
### End-to-end app login verified 2026-08-13 (HistorianGateway docker → real NAM AD)
A real ZB.MOM.WW app authenticated a real user against the production directory for the
first time: the local HistorianGateway container (Auth `0.2.1`) with its dashboard LDAP
temporarily flipped from the glauth-redundant pair to the two NAM DCs — LDAPS 636,
`AllowInsecure` (the container trust store lacks `zimmer-rootca`), domain-root
`SearchBase`, `sAMAccountName` login, `memberOf` → bare-CN `UG_WW_*` keys in
`Dashboard:GroupToRole`. Login succeeded and the group mapping resolved to a dashboard
role. The flip is an **uncommitted working-tree change** in
`HistorianGateway/docker/docker-compose.yml` (revert: `git checkout -- docker/docker-compose.yml`
+ remove the `Ldap__ServiceAccountPassword` line from `histgw.secrets.env`).
Three findings worth keeping:
- **The service account for our use is `svc_igntadmin@nam.zimmer.com` — not
`svc_igntnapp`.** `svc_igntnapp` is *Ignition's* account (the "Bind identity" section
above describes Ignition's config); binding it with our held password fails
`data 52e` (bad credential). ldapprobe's built-in default is `svc_igntnapp` — pass
`--svc svc_igntadmin@nam.zimmer.com` when probing with the held password.
- **The failover log's failure *stage* is a precise diagnostic.** Seen live:
`failed system-side at search` on both DCs = the service password is **empty**
(AD treats a passwordless simple bind as anonymous, accepts it, then refuses the
search); `failed system-side at service-bind` = the credential is **wrong** (52e);
a clean "invalid or not authorized" with **no** WRN lines = the directory answered
and the user is unknown or matched zero mapped groups (fail-closed).
- **Failed logins double against lockout policy** (5 attempts / 15 min): a bad
*service* credential costs one failed bind on **each** DC per attempt, because the
endpoint walk retries system-side failures on the fallback. Validate a candidate
service password with a single `ldapsearch -H ldaps://uswap-vdc-nam1… -x -D <upn> -W`
before letting an app loop on it.
The Debian-based .NET 10 container handshook LDAPS 636 to both DCs without the
`SECLEVEL` workaround mentioned above being needed.
### `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). **ADOPTED by all four apps 2026-08-13** —
OtOpcUa `master @ 08e33fc7`, ScadaBridge `main @ d28824d9`, MxAccessGateway
`main @ 1cb14d2`, HistorianGateway `e42ff31` (merged to `main`) — 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.