10 KiB
SEC-36 — LDAP Service-Account Credential Rotation (Operator Runbook)
Executed 2026-08-07 — the rotation is done; this runbook is now history plus the four corrections below. A new service-account password was generated,
scadaproj/infra/glauth/config.toml'sserviceaccountpasssha256was replaced and the shared GLAuth recreated, and the old value (the literal this repo committed, live in the directory since 2026-06-04) no longer binds. The new value now exists only in the three channels this runbook names: the GLAuthpasssha256(committed inscadaproj), the NSSM service environment on10.100.0.48, and this dev Mac's user-secrets. The retired plaintext was also scrubbed fromscadaproj/infra/glauth/'sconfig.toml/docker-compose.yml/README.mdcomments and from the host's livedocker-compose.yml(the*.bak-sec36backups on the host still carry it, deliberately — they are the rollback artifacts).Correction 1 — host paths in step 3 were stale. The runbook says
cd ~/Desktop/scadaproj/infra/glauthon10.100.0.35. That directory does not exist there:scadaprojis a dev-workstation checkout, and the docker host runs the stack from/home/dohertj2/zb-glauth(containerzb-shared-glauth, project namezb-shared-glauth). The repo remains the source of truth; deployment is thescpofconfig.toml/docker-compose.ymlinto~/zb-glauthdocumented inscadaproj/infra/glauth/README.md, followed bydocker compose up -d --force-recreatethere.Correction 2 —
wonder-app-vd03is out of scope, on documentary evidence. The precondition above says to checkMxGateway:Ldap:Enabledon that host. It could not be checked directly (the host is unreachable from the dev network), but it is out of scope regardless: its gateway binds a different directory — the ScadaBridge/ScadaLink local GLAuth underdc=scadalink/dc=scadabridge, notdc=zb,dc=local— so this credential is not one it can hold. No env var was staged there and none is needed.Correction 3 — step 4's dashboard verification is deferred on
10.100.0.48; a direct bind was used instead. The NEW value is staged on windev (added as the 10thAppEnvironmentExtraentry on theMxAccessGwNSSM service), but dashboard/logincould not exercise it: windev's gateway is crash-looping on a pre-existing, unrelated fault — the deployed Server binary (2026-06-25) predates the auth-DB migration of 2026-07-15, so it opens a schema-version-3 database it only supports at version 2 and aborts at startup (~10k Hosting-failed events/day since at least 08-06). This is a stale-deployment problem, not a rotation problem; it is filed as a next-cycle finding. Verification used instead: a directldapsearchbind ascn=serviceaccount,dc=zb,dc=localwith the new value against10.100.0.35:3893succeeded and returned themulti-roleentry — which is precisely the search bind the dashboard performs, minus the HTTP shell. Finish the deferred check when windev is repaired: redeploy a current Server build (or restore a schema-2 auth DB), then browse the dashboard/loginasmulti-roleper step 4.Correction 4 — the lockout caution under "Verifying the rotation" is inert for this instance. It warns that GLAuth's 3-fail / 10-minute per-IP lockout can lock the whole office when testing that the old value is dead. This GLAuth runs
LimitFailedBinds = false(config.toml:14), so no failed-bind limiter is active and the caution does not apply here. Keep the caution for any instance that enables the limiter.
Operator steps to rotate the shared GLAuth service-account password after the repo-side removal landed (SEC-36). The repo change (removal of the committed value, the two supported secret channels, and this runbook) is already merged; the live rotation below is the load-bearing half and is yours to execute.
Never put the old or new password in this repo, in a commit, in a chat, or in this file. The value lives only in the GLAuth source of truth and in each host's out-of-band channel.
Why
The dev GLAuth service-account password (cn=serviceaccount,dc=zb,dc=local) was historically
committed to this repo. Removal alone is insufficient — the old value is permanently recoverable
from git history — so rotation is required. Until the shared GLAuth on 10.100.0.35:3893
stops honoring the old value, the repo history discloses a live directory account with LDAP
search capability over dc=zb,dc=local.
Where the credential lives now (three channels, all bind MxGateway:Ldap:ServiceAccountPassword)
- Source of truth:
scadaproj/infra/glauth/config.tomlon host10.100.0.35(theserviceaccountuser'spasssha256).scadaprojis a shared monorepo — stage only the explicit glauth paths. - Encrypted secrets store (gateway default):
appsettings.jsonships${secret:ldap/mxgateway/bind}, resolved from the local encrypted store (seed withsecret set ldap/mxgateway/bind <value>). - Deployed hosts: env var
MxGateway__Ldap__ServiceAccountPasswordin the NSSM service environment. - Dev boxes:
dotnet user-secrets set "MxGateway:Ldap:ServiceAccountPassword" <value>(the server carries<UserSecretsId>mxaccessgw-server</UserSecretsId>).
See docs/GatewayConfiguration.md (the ServiceAccountPassword row) and glauth.md.
Preconditions
- SSH access to the GLAuth docker host
10.100.0.35and to the deployed gateway host(s). - Write access to
scadaproj/infra/glauth/. - Know which deployed hosts run LDAP-backed dashboard login:
10.100.0.48(windev) — primary; verify here.wonder-app-vd03— its dashboard is disabled. CheckMxGateway:Ldap:Enabledthere first. If LDAP is disabled (Enabled=false), it has nothing to bind and needs no env var — skip it.
- A generated replacement secret (see step 1). Generate the
passsha256perglauth.md("Generatepasssha256from a plaintext password").
Cutover order
Follow this order so no window opens where the deployed dashboard cannot bind. Do not rotate GLAuth before the deployed hosts already carry the new value.
-
Generate the new secret in
scadaproj/infra/glauth/. Pick a new password, compute itspasssha256, and stage the change to theserviceaccountuser inconfig.toml(do notdocker compose upyet — the directory must keep honoring the OLD value until the deployed hosts carry the NEW one). -
Pre-stage the NEW value on every LDAP-enabled deployed host via the env-var channel, so the host is ready the instant GLAuth flips:
nssm get MxAccessGw AppEnvironmentExtra nssm set MxAccessGw AppEnvironmentExtra MxGateway__Ldap__ServiceAccountPassword=<new-value> # restart the service so the new environment is picked up nssm restart MxAccessGwDo this on
10.100.0.48, and onwonder-app-vd03only ifMxGateway:Ldap:Enabled=truethere. (Alternatively seed the encrypted store withsecret set ldap/mxgateway/bind <new-value>; the env var overrides the store and is the simplest per-host mechanism.) At this moment the deployed host holds the NEW value but GLAuth still honors the OLD one — binds still fail closed against the old directory, which is expected and brief; proceed immediately. -
Rotate GLAuth on
10.100.0.35to honor the new value:ssh 10.100.0.35 cd ~/Desktop/scadaproj/infra/glauth docker compose up -d --force-recreate docker compose logs -f # confirm clean startup, no TOML parse error -
Verify dashboard login on the deployed host(s). Browse to the gateway dashboard on
10.100.0.48and log in asmulti-role/password(Administrator) — a successful login proves the search bind used the new service-account credential end-to-end. Ifwonder-app-vd03runs LDAP, verify it too; if its dashboard/LDAP is disabled, no check is needed. -
The repo change is already landed (removal of the committed value,
<UserSecretsId>, the validator message naming the two channels, and doc/scrub updates). Nothing more to commit for the cutover. -
Developers set user-secrets on next pull. After pulling, a dev box with no secret configured will fail startup with a validation message naming the exact command. One-time per machine:
dotnet user-secrets set "MxGateway:Ldap:ServiceAccountPassword" <new-value>(value from
scadaproj/infra/glauth/, never from a repo file).
Verifying the rotation
- Primary: dashboard
/loginasmulti-roleon10.100.0.48succeeds (step 4). wonder-app-vd03: only ifMxGateway:Ldap:Enabled=true; otherwise no action.- Live-LDAP integration tests (opt-in, only where the GLAuth instance is reachable):
A green
$env:MXGATEWAY_RUN_LIVE_LDAP_TESTS = "1" $env:MxGateway__Ldap__ServiceAccountPassword = "<new-value>" # shell env only, never committed dotnet test src/ZB.MOM.WW.MxGateway.IntegrationTests/ZB.MOM.WW.MxGateway.IntegrationTests.csproj ` --filter FullyQualifiedName~DashboardLdapLiveTestsDashboardLdapLiveTestsrun confirms the new credential binds and searches. Where GLAuth is unreachable, document the suite as skipped per thedocs/GatewayTesting.mdopt-in matrix. - Old value is dead: after step 3, a bind with the old password must fail. Do not test this from a shared-NAT box — GLAuth's 3-fail / 10-minute per-IP lockout can lock the whole office.
Rollback
If dashboard login breaks after step 3, restore the previous passsha256 in
scadaproj/infra/glauth/config.toml, docker compose up -d --force-recreate, and re-point the
deployed hosts' env var / store back to the previous value. Because the deployed hosts were
pre-staged in step 2, the exposure window is only steps 2→4.
Done criteria
- GLAuth on
10.100.0.35honors only the new value. - Every LDAP-enabled deployed host binds with the new value (dashboard login verified).
- The source of truth
scadaproj/infra/glauth/config.tomlcarries the newpasssha256. - No repo file (this one included) contains the old or new value.
- The SEC-36 tracker rows are
Donewith this runbook cited for the operator action.