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.
This commit is contained in:
+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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user