diff --git a/docs/GatewayConfiguration.md b/docs/GatewayConfiguration.md index 1cbff3e..9ed343b 100644 --- a/docs/GatewayConfiguration.md +++ b/docs/GatewayConfiguration.md @@ -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 ` (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`). | diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/LdapOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/LdapOptions.cs index a367bb4..ebfb9e3 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/LdapOptions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/LdapOptions.cs @@ -58,7 +58,7 @@ public sealed class LdapOptions public string ServiceAccountDn { get; init; } = "cn=serviceaccount,dc=zb,dc=local"; /// Gets the service account password. - public string ServiceAccountPassword { get; init; } = "serviceaccount123"; + public string ServiceAccountPassword { get; init; } = string.Empty; /// Gets the LDAP attribute name for user names. public string UserNameAttribute { get; init; } = "cn"; diff --git a/src/ZB.MOM.WW.MxGateway.Server/appsettings.json b/src/ZB.MOM.WW.MxGateway.Server/appsettings.json index 8c3a060..c8eb697 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/appsettings.json +++ b/src/ZB.MOM.WW.MxGateway.Server/appsettings.json @@ -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" diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsTests.cs index 94bf920..e05e93b 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsTests.cs @@ -11,7 +11,16 @@ public sealed class GatewayOptionsTests [Fact] public void OptionsBinding_UsesDesignDefaults() { - GatewayOptions options = BindOptions(new Dictionary()); + // 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 + { + ["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 { - ["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(); diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs index 57c60bb..07544d0 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs @@ -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 }; + /// + /// Verifies enabled LDAP with a blank fails + /// validation. Locks in the fail-closed design default (blank) introduced when the bind password + /// moved to the encrypted secrets store (${secret:ldap/mxgateway/bind}): a blanked/unresolved + /// password must abort startup rather than silently binding with an empty credential. + /// + [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() }; /// Verifies the default security options pass validation. [Fact] diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/PreHostSecretExpansionTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/PreHostSecretExpansionTests.cs index 8b37a66..95725ae 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/PreHostSecretExpansionTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/PreHostSecretExpansionTests.cs @@ -32,6 +32,26 @@ public sealed class PreHostSecretExpansionTests Assert.Equal(plaintext, config["MxGateway:Ldap:ServiceAccountPassword"]); } + /// + /// G-4: the shipped appsettings.json sources the LDAP bind password from + /// ${secret:ldap/mxgateway/bind}. Proves that exact reference at that exact key resolves to + /// the seeded plaintext through the same pre-host expander the host runs. + /// + [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() { diff --git a/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/TestHostEnvironmentInitializer.cs b/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/TestHostEnvironmentInitializer.cs index 300f933..716cb61 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/TestHostEnvironmentInitializer.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/TestHostEnvironmentInitializer.cs @@ -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); + } } }