feat(secrets): SecretsOptions + AddZbSecrets DI + migration hosted service

This commit is contained in:
Joseph Doherty
2026-07-15 17:05:30 -04:00
parent 8c4175d8ce
commit 7f1db1b214
6 changed files with 283 additions and 0 deletions
@@ -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;
}
@@ -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;
}
@@ -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; }
}
@@ -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.runner.visualstudio" />
<PackageReference Include="Xunit.SkippableFact" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
</ItemGroup>
<ItemGroup>