feat(secrets): pre-host ${secret:} config expansion in GatewayApplication.CreateBuilder (G-4 mechanism)
This commit is contained in:
@@ -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.Security.Authorization;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Workers;
|
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;
|
||||||
using ZB.MOM.WW.Telemetry.Serilog;
|
using ZB.MOM.WW.Telemetry.Serilog;
|
||||||
|
|
||||||
@@ -64,6 +68,26 @@ public static class GatewayApplication
|
|||||||
});
|
});
|
||||||
StaticWebAssetsLoader.UseStaticWebAssets(builder.Environment, builder.Configuration);
|
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<SqliteSecretsStoreMigrator>()
|
||||||
|
.MigrateAsync(default).GetAwaiter().GetResult();
|
||||||
|
var resolver = secretsProvider.GetRequiredService<ISecretResolver>();
|
||||||
|
new SecretReferenceExpander(resolver)
|
||||||
|
.ExpandConfigurationAsync((IConfigurationRoot)builder.Configuration, default)
|
||||||
|
.GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
ConfigureSelfSignedTls(builder);
|
ConfigureSelfSignedTls(builder);
|
||||||
|
|
||||||
builder.AddZbSerilog(o => o.ServiceName = "mxgateway");
|
builder.AddZbSerilog(o => o.ServiceName = "mxgateway");
|
||||||
|
|||||||
@@ -11,6 +11,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
|
"Secrets": {
|
||||||
|
"SqlitePath": "mxgateway-secrets.db",
|
||||||
|
"MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" },
|
||||||
|
"RunMigrationsOnStartup": true,
|
||||||
|
"ResolveCacheTtl": "00:00:30"
|
||||||
|
},
|
||||||
"MxGateway": {
|
"MxGateway": {
|
||||||
"Authentication": {
|
"Authentication": {
|
||||||
"Mode": "ApiKey",
|
"Mode": "ApiKey",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exercises the exact pre-host <c>${secret:}</c> expansion flow that
|
||||||
|
/// <see cref="ZB.MOM.WW.MxGateway.Server.GatewayApplication.CreateBuilder"/> runs before the host is
|
||||||
|
/// built: a throwaway <c>AddZbSecrets</c> container migrates the SQLite store, then a
|
||||||
|
/// <see cref="SecretReferenceExpander"/> rewrites configuration tokens in place. A seeded reference
|
||||||
|
/// resolves to its plaintext; a reference to an absent secret is fail-closed
|
||||||
|
/// (<see cref="SecretNotFoundException"/>).
|
||||||
|
/// </summary>
|
||||||
|
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<SecretNotFoundException>(
|
||||||
|
() => new SecretReferenceExpander(fixture.Resolver).ExpandConfigurationAsync(config, default));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <c>AddZbSecrets</c> DI graph the
|
||||||
|
/// host uses. Disposal removes the temp file and clears the env var.
|
||||||
|
/// </summary>
|
||||||
|
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<ISecretResolver>();
|
||||||
|
|
||||||
|
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<string, string?>
|
||||||
|
{
|
||||||
|
["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<SqliteSecretsStoreMigrator>().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<ISecretCipher>();
|
||||||
|
ISecretStore store = _provider.GetRequiredService<ISecretStore>();
|
||||||
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user