feat(secrets): SecretsOptions + AddZbSecrets DI + migration hosted service
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.Secrets.DependencyInjection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The single-node default <see cref="ISecretReplicator"/>: publishing a row is a no-op because
|
||||||
|
/// there are no peers to broadcast to. The deferred <c>ZB.MOM.WW.Secrets.Akka</c> package replaces
|
||||||
|
/// this registration with a real cluster replicator for clustered deployments.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NoOpSecretReplicator : ISecretReplicator
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task PublishAsync(StoredSecret row, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.Secrets.Sqlite;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.Secrets.DependencyInjection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the secrets SQLite schema migration at application startup when
|
||||||
|
/// <see cref="SecretsOptions.RunMigrationsOnStartup"/> is <see langword="true"/>. The migration is
|
||||||
|
/// idempotent, so repeated restarts are safe.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class SecretsMigrationHostedService(
|
||||||
|
SqliteSecretsStoreMigrator migrator,
|
||||||
|
IOptions<SecretsOptions> options) : IHostedService
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (options.Value.RunMigrationsOnStartup)
|
||||||
|
{
|
||||||
|
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
}
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
using ZB.MOM.WW.Secrets.Crypto;
|
||||||
|
using ZB.MOM.WW.Secrets.MasterKey;
|
||||||
|
using ZB.MOM.WW.Secrets.Sqlite;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.Secrets.DependencyInjection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dependency-injection helpers that wire the ZB.MOM.WW secrets subsystem — master-key provider,
|
||||||
|
/// envelope cipher, SQLite store, resolver, no-op replicator, and the startup migration hosted
|
||||||
|
/// service — from configuration with a single call.
|
||||||
|
/// </summary>
|
||||||
|
public static class SecretsServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the secrets subsystem: binds <see cref="SecretsOptions"/> from the configuration
|
||||||
|
/// section at <paramref name="sectionPath"/> and wires the master-key provider, envelope cipher,
|
||||||
|
/// SQLite-backed store, the caching resolver, the single-node no-op replicator, and a hosted
|
||||||
|
/// service that runs the schema migration on startup (when enabled).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="services">The service collection to add to.</param>
|
||||||
|
/// <param name="config">The application configuration.</param>
|
||||||
|
/// <param name="sectionPath">Path of the configuration section holding the secrets options.</param>
|
||||||
|
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
|
||||||
|
public static IServiceCollection AddZbSecrets(
|
||||||
|
this IServiceCollection services,
|
||||||
|
IConfiguration config,
|
||||||
|
string sectionPath)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
ArgumentNullException.ThrowIfNull(config);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath);
|
||||||
|
|
||||||
|
services.Configure<SecretsOptions>(config.GetSection(sectionPath));
|
||||||
|
|
||||||
|
// One connection factory targets the configured SQLite path. Singleton: it is stateless
|
||||||
|
// aside from the path and opens a fresh connection per operation.
|
||||||
|
services.TryAddSingleton(sp =>
|
||||||
|
new SecretsSqliteConnectionFactory(
|
||||||
|
sp.GetRequiredService<IOptions<SecretsOptions>>().Value.SqlitePath));
|
||||||
|
|
||||||
|
services.TryAddSingleton<IMasterKeyProvider>(sp =>
|
||||||
|
MasterKeyProviderFactory.Create(
|
||||||
|
sp.GetRequiredService<IOptions<SecretsOptions>>().Value.MasterKey));
|
||||||
|
|
||||||
|
services.TryAddSingleton<ISecretCipher>(sp =>
|
||||||
|
new AesGcmEnvelopeCipher(sp.GetRequiredService<IMasterKeyProvider>()));
|
||||||
|
|
||||||
|
services.TryAddSingleton<ISecretStore>(sp =>
|
||||||
|
new SqliteSecretStore(sp.GetRequiredService<SecretsSqliteConnectionFactory>()));
|
||||||
|
|
||||||
|
services.TryAddSingleton<ISecretReplicator, NoOpSecretReplicator>();
|
||||||
|
|
||||||
|
services.TryAddSingleton<ISecretResolver>(sp =>
|
||||||
|
{
|
||||||
|
// Resolve the audit writer LAZILY so this picks up the app's AddZbAudit registration
|
||||||
|
// regardless of call order; fall back to a no-op writer when the app registers none.
|
||||||
|
IAuditWriter audit = sp.GetService<IAuditWriter>() ?? NoOpAuditWriter.Instance;
|
||||||
|
ISecretActorAccessor? actorAccessor = sp.GetService<ISecretActorAccessor>();
|
||||||
|
TimeProvider? timeProvider = sp.GetService<TimeProvider>();
|
||||||
|
TimeSpan ttl = sp.GetRequiredService<IOptions<SecretsOptions>>().Value.ResolveCacheTtl;
|
||||||
|
|
||||||
|
return new DefaultSecretResolver(
|
||||||
|
sp.GetRequiredService<ISecretStore>(),
|
||||||
|
sp.GetRequiredService<ISecretCipher>(),
|
||||||
|
audit,
|
||||||
|
ttl,
|
||||||
|
actorAccessor,
|
||||||
|
timeProvider);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Migrator: singleton, constructed from the already-registered connection factory. Needed
|
||||||
|
// before any store operations so the schema exists.
|
||||||
|
services.TryAddSingleton(sp =>
|
||||||
|
new SqliteSecretsStoreMigrator(sp.GetRequiredService<SecretsSqliteConnectionFactory>()));
|
||||||
|
|
||||||
|
// Hosted service that runs migrations on startup when SecretsOptions.RunMigrationsOnStartup.
|
||||||
|
services.AddHostedService<SecretsMigrationHostedService>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using ZB.MOM.WW.Secrets.MasterKey;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.Secrets;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Application-level configuration for the ZB.MOM.WW secrets subsystem: where the SQLite store
|
||||||
|
/// lives, how the master key (KEK) is resolved, whether the schema migration runs on startup,
|
||||||
|
/// how long decrypted values are cached, and whether the UI reveal path is enabled. Bound from a
|
||||||
|
/// configuration section by <see cref="DependencyInjection.SecretsServiceCollectionExtensions.AddZbSecrets"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SecretsOptions
|
||||||
|
{
|
||||||
|
/// <summary>The path to the SQLite database backing the secret store. Defaults to <c>secrets.db</c>.</summary>
|
||||||
|
public string SqlitePath { get; set; } = "secrets.db";
|
||||||
|
|
||||||
|
/// <summary>The master-key (KEK) resolution configuration. Defaults to a fresh <see cref="MasterKeyOptions"/>.</summary>
|
||||||
|
public MasterKeyOptions MasterKey { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When <see langword="true"/> (the default), the SQLite schema migration runs at application
|
||||||
|
/// startup via the hosted service. The migration is idempotent, so repeated restarts are safe.
|
||||||
|
/// </summary>
|
||||||
|
public bool RunMigrationsOnStartup { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How long a decrypted secret value is cached in process memory by the resolver. Kept short by
|
||||||
|
/// default (30 seconds) because the cache holds plaintext secret material.
|
||||||
|
/// </summary>
|
||||||
|
public TimeSpan ResolveCacheTtl { get; set; } = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opt-in switch for the UI reveal path (consumed by the deferred <c>ZB.MOM.WW.Secrets.Ui</c>
|
||||||
|
/// package). Defaults to <see langword="false"/> — secrets are write-only from the UI unless a
|
||||||
|
/// deployment explicitly opts in.
|
||||||
|
/// </summary>
|
||||||
|
public bool RevealEnabled { get; set; }
|
||||||
|
}
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
using ZB.MOM.WW.Secrets.DependencyInjection;
|
||||||
|
using ZB.MOM.WW.Secrets.Sqlite;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.Secrets.Tests.DependencyInjection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that <see cref="SecretsServiceCollectionExtensions.AddZbSecrets"/> wires the whole
|
||||||
|
/// core: every seam resolves, a real secret round-trips through the composed graph, and a missing
|
||||||
|
/// master key fails closed.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AddZbSecretsTests
|
||||||
|
{
|
||||||
|
private static string NewTempDbPath() =>
|
||||||
|
Path.Combine(Path.GetTempPath(), $"zb-secrets-di-{Guid.NewGuid():N}.db");
|
||||||
|
|
||||||
|
private static string Base64Key32() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
|
|
||||||
|
private static IConfiguration BuildConfig(string sqlitePath, string envVarName) =>
|
||||||
|
new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Secrets:SqlitePath"] = sqlitePath,
|
||||||
|
["Secrets:MasterKey:Source"] = "Environment",
|
||||||
|
["Secrets:MasterKey:EnvVarName"] = envVarName,
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Registers_All_CoreServices()
|
||||||
|
{
|
||||||
|
string dbPath = NewTempDbPath();
|
||||||
|
string envVar = $"ZB_TEST_KEY_{Guid.NewGuid():N}";
|
||||||
|
Environment.SetEnvironmentVariable(envVar, Base64Key32());
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddZbSecrets(BuildConfig(dbPath, envVar), "Secrets");
|
||||||
|
using ServiceProvider provider = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
Assert.NotNull(provider.GetService<ISecretResolver>());
|
||||||
|
Assert.NotNull(provider.GetService<ISecretStore>());
|
||||||
|
Assert.NotNull(provider.GetService<IMasterKeyProvider>());
|
||||||
|
Assert.NotNull(provider.GetService<ISecretCipher>());
|
||||||
|
|
||||||
|
ISecretReplicator? replicator = provider.GetService<ISecretReplicator>();
|
||||||
|
Assert.NotNull(replicator);
|
||||||
|
Assert.IsType<NoOpSecretReplicator>(replicator);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable(envVar, null);
|
||||||
|
File.Delete(dbPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolver_Works_EndToEnd_ThroughDi()
|
||||||
|
{
|
||||||
|
string dbPath = NewTempDbPath();
|
||||||
|
string envVar = $"ZB_TEST_KEY_{Guid.NewGuid():N}";
|
||||||
|
Environment.SetEnvironmentVariable(envVar, Base64Key32());
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddZbSecrets(BuildConfig(dbPath, envVar), "Secrets");
|
||||||
|
using ServiceProvider provider = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
// Run the migration first so the schema exists (as the hosted service would on startup).
|
||||||
|
await provider.GetRequiredService<SqliteSecretsStoreMigrator>().MigrateAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var name = new SecretName("db/password");
|
||||||
|
const string plaintext = "super-secret-value";
|
||||||
|
|
||||||
|
ISecretCipher cipher = provider.GetRequiredService<ISecretCipher>();
|
||||||
|
ISecretStore store = provider.GetRequiredService<ISecretStore>();
|
||||||
|
StoredSecret row = cipher.Encrypt(name, plaintext, SecretContentType.Text);
|
||||||
|
await store.UpsertAsync(row, CancellationToken.None);
|
||||||
|
|
||||||
|
ISecretResolver resolver = provider.GetRequiredService<ISecretResolver>();
|
||||||
|
string? resolved = await resolver.GetAsync(name, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(plaintext, resolved);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable(envVar, null);
|
||||||
|
File.Delete(dbPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MasterKey_Unavailable_FailsClosed()
|
||||||
|
{
|
||||||
|
string dbPath = NewTempDbPath();
|
||||||
|
string envVar = $"ZB_TEST_KEY_{Guid.NewGuid():N}";
|
||||||
|
// Deliberately do NOT set the env var — the master key cannot be resolved.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddZbSecrets(BuildConfig(dbPath, envVar), "Secrets");
|
||||||
|
using ServiceProvider provider = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
IMasterKeyProvider keyProvider = provider.GetRequiredService<IMasterKeyProvider>();
|
||||||
|
|
||||||
|
Assert.Throws<MasterKeyUnavailableException>(() => keyProvider.GetMasterKey());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(dbPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
<PackageReference Include="xunit" />
|
<PackageReference Include="xunit" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" />
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
<PackageReference Include="Xunit.SkippableFact" />
|
<PackageReference Include="Xunit.SkippableFact" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
Reference in New Issue
Block a user