docs(plans): VD03 NAM AD LDAP cutover plan (not executed)
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
# VD03 NAM AD LDAP cutover — plan (NOT executed)
|
||||
|
||||
**Written 2026-08-13. Status: PLAN ONLY.** Swap every auth-bearing service on
|
||||
`wonder-app-vd03` from its current login posture (mostly login-disabled) to the real
|
||||
Zimmer NAM Active Directory, with the service-account password held as a
|
||||
`${secret:}` reference in each app's encrypted Secrets store — never in config.
|
||||
|
||||
Grounded in: [`ldap_setup.md`](../../ldap_setup.md) (directory facts + the 2026-08-13
|
||||
end-to-end verification via the HistorianGateway docker container),
|
||||
[`ldap_mapping.md`](../../ldap_mapping.md) (the `UG_WW_*` → role mapping, §4),
|
||||
and the wonder deploy records (memory: `wonder-upgrade-program`).
|
||||
|
||||
## Scope
|
||||
|
||||
| Service on VD03 | In scope | LDAP config surface | Login state today |
|
||||
|---|---|---|---|
|
||||
| OtOpcUa AdminUI | ✅ | `Security:Ldap` (+ `GroupToRole` subkey, merged with a DB overlay) | LDAP enabled; target = TBD in preflight (data plane is API-key, **not** LDAP — untouched) |
|
||||
| ScadaBridge (central UI) | ✅ | `ScadaBridge:Security:Ldap` in `appsettings.Central.json`; roles in the **`LdapGroupMapping` DB table**, not config | **Login DISABLED** (`DisableLogin=true` + `AllowOutsideDevelopment=true` — the two-key set) |
|
||||
| MxAccessGateway dashboard | ✅ | `MxGateway:Ldap` + `MxGateway:Dashboard:GroupToRole` (gRPC stays API-key, untouched) | LDAP enabled per 08-12 notes; target = TBD in preflight |
|
||||
| HistorianGateway dashboard | ✅ | `Ldap` (root section) + `Dashboard:GroupToRole` (gRPC stays API-key, untouched) | **Login DISABLED** (`Dashboard:DisableLogin=true`, deliberately open) |
|
||||
| Overview | ❌ exempt | — | No login **by design** (holds no credentials; references no Auth package) |
|
||||
|
||||
Out of scope: windev's OtOpcUa/mxgw instances, the QA pair (VQ03/VQ04 — do it as a
|
||||
follow-on once VD03 has soaked; QA is where the 2-node/failover behaviour actually gets
|
||||
exercised), and OtOpcUa's OPC UA data plane (API-key auth since Gitea #520).
|
||||
|
||||
## The target LDAP block (identical shape in all four apps)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"Enabled": true,
|
||||
"Server": "uswap-vdc-nam1.nam.zimmer.com",
|
||||
"Port": 636,
|
||||
"Transport": "Ldaps",
|
||||
"AllowInsecure": false, // see TLS decision below
|
||||
"FallbackServers": [ "uswap-vdc-nam2.nam.zimmer.com:636" ],
|
||||
"SearchBase": "DC=nam,DC=zimmer,DC=com",
|
||||
"ServiceAccountDn": "svc_igntadmin@nam.zimmer.com",
|
||||
"ServiceAccountPassword": "${secret:nam-ldap-svc-password}",
|
||||
"UserNameAttribute": "sAMAccountName",
|
||||
"DisplayNameAttribute": "name",
|
||||
"GroupAttribute": "memberOf"
|
||||
}
|
||||
```
|
||||
|
||||
Facts already proven live (2026-08-13, HG docker → real NAM AD): the UPN bind as
|
||||
`svc_igntadmin` works (**not** `svc_igntnapp` — that is Ignition's account), the
|
||||
domain-root SearchBase works **on Auth 0.2.1 only**, `memberOf` DNs collapse to bare
|
||||
CNs so `GroupToRole` keys are plain `UG_WW_*` names, and `FallbackServers` walks both
|
||||
DCs on system-side failures. Users log in with the **bare sAMAccountName**.
|
||||
|
||||
## Hard prerequisite: every deployed build must carry Auth 0.2.1
|
||||
|
||||
**None of the four deployed builds has it** (pins landed 2026-08-13, after the last
|
||||
deploy round): OtOpcUa runs `c51c5998`, ScadaBridge `20f2ae05`, mxgw the 08-12 build
|
||||
(Auth 0.2.0), HG `e9c31da` (Auth **0.1.5**). On anything below 0.2.1 a domain-root
|
||||
SearchBase fails **every** login as `ServiceAccountBindFailed` (AD continuation
|
||||
referrals), and on 0.1.5 there is no `FallbackServers` at all. Narrowing SearchBase
|
||||
below the root is NOT an acceptable workaround — user objects span multiple OUs.
|
||||
|
||||
So **Phase 1 is a redeploy of all four apps at their current default-branch tips**
|
||||
(each already pins 0.2.1 and is pushed: OtOpcUa `08e33fc7`, ScadaBridge `d28824d9`,
|
||||
mxgw `1cb14d2`, HG `e42ff31`). This folds into the standing wonder-upgrade practice —
|
||||
HG is behind `main` anyway. Run the standard per-app preflight (migration-delta check,
|
||||
appsettings SHA capture, env backup) from the `wonder-upgrade-program` memory.
|
||||
|
||||
## Decisions to confirm before executing
|
||||
|
||||
1. **Service account.** Use `svc_igntadmin` (proven), or request a dedicated
|
||||
`svc_zbmomww` via ServiceNow first (the `UG_WW_*` groups are ticket-provisioned, so
|
||||
the process exists). Reusing the Ignition admin account couples our uptime to their
|
||||
rotations. **Recommended: start with `svc_igntadmin`, file the dedicated-account
|
||||
ticket in parallel, swap later by rotating one secret per store.**
|
||||
2. **Enabling login on services that are deliberately open.** ScadaBridge and HG run
|
||||
login-disabled on this box today, on purpose. This cutover flips both to
|
||||
authenticated. Confirm that is wanted on the DEV box (it is the point of the
|
||||
exercise, but it changes who can reach those dashboards).
|
||||
3. **Who gets in: VD03 = DEV tier ⇒ map only `_DEV` groups** (ldap_mapping §3 — a
|
||||
deployment maps only its own tier; mapping PROD groups here would make every DEV
|
||||
grant a cross-tier grant). Verify the intended users are actually members:
|
||||
`UG_WW_APP_ADMIN_DEV` (7), `UG_WW_HIST_ADMIN_DEV` (7), `UG_WW_APP_IDE_DEV` (6),
|
||||
`UG_WW_APP_IDE_RO_DEV` (2). If someone who needs access is in none, that is a
|
||||
ServiceNow group-membership request **before** cutover, not a mapping widen.
|
||||
(`BTMGR_RO` has no DEV group — omit.) Check with
|
||||
`ldapprobe --insecure --svc svc_igntadmin@nam.zimmer.com --self-bind <sAMAccountName>`.
|
||||
4. **TLS.** Try `AllowInsecure: false` first — the box is on the corporate network and
|
||||
Windows machine stores on managed boxes usually carry `zimmer-rootca` (installing it
|
||||
is sufficient; the only chain error observed is `UntrustedRoot`, and the RSA-1024
|
||||
hazard is OpenSSL-container-specific, not Windows). If the preflight cert check
|
||||
fails: install `zimmer-rootca` into LocalMachine\Root (preferred) or fall back to
|
||||
`AllowInsecure: true` (DEV-acceptable; transport still encrypted).
|
||||
5. **ScadaBridge plant-floor/operator rows** (`*_OPERATOR`, `SITE_ADMIN_*`,
|
||||
`SUPERVISOR`/`ENGINEER`): ldap_mapping flags these ⚠ DECIDE and they are not
|
||||
env-scoped. **Recommended: leave them out of this cutover** — seed only the
|
||||
platform rows (below) and add operator rows when a real site use-case lands.
|
||||
|
||||
## Phase 0 — preflight (read-only, no changes)
|
||||
|
||||
From the box (servecli :2222; note servecli never survives a reboot — restart it first
|
||||
if dead):
|
||||
|
||||
- **Network:** `Test-NetConnection uswap-vdc-nam1.nam.zimmer.com -Port 636` (and nam2).
|
||||
- **TLS trust:** from PowerShell, open an `SslStream` to nam1:636 (or
|
||||
`certutil -verify` the exported chain) to learn whether `zimmer-rootca` is trusted →
|
||||
settles decision 4.
|
||||
- **Service-account credential, once, without lockout risk** (bad binds count on both
|
||||
DCs per app attempt; policy is 5/15 min): a single bind test from the Mac —
|
||||
`LDAPTLS_REQCERT=never ldapsearch -H ldaps://uswap-vdc-nam1.nam.zimmer.com:636 -x -D
|
||||
svc_igntadmin@nam.zimmer.com -W -b "DC=nam,DC=zimmer,DC=com" -s base dn`.
|
||||
- **Capture current state per app:** SHA the live appsettings files; record the NSSM
|
||||
registry env (`Parameters\AppEnvironmentExtra` for mxgw/HG; the OtOpcUa/ScadaBridge
|
||||
service envs) — **check no `Ldap__*` keys lurk in service env, which would silently
|
||||
override the JSON edits**; record current `DisableLogin` posture.
|
||||
- **Secrets plumbing:** confirm the four stores + file-based KEKs are healthy (they
|
||||
were authored + round-trip-verified 2026-08-12; KEK backups at
|
||||
`E:\ApiInstall\_kek-backup-20260812T070057`). Stores live outside content roots
|
||||
(OtOpcUa `E:\ApiInstall\OtOpcUa-data\`, mxgw + HG under their `data\`,
|
||||
ScadaBridge per its overlays).
|
||||
|
||||
## Phase 1 — redeploy all four apps at Auth-0.2.1 tips
|
||||
|
||||
Standard wonder deploy recipe per app (memory: `wonder-app-vd03-mxgateway-deploy` +
|
||||
per-app records). Self-contained win-x64 publishes (box has no .NET 10 runtime).
|
||||
Expected deltas are small (each app's tip is its 08-12 deployed SHA + the 0.2.1 pin
|
||||
± UI-sweep commits for HG); still run the migration-delta check — HG's jump
|
||||
(`e9c31da → e42ff31`) carries the Theme 0.4.1/Health 0.3.0/Secrets 0.6.2 sweep, so
|
||||
give it the full preflight (its Secrets 0.6.2 store-path guard will validate the
|
||||
box's paths — they were fixed 08-12, so this should pass, but it is the guard that
|
||||
bit OtOpcUa last round).
|
||||
|
||||
**Gate:** all services healthy on their current (pre-LDAP-swap) config before touching
|
||||
auth. Do not combine the redeploy and the auth swap in one restart — two variables.
|
||||
|
||||
## Phase 2 — seed the secret into each app's store
|
||||
|
||||
Secret name: **`nam-ldap-svc-password`** (same name in every store, so the config
|
||||
reference is identical everywhere and rotation is a mechanical sweep).
|
||||
|
||||
Use the on-box CLI `E:\ApiInstall\_tools\secret` (0.6.2, self-contained). **Trap
|
||||
(proven 08-12): bare CLI verbs read the CLI's own env, NOT the target app's config** —
|
||||
target each store per-invocation:
|
||||
|
||||
```powershell
|
||||
$env:Secrets__SqlitePath = "<that app's store path>"
|
||||
$env:Secrets__MasterKey__Source = "File"
|
||||
$env:Secrets__MasterKey__FilePath = "<that app's KEK file>"
|
||||
E:\ApiInstall\_tools\secret\secret.exe set nam-ldap-svc-password # prompts; never a literal arg
|
||||
```
|
||||
|
||||
Stores to seed (verify paths from the live NSSM/registry env captured in Phase 0):
|
||||
|
||||
| Store | Needed by |
|
||||
|---|---|
|
||||
| OtOpcUa (`E:\ApiInstall\OtOpcUa-data\...`) | AdminUI LDAP login |
|
||||
| ScadaBridge **central** | central UI LDAP login (the `${secret:}` ref lives in `appsettings.Central.json`) |
|
||||
| ScadaBridge **site** | not strictly needed (site does no LDAP) — seed anyway for symmetry; the two stores share one KEK but are separate stores |
|
||||
| MxGateway | dashboard LDAP login |
|
||||
| HistorianGateway | dashboard LDAP login |
|
||||
|
||||
Verify each with `secret.exe get nam-ldap-svc-password` (value round-trip) under the
|
||||
same per-invocation targeting. **Rotation note for the runbook:** one password now
|
||||
lives in 4–5 stores; a rotation is N `set` operations plus zero config changes.
|
||||
|
||||
## Phase 3 — config edits (one app at a time, restart, verify, then next)
|
||||
|
||||
Order: **HG → mxgw → OtOpcUa → ScadaBridge** (proven-pattern first, DB-mapping app
|
||||
last). Back up every file edited; edits go in the box's live overlay files, and the
|
||||
next deploy must re-apply them (add to the deploy-notes convention — the deploy
|
||||
process restores box appsettings, so once these edits ARE the box appsettings they
|
||||
survive; the risk is a fresh-install overwrite, same as every other box-local key).
|
||||
|
||||
### 3a. HistorianGateway
|
||||
- `Ldap` section ← the target block above.
|
||||
- `Dashboard:DisableLogin` → `false`; `Dashboard:GroupToRole` ←
|
||||
`UG_WW_HIST_ADMIN_DEV: Administrator`, `UG_WW_APP_ADMIN_DEV: Administrator`,
|
||||
`UG_WW_APP_IDE_RO_DEV: Viewer`.
|
||||
- Restart `ZbHistorianGateway`; run the Phase 4 checks.
|
||||
|
||||
### 3b. MxAccessGateway
|
||||
- `MxGateway:Ldap` ← target block.
|
||||
- `MxGateway:Dashboard:GroupToRole` ← `UG_WW_APP_ADMIN_DEV: Administrator`,
|
||||
`UG_WW_APP_IDE_DEV: Viewer`, `UG_WW_APP_IDE_RO_DEV: Viewer` (only two roles exist;
|
||||
Designer collapses to Viewer per ldap_mapping §4).
|
||||
- Restart; verify. (gRPC/API-key surface untouched — do NOT disturb the restored
|
||||
API-key store in `data\`.)
|
||||
|
||||
### 3c. OtOpcUa (AdminUI only)
|
||||
- `Security:Ldap` ← target block (keep any existing `GroupToRole` DB-overlay rows in
|
||||
mind — config merges with the DB overlay; check the RoleGrants page after).
|
||||
- `Security:Ldap:GroupToRole` ← `UG_WW_APP_ADMIN_DEV: Administrator`,
|
||||
`UG_WW_APP_IDE_DEV: Designer`, `UG_WW_APP_IDE_RO_DEV: Viewer`,
|
||||
`UG_WW_HIST_ADMIN_DEV: Viewer` (historization targets are authored here — see
|
||||
ldap_mapping §4 rationale).
|
||||
- Restart the AdminUI/central service. Driver-only nodes are unaffected (no LDAP).
|
||||
- The OPC UA data plane keeps its `otua_` API keys — no consumer impact.
|
||||
|
||||
### 3d. ScadaBridge (central)
|
||||
- `appsettings.Central.json`: `ScadaBridge:Security:Ldap` ← target block; **remove the
|
||||
two disable-login keys** (`DisableLogin` + `AllowOutsideDevelopment`) — note the trap:
|
||||
removing only `AllowOutsideDevelopment` while leaving `DisableLogin=true`
|
||||
**crash-loops** (`DisableLoginGuard`); remove both.
|
||||
- Role rows in the central DB (`LdapGroupMapping` — the only app whose mapping is data,
|
||||
and the only one that can grant two roles per group):
|
||||
```sql
|
||||
INSERT INTO LdapGroupMapping (LdapGroupName, Role) VALUES
|
||||
('UG_WW_APP_ADMIN_DEV', 'Administrator'),
|
||||
('UG_WW_APP_IDE_DEV', 'Designer'),
|
||||
('UG_WW_APP_IDE_DEV', 'Deployer'), -- two rows on purpose (§2.1)
|
||||
('UG_WW_APP_IDE_RO_DEV', 'Viewer');
|
||||
```
|
||||
(Operator/site rows deliberately deferred — decision 5.)
|
||||
- Restart central; site service untouched.
|
||||
|
||||
## Phase 4 — verification (per app, immediately after its restart)
|
||||
|
||||
The log **failure stage** is the diagnostic (proven live, recorded in ldap_setup.md):
|
||||
|
||||
1. **Negative control first** (bogus user + bogus password): expect a clean
|
||||
"invalid/not authorized" with **zero** `WRN ... failed system-side` lines. A
|
||||
`service-bind`-stage WRN = wrong secret value; a `search`-stage WRN = empty
|
||||
password (the `${secret:}` ref did not resolve — check the store/KEK targeting);
|
||||
connect-stage = network/TLS.
|
||||
2. **Real login** with an account in a mapped `_DEV` group → correct role visible in
|
||||
the UI. And one with **no** mapped group → denied (fail-closed zero-group rule).
|
||||
3. **`${secret:}` positive control:** the value must come from the store, not residue —
|
||||
e.g. confirm no `Ldap__ServiceAccountPassword` in service env, and that
|
||||
`secret.exe get` matches what auth uses (a wrong-value canary set+revert is the
|
||||
strong form).
|
||||
4. **Failover spot-check (once, on HG):** block/blackhole nam1:636 briefly →
|
||||
login still works, WRN for nam1 + `sticky preference moved` to nam2; restore.
|
||||
Do not drill on every app — it is the same library and it doubles bad-bind
|
||||
exposure against lockout policy if anything is misconfigured.
|
||||
5. Mind the lockout arithmetic throughout: every failed app attempt = a failed bind
|
||||
on **both** DCs (endpoint walk). Stop and diagnose after 2 failures, don't retry.
|
||||
|
||||
## Rollback
|
||||
|
||||
Per app, in reverse: restore the backed-up appsettings (which restores the previous
|
||||
login posture, including ScadaBridge's two-key disable and HG's `DisableLogin=true`),
|
||||
restart. The seeded secrets are inert once nothing references them — leave them.
|
||||
ScadaBridge: `DELETE FROM LdapGroupMapping WHERE LdapGroupName LIKE 'UG_WW_%'` only if
|
||||
a clean slate is wanted; unreferenced rows grant nothing while login is disabled.
|
||||
Phase 1's redeploys are independent and should NOT be rolled back for an auth problem.
|
||||
|
||||
## Follow-ups this creates
|
||||
|
||||
- QA pair (VQ03/VQ04) cutover with `_QA` groups — the same plan, plus real
|
||||
failover/pair semantics; QA is where `FallbackServers` + sticky actually matter.
|
||||
- Dedicated service account (decision 1) → rotate `nam-ldap-svc-password` in N stores.
|
||||
- Revert the Mac's HG docker container from its temporary NAM flip back to the
|
||||
glauth-redundant pair (uncommitted working-tree change, independent of this plan).
|
||||
- ldap_mapping.md is still untracked in scadaproj — commit it when its DECIDE items
|
||||
settle; this plan depends on its §4 tables.
|
||||
Reference in New Issue
Block a user