feat(secrets): source LDAP bind password via ${secret:ldap/mxgateway/bind}; drop plaintext defaults (G-4)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user