feat(secrets): source LDAP bind password via ${secret:ldap/mxgateway/bind}; drop plaintext defaults (G-4)
This commit is contained in:
@@ -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: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: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: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:UserNameAttribute` | `cn` | LDAP attribute holding the login user name. |
|
||||||
| `MxGateway:Ldap:DisplayNameAttribute` | `cn` | LDAP attribute holding the display 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`). |
|
| `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";
|
public string ServiceAccountDn { get; init; } = "cn=serviceaccount,dc=zb,dc=local";
|
||||||
|
|
||||||
/// <summary>Gets the service account password.</summary>
|
/// <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>
|
/// <summary>Gets the LDAP attribute name for user names.</summary>
|
||||||
public string UserNameAttribute { get; init; } = "cn";
|
public string UserNameAttribute { get; init; } = "cn";
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"AllowInsecure": true,
|
"AllowInsecure": true,
|
||||||
"SearchBase": "dc=zb,dc=local",
|
"SearchBase": "dc=zb,dc=local",
|
||||||
"ServiceAccountDn": "cn=serviceaccount,dc=zb,dc=local",
|
"ServiceAccountDn": "cn=serviceaccount,dc=zb,dc=local",
|
||||||
"ServiceAccountPassword": "serviceaccount123",
|
"ServiceAccountPassword": "${secret:ldap/mxgateway/bind}",
|
||||||
"UserNameAttribute": "cn",
|
"UserNameAttribute": "cn",
|
||||||
"DisplayNameAttribute": "cn",
|
"DisplayNameAttribute": "cn",
|
||||||
"GroupAttribute": "memberOf"
|
"GroupAttribute": "memberOf"
|
||||||
|
|||||||
@@ -11,7 +11,16 @@ public sealed class GatewayOptionsTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void OptionsBinding_UsesDesignDefaults()
|
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);
|
Assert.Equal(AuthenticationMode.ApiKey, options.Authentication.Mode);
|
||||||
// The default is derived from CommonApplicationData (C:\ProgramData on Windows,
|
// The default is derived from CommonApplicationData (C:\ProgramData on Windows,
|
||||||
@@ -75,7 +84,9 @@ public sealed class GatewayOptionsTests
|
|||||||
["MxGateway:Sessions:DefaultLeaseSeconds"] = "900",
|
["MxGateway:Sessions:DefaultLeaseSeconds"] = "900",
|
||||||
["MxGateway:Events:QueueCapacity"] = "256",
|
["MxGateway:Events:QueueCapacity"] = "256",
|
||||||
["MxGateway:Dashboard:Enabled"] = "false",
|
["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);
|
Assert.Equal(AuthenticationMode.Disabled, options.Authentication.Mode);
|
||||||
@@ -118,7 +129,9 @@ public sealed class GatewayOptionsTests
|
|||||||
using ServiceProvider services = BuildServices(
|
using ServiceProvider services = BuildServices(
|
||||||
new Dictionary<string, string?>
|
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>();
|
IGatewayConfigurationProvider provider = services.GetRequiredService<IGatewayConfigurationProvider>();
|
||||||
|
|||||||
@@ -6,17 +6,27 @@ namespace ZB.MOM.WW.MxGateway.Tests.Configuration;
|
|||||||
|
|
||||||
public sealed class GatewayOptionsValidatorTests
|
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
|
// Constructs the minimal valid GatewayOptions by relying on each sub-option's
|
||||||
// design-default values; those defaults are validated separately in GatewayOptionsTests.
|
// 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.
|
// Returns enabled LDAP options that pass all checks except Port.
|
||||||
// The class defaults already satisfy the blank-field checks; we only
|
// The class defaults already satisfy the remaining blank-field checks; we override Enabled
|
||||||
// override Enabled (must be true to exercise the port check) and Port.
|
// (must be true to exercise the port check), Port, and the now-blank-by-default bind password.
|
||||||
private static LdapOptions LdapOptionsWithPort(int port) => new()
|
private static LdapOptions LdapOptionsWithPort(int port) => new()
|
||||||
{
|
{
|
||||||
Enabled = true,
|
Enabled = true,
|
||||||
Port = port,
|
Port = port,
|
||||||
|
ServiceAccountPassword = TestBindPassword,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static GatewayOptions CloneWithLdap(GatewayOptions source, LdapOptions ldap)
|
private static GatewayOptions CloneWithLdap(GatewayOptions source, LdapOptions ldap)
|
||||||
@@ -667,12 +677,38 @@ public sealed class GatewayOptionsValidatorTests
|
|||||||
{
|
{
|
||||||
GatewayOptions options = CloneWithLdap(
|
GatewayOptions options = CloneWithLdap(
|
||||||
ValidOptions(),
|
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);
|
ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: true).Validate(null, options);
|
||||||
Assert.True(result.Succeeded);
|
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>
|
/// <summary>Verifies the default security options pass validation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -32,6 +32,26 @@ public sealed class PreHostSecretExpansionTests
|
|||||||
Assert.Equal(plaintext, config["MxGateway:Ldap:ServiceAccountPassword"]);
|
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]
|
[Fact]
|
||||||
public async Task ExpandConfiguration_MissingReference_ThrowsFailClosed()
|
public async Task ExpandConfiguration_MissingReference_ThrowsFailClosed()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
namespace ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
||||||
|
|
||||||
@@ -51,5 +52,37 @@ internal static class TestHostEnvironmentInitializer
|
|||||||
"gateway-selfsigned.pfx");
|
"gateway-selfsigned.pfx");
|
||||||
Environment.SetEnvironmentVariable("MxGateway__Tls__SelfSignedCertPath", certPath);
|
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