fix(SEC-36): scrub committed dev LDAP service-account password; add user-secrets channel + rotation runbook

Repo-side half of SEC-36. The appsettings.json plaintext was already discharged
before this branch (HEAD ships the fail-closed ${secret:ldap/mxgateway/bind}
store reference), so the residual leak was the literal value in glauth.md,
docs/GatewayTesting.md, and the historical archreview SEC-06 evidence -- all
scrubbed to <service-account-password> placeholders pointing at the source of
truth scadaproj/infra/glauth/.

- csproj: add <UserSecretsId>mxaccessgw-server</UserSecretsId> (dev channel)
- GatewayOptionsValidator: blank-password message now names both channels
  (dev user-secrets, deployed MxGateway__Ldap__ServiceAccountPassword)
- test: assert the message names both channels
- docs: GatewayConfiguration.md (three channels + rotation note), glauth.md
  (placeholders + rotation-required + runbook pointer), GatewayTesting.md
- new operator runbook docs/runbooks/SEC-36-ldap-credential-rotation.md
  (live rotation + NSSM staging remain operator-pending)
- tracking: SEC-36 -> Done (repo-side) in both registers + change-log

Deviation: kept the ${secret:} reference in appsettings.json rather than
deleting it (spec step 2 assumed the stale plaintext baseline); deleting it
would regress the shipped/documented/tested secret-store channel.

git grep -i for the old value is empty across all tracked files.
This commit is contained in:
Joseph Doherty
2026-08-07 07:40:41 -04:00
parent 10534ec906
commit 8c312c717c
11 changed files with 163 additions and 16 deletions
@@ -145,7 +145,12 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
builder);
AddIfBlank(
options.ServiceAccountPassword,
"MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled.",
"MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled. "
+ "Never commit it: on dev boxes set user-secrets "
+ "(dotnet user-secrets set \"MxGateway:Ldap:ServiceAccountPassword\" <value>); "
+ "on deployed hosts set the environment variable "
+ "MxGateway__Ldap__ServiceAccountPassword. "
+ "(appsettings.json ships the ${secret:ldap/mxgateway/bind} store reference as the default.)",
builder);
AddIfBlank(
options.UserNameAttribute,
@@ -2,6 +2,10 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<!-- Dev-box channel for MxGateway:Ldap:ServiceAccountPassword (SEC-36): user-secrets
are loaded automatically in the Development environment and live under the user
profile, outside the tree, so the shared GLAuth bind credential is never committed. -->
<UserSecretsId>mxaccessgw-server</UserSecretsId>
</PropertyGroup>
<ItemGroup>
@@ -757,9 +757,15 @@ public sealed class GatewayOptionsValidatorTests
new LdapOptions { Enabled = true, ServiceAccountPassword = string.Empty });
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Failed);
Assert.Contains(
string failure = Assert.Single(
result.Failures!,
f => f.Contains("MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled."));
// SEC-36: the message must steer the operator to the two supported out-of-band channels
// (dev user-secrets, deployed env var) so a blanked/unresolved credential never gets
// "fixed" by re-committing a value.
Assert.Contains("dotnet user-secrets set", failure);
Assert.Contains("MxGateway__Ldap__ServiceAccountPassword", failure);
}
private static GatewayOptions WithSecurity(SecurityOptions security)