diff --git a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs index ea38dec..5c08042 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs @@ -15,6 +15,10 @@ using ZB.MOM.WW.MxGateway.Server.Security.Authentication; using ZB.MOM.WW.MxGateway.Server.Security.Authorization; using ZB.MOM.WW.MxGateway.Server.Sessions; using ZB.MOM.WW.MxGateway.Server.Workers; +using ZB.MOM.WW.Secrets.Abstractions; +using ZB.MOM.WW.Secrets.Configuration; +using ZB.MOM.WW.Secrets.DependencyInjection; +using ZB.MOM.WW.Secrets.Sqlite; using ZB.MOM.WW.Telemetry; using ZB.MOM.WW.Telemetry.Serilog; @@ -64,6 +68,26 @@ public static class GatewayApplication }); StaticWebAssetsLoader.UseStaticWebAssets(builder.Environment, builder.Configuration); + // Resolve ${secret:...} references in configuration BEFORE any config consumer (TLS, Kestrel, + // GatewayOptions/Ldap/Galaxy validators) reads a value, using a standalone secrets provider + // (envelope-decrypted via the master key). A token referencing a missing secret fails fast + // here (SecretNotFoundException); config with no tokens is untouched (no-op), so this is safe + // to always run. CreateBuilder is synchronous and single-shot at bootstrap, so the two awaits + // are driven via GetAwaiter().GetResult() (no sync-context deadlock risk during host startup). +#pragma warning disable ASP0000 // deliberate throwaway container, disposed here, shares no singletons + using (var secretsProvider = new ServiceCollection() + .AddZbSecrets(builder.Configuration, "Secrets") + .BuildServiceProvider()) +#pragma warning restore ASP0000 + { + secretsProvider.GetRequiredService() + .MigrateAsync(default).GetAwaiter().GetResult(); + var resolver = secretsProvider.GetRequiredService(); + new SecretReferenceExpander(resolver) + .ExpandConfigurationAsync((IConfigurationRoot)builder.Configuration, default) + .GetAwaiter().GetResult(); + } + ConfigureSelfSignedTls(builder); builder.AddZbSerilog(o => o.ServiceName = "mxgateway"); diff --git a/src/ZB.MOM.WW.MxGateway.Server/appsettings.json b/src/ZB.MOM.WW.MxGateway.Server/appsettings.json index 7623e47..8c3a060 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/appsettings.json +++ b/src/ZB.MOM.WW.MxGateway.Server/appsettings.json @@ -11,6 +11,12 @@ ] }, "AllowedHosts": "*", + "Secrets": { + "SqlitePath": "mxgateway-secrets.db", + "MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" }, + "RunMigrationsOnStartup": true, + "ResolveCacheTtl": "00:00:30" + }, "MxGateway": { "Authentication": { "Mode": "ApiKey", diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/PreHostSecretExpansionTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/PreHostSecretExpansionTests.cs new file mode 100644 index 0000000..8b37a66 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/PreHostSecretExpansionTests.cs @@ -0,0 +1,135 @@ +using System.Security.Cryptography; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using ZB.MOM.WW.Secrets.Abstractions; +using ZB.MOM.WW.Secrets.Configuration; +using ZB.MOM.WW.Secrets.DependencyInjection; +using ZB.MOM.WW.Secrets.Sqlite; + +namespace ZB.MOM.WW.MxGateway.Tests.Configuration; + +/// +/// Exercises the exact pre-host ${secret:} expansion flow that +/// runs before the host is +/// built: a throwaway AddZbSecrets container migrates the SQLite store, then a +/// rewrites configuration tokens in place. A seeded reference +/// resolves to its plaintext; a reference to an absent secret is fail-closed +/// (). +/// +public sealed class PreHostSecretExpansionTests +{ + [Fact] + public async Task ExpandConfiguration_SeededReference_ResolvesToPlaintext() + { + using var fixture = SecretsFixture.Create(); + const string plaintext = "s3cr3t-plaintext-value"; + await fixture.SeedAsync("test/value", plaintext); + + IConfigurationRoot config = fixture.BuildConfig(("MxGateway:Ldap:ServiceAccountPassword", "${secret:test/value}")); + + await new SecretReferenceExpander(fixture.Resolver).ExpandConfigurationAsync(config, default); + + Assert.Equal(plaintext, config["MxGateway:Ldap:ServiceAccountPassword"]); + } + + [Fact] + public async Task ExpandConfiguration_MissingReference_ThrowsFailClosed() + { + using var fixture = SecretsFixture.Create(); + + IConfigurationRoot config = fixture.BuildConfig(("MxGateway:Ldap:ServiceAccountPassword", "${secret:missing}")); + + await Assert.ThrowsAsync( + () => new SecretReferenceExpander(fixture.Resolver).ExpandConfigurationAsync(config, default)); + } + + /// + /// A self-contained secrets store: a unique temp SQLite path + a per-test master-key env var + /// (so parallel tests never collide), wired through the real AddZbSecrets DI graph the + /// host uses. Disposal removes the temp file and clears the env var. + /// + private sealed class SecretsFixture : IDisposable + { + private readonly ServiceProvider _provider; + private readonly string _sqlitePath; + private readonly string _envVarName; + + private SecretsFixture(ServiceProvider provider, string sqlitePath, string envVarName) + { + _provider = provider; + _sqlitePath = sqlitePath; + _envVarName = envVarName; + } + + public ISecretResolver Resolver => _provider.GetRequiredService(); + + public static SecretsFixture Create() + { + string unique = Guid.NewGuid().ToString("N"); + string sqlitePath = Path.Combine(Path.GetTempPath(), $"mxgw-secrets-{unique}.db"); + string envVarName = $"ZB_SECRETS_MASTER_KEY_TEST_{unique}"; + Environment.SetEnvironmentVariable(envVarName, Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))); + + IConfigurationRoot secretsConfig = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Secrets:SqlitePath"] = sqlitePath, + ["Secrets:MasterKey:Source"] = "Environment", + ["Secrets:MasterKey:EnvVarName"] = envVarName, + ["Secrets:RunMigrationsOnStartup"] = "true", + ["Secrets:ResolveCacheTtl"] = "00:00:30", + }) + .Build(); + + ServiceProvider provider = new ServiceCollection() + .AddZbSecrets(secretsConfig, "Secrets") + .BuildServiceProvider(); + + // Mirror the pre-host path: migrate the store explicitly before the first resolve. + provider.GetRequiredService().MigrateAsync(default).GetAwaiter().GetResult(); + + return new SecretsFixture(provider, sqlitePath, envVarName); + } + + // Seals a plaintext value under a fresh DEK and upserts it — the same store/cipher pair the + // CLI 'secret set' verb uses. + public async Task SeedAsync(string name, string plaintext) + { + var secretName = new SecretName(name); + ISecretCipher cipher = _provider.GetRequiredService(); + ISecretStore store = _provider.GetRequiredService(); + StoredSecret sealed_ = cipher.Encrypt(secretName, plaintext, SecretContentType.Text); + await store.UpsertAsync(sealed_, default); + } + + public IConfigurationRoot BuildConfig(params (string Key, string Value)[] entries) => + new ConfigurationBuilder() + .AddInMemoryCollection(entries.ToDictionary(e => e.Key, e => (string?)e.Value)) + .Build(); + + public void Dispose() + { + _provider.Dispose(); + Environment.SetEnvironmentVariable(_envVarName, null); + + // The SQLite store runs in WAL mode with connection pooling, so a pooled handle can + // outlive _provider.Dispose() and keep the .db (plus its -wal/-shm sidecars) open. Clear + // the pool first, then delete all three files so no temp artifact leaks between tests. + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + foreach (string path in new[] { _sqlitePath, _sqlitePath + "-wal", _sqlitePath + "-shm" }) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (IOException) + { + // Best-effort cleanup of the temp store; a locked file must not fail the test. + } + } + } + } +}