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; }
}