feat(secrets): source LDAP bind password via ${secret:ldap/mxgateway/bind}; drop plaintext defaults (G-4)

This commit is contained in:
Joseph Doherty
2026-07-16 10:40:07 -04:00
parent a2538039c0
commit b79e119ada
7 changed files with 113 additions and 11 deletions
+1 -1
View File
@@ -245,7 +245,7 @@ dev/test GLAuth posture (`glauth.md`), not a production posture.
| `MxGateway:Ldap:AllowInsecure` | `true` | Permits a plaintext bind. Must be `true` when `Transport` is `None`; set `false` (with `Ldaps`/`StartTls`) in production. |
| `MxGateway:Ldap:SearchBase` | `dc=zb,dc=local` | Search base DN. |
| `MxGateway:Ldap:ServiceAccountDn` | `cn=serviceaccount,dc=zb,dc=local` | Bind DN for the search account. |
| `MxGateway:Ldap:ServiceAccountPassword` | `serviceaccount123` | Search-account password. **Dev-only committed value.** On the deployed hosts, do not ship this in `appsettings.json`; supply it out-of-band via the env-var override `MxGateway__Ldap__ServiceAccountPassword` (double-underscore form, as set on the NSSM-wrapped services). The committed `serviceaccount123` is a shared dev credential for the local GLAuth instance and should be rotated; production must use a secret it does not share with dev. |
| `MxGateway:Ldap:ServiceAccountPassword` | `${secret:ldap/mxgateway/bind}` | Search-account password. **No longer a committed plaintext value:** `appsettings.json` ships the reference `${secret:ldap/mxgateway/bind}`, which the pre-host `${secret:}` expander resolves at startup from the encrypted secrets store (the code-side design default is now blank, so a missing/unresolved value fails closed rather than falling back to a leaked credential). Seed the value once with `secret set ldap/mxgateway/bind <value>` (the store's master key must be present via `ZB_SECRETS_MASTER_KEY`); startup aborts with `SecretNotFoundException` if the secret is absent. An operator may instead override it directly with the env var `MxGateway__Ldap__ServiceAccountPassword` (double-underscore form) — a plain literal there is used as-is and the secret lookup is skipped. |
| `MxGateway:Ldap:UserNameAttribute` | `cn` | LDAP attribute holding the login user name. |
| `MxGateway:Ldap:DisplayNameAttribute` | `cn` | LDAP attribute holding the display name. |
| `MxGateway:Ldap:GroupAttribute` | `memberOf` | LDAP attribute enumerating group membership (mapped to dashboard roles via `MxGateway:Dashboard:GroupToRole`). |
@@ -58,7 +58,7 @@ public sealed class LdapOptions
public string ServiceAccountDn { get; init; } = "cn=serviceaccount,dc=zb,dc=local";
/// <summary>Gets the service account password.</summary>
public string ServiceAccountPassword { get; init; } = "serviceaccount123";
public string ServiceAccountPassword { get; init; } = string.Empty;
/// <summary>Gets the LDAP attribute name for user names.</summary>
public string UserNameAttribute { get; init; } = "cn";
@@ -32,7 +32,7 @@
"AllowInsecure": true,
"SearchBase": "dc=zb,dc=local",
"ServiceAccountDn": "cn=serviceaccount,dc=zb,dc=local",
"ServiceAccountPassword": "serviceaccount123",
"ServiceAccountPassword": "${secret:ldap/mxgateway/bind}",
"UserNameAttribute": "cn",
"DisplayNameAttribute": "cn",
"GroupAttribute": "memberOf"
@@ -11,7 +11,16 @@ public sealed class GatewayOptionsTests
[Fact]
public void OptionsBinding_UsesDesignDefaults()
{
GatewayOptions options = BindOptions(new Dictionary<string, string?>());
// The LDAP bind password now defaults to blank (sourced from ${secret:ldap/mxgateway/bind}
// at runtime), so enabled-LDAP options fail validation until a value is supplied. Seed one
// so binding validates; the blank design default itself is asserted below.
GatewayOptions options = BindOptions(new Dictionary<string, string?>
{
["MxGateway:Ldap:ServiceAccountPassword"] = "test-bind-password",
});
// The bind password is no longer a leaked plaintext literal; its design default is blank.
Assert.Equal(string.Empty, new LdapOptions().ServiceAccountPassword);
Assert.Equal(AuthenticationMode.ApiKey, options.Authentication.Mode);
// The default is derived from CommonApplicationData (C:\ProgramData on Windows,
@@ -75,7 +84,9 @@ public sealed class GatewayOptionsTests
["MxGateway:Sessions:DefaultLeaseSeconds"] = "900",
["MxGateway:Events:QueueCapacity"] = "256",
["MxGateway:Dashboard:Enabled"] = "false",
["MxGateway:Protocol:MaxGrpcMessageBytes"] = "8388608"
["MxGateway:Protocol:MaxGrpcMessageBytes"] = "8388608",
// Blank-by-default bind password must be supplied so enabled LDAP validates on bind.
["MxGateway:Ldap:ServiceAccountPassword"] = "test-bind-password"
});
Assert.Equal(AuthenticationMode.Disabled, options.Authentication.Mode);
@@ -118,7 +129,9 @@ public sealed class GatewayOptionsTests
using ServiceProvider services = BuildServices(
new Dictionary<string, string?>
{
["MxGateway:Authentication:PepperSecretName"] = "RawPepperSecretName"
["MxGateway:Authentication:PepperSecretName"] = "RawPepperSecretName",
// Blank-by-default bind password must be supplied so enabled LDAP validates on bind.
["MxGateway:Ldap:ServiceAccountPassword"] = "test-bind-password"
});
IGatewayConfigurationProvider provider = services.GetRequiredService<IGatewayConfigurationProvider>();
@@ -6,17 +6,27 @@ namespace ZB.MOM.WW.MxGateway.Tests.Configuration;
public sealed class GatewayOptionsValidatorTests
{
// A non-blank LDAP bind password for tests. The shipped design default is now blank
// (string.Empty) so the secret-sourced value fails closed if unresolved; enabled-LDAP test
// options must therefore supply an explicit password to pass the required-field check.
private const string TestBindPassword = "test-bind-password";
// Constructs the minimal valid GatewayOptions by relying on each sub-option's
// design-default values; those defaults are validated separately in GatewayOptionsTests.
private static GatewayOptions ValidOptions() => new();
// The one exception is the LDAP bind password (blank by design default), which we supply.
private static GatewayOptions ValidOptions() => new() { Ldap = ValidLdapOptions() };
// Enabled LDAP options with all class defaults plus the non-blank bind password.
private static LdapOptions ValidLdapOptions() => new() { ServiceAccountPassword = TestBindPassword };
// Returns enabled LDAP options that pass all checks except Port.
// The class defaults already satisfy the blank-field checks; we only
// override Enabled (must be true to exercise the port check) and Port.
// The class defaults already satisfy the remaining blank-field checks; we override Enabled
// (must be true to exercise the port check), Port, and the now-blank-by-default bind password.
private static LdapOptions LdapOptionsWithPort(int port) => new()
{
Enabled = true,
Port = port,
ServiceAccountPassword = TestBindPassword,
};
private static GatewayOptions CloneWithLdap(GatewayOptions source, LdapOptions ldap)
@@ -667,12 +677,38 @@ public sealed class GatewayOptionsValidatorTests
{
GatewayOptions options = CloneWithLdap(
ValidOptions(),
new LdapOptions { Enabled = true, Transport = LdapTransport.Ldaps, AllowInsecure = false });
new LdapOptions
{
Enabled = true,
Transport = LdapTransport.Ldaps,
AllowInsecure = false,
ServiceAccountPassword = TestBindPassword,
});
ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: true).Validate(null, options);
Assert.True(result.Succeeded);
}
private static GatewayOptions WithSecurity(SecurityOptions security) => new() { Security = security };
/// <summary>
/// Verifies enabled LDAP with a blank <see cref="LdapOptions.ServiceAccountPassword"/> fails
/// validation. Locks in the fail-closed design default (blank) introduced when the bind password
/// moved to the encrypted secrets store (<c>${secret:ldap/mxgateway/bind}</c>): a blanked/unresolved
/// password must abort startup rather than silently binding with an empty credential.
/// </summary>
[Fact]
public void Validate_Fails_WhenLdapEnabledAndServiceAccountPasswordBlank()
{
GatewayOptions options = CloneWithLdap(
ValidOptions(),
new LdapOptions { Enabled = true, ServiceAccountPassword = string.Empty });
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Failed);
Assert.Contains(
result.Failures!,
f => f.Contains("MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled."));
}
private static GatewayOptions WithSecurity(SecurityOptions security)
=> new() { Security = security, Ldap = ValidLdapOptions() };
/// <summary>Verifies the default security options pass validation.</summary>
[Fact]
@@ -32,6 +32,26 @@ public sealed class PreHostSecretExpansionTests
Assert.Equal(plaintext, config["MxGateway:Ldap:ServiceAccountPassword"]);
}
/// <summary>
/// G-4: the shipped <c>appsettings.json</c> sources the LDAP bind password from
/// <c>${secret:ldap/mxgateway/bind}</c>. Proves that exact reference at that exact key resolves to
/// the seeded plaintext through the same pre-host expander the host runs.
/// </summary>
[Fact]
public async Task ExpandConfiguration_LdapBindReference_ResolvesToSeededPassword()
{
using var fixture = SecretsFixture.Create();
const string bindPassword = "seeded-bind-password";
await fixture.SeedAsync("ldap/mxgateway/bind", bindPassword);
IConfigurationRoot config = fixture.BuildConfig(
("MxGateway:Ldap:ServiceAccountPassword", "${secret:ldap/mxgateway/bind}"));
await new SecretReferenceExpander(fixture.Resolver).ExpandConfigurationAsync(config, default);
Assert.Equal(bindPassword, config["MxGateway:Ldap:ServiceAccountPassword"]);
}
[Fact]
public async Task ExpandConfiguration_MissingReference_ThrowsFailClosed()
{
@@ -1,4 +1,5 @@
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
namespace ZB.MOM.WW.MxGateway.Tests.TestSupport;
@@ -51,5 +52,37 @@ internal static class TestHostEnvironmentInitializer
"gateway-selfsigned.pfx");
Environment.SetEnvironmentVariable("MxGateway__Tls__SelfSignedCertPath", certPath);
}
// Host-building tests must not depend on a seeded secret store. The shipped appsettings.json
// sources the LDAP bind password from ${secret:ldap/mxgateway/bind}, which the pre-host
// expander in GatewayApplication.CreateBuilder resolves before the host is built; with no
// seeded store (as on CI) that resolution fails closed with SecretNotFoundException. Supplying
// the bind password as an environment override — the same supported way an operator can — makes
// config["MxGateway:Ldap:ServiceAccountPassword"] a plain literal (the env provider outranks the
// JSON provider), so the expander sees no ${secret:} prefix and skips it. No store/seed needed.
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("MxGateway__Ldap__ServiceAccountPassword")))
{
Environment.SetEnvironmentVariable("MxGateway__Ldap__ServiceAccountPassword", "test-bind-password");
}
// The runtime AddZbSecrets registration still runs SqliteSecretsStoreMigrator.MigrateAsync on
// startup, which needs a valid master key and a writable store path. Point the store at a
// per-process temp file (not the test working dir) and supply a throwaway base64 32-byte key so
// migration succeeds without touching any real/shared secrets store.
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ZB_SECRETS_MASTER_KEY")))
{
Environment.SetEnvironmentVariable(
"ZB_SECRETS_MASTER_KEY",
Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)));
}
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("Secrets__SqlitePath")))
{
string secretsPath = Path.Combine(
Path.GetTempPath(),
$"mxgw-tests-{Environment.ProcessId}",
"secrets.db");
Environment.SetEnvironmentVariable("Secrets__SqlitePath", secretsPath);
}
}
}