From 7f1db1b214bb748061767024b7094a2ba88cbbfb Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 17:05:30 -0400 Subject: [PATCH] feat(secrets): SecretsOptions + AddZbSecrets DI + migration hosted service --- .../NoOpSecretReplicator.cs | 14 +++ .../SecretsMigrationHostedService.cs | 27 ++++ .../SecretsServiceCollectionExtensions.cs | 87 +++++++++++++ .../src/ZB.MOM.WW.Secrets/SecretsOptions.cs | 37 ++++++ .../DependencyInjection/AddZbSecretsTests.cs | 116 ++++++++++++++++++ .../ZB.MOM.WW.Secrets.Tests.csproj | 2 + 6 files changed, 283 insertions(+) create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/NoOpSecretReplicator.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsMigrationHostedService.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/SecretsOptions.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DependencyInjection/AddZbSecretsTests.cs diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/NoOpSecretReplicator.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/NoOpSecretReplicator.cs new file mode 100644 index 0000000..04cfe87 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/NoOpSecretReplicator.cs @@ -0,0 +1,14 @@ +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.Secrets.DependencyInjection; + +/// +/// The single-node default : publishing a row is a no-op because +/// there are no peers to broadcast to. The deferred ZB.MOM.WW.Secrets.Akka package replaces +/// this registration with a real cluster replicator for clustered deployments. +/// +public sealed class NoOpSecretReplicator : ISecretReplicator +{ + /// + public Task PublishAsync(StoredSecret row, CancellationToken ct) => Task.CompletedTask; +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsMigrationHostedService.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsMigrationHostedService.cs new file mode 100644 index 0000000..bf58beb --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsMigrationHostedService.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.Secrets.Sqlite; + +namespace ZB.MOM.WW.Secrets.DependencyInjection; + +/// +/// Runs the secrets SQLite schema migration at application startup when +/// is . The migration is +/// idempotent, so repeated restarts are safe. +/// +internal sealed class SecretsMigrationHostedService( + SqliteSecretsStoreMigrator migrator, + IOptions options) : IHostedService +{ + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + if (options.Value.RunMigrationsOnStartup) + { + await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false); + } + } + + /// + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs new file mode 100644 index 0000000..4d73a5b --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs @@ -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; + +/// +/// 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. +/// +public static class SecretsServiceCollectionExtensions +{ + /// + /// Registers the secrets subsystem: binds from the configuration + /// section at 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). + /// + /// The service collection to add to. + /// The application configuration. + /// Path of the configuration section holding the secrets options. + /// The same instance, for chaining. + public static IServiceCollection AddZbSecrets( + this IServiceCollection services, + IConfiguration config, + string sectionPath) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(config); + ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath); + + services.Configure(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>().Value.SqlitePath)); + + services.TryAddSingleton(sp => + MasterKeyProviderFactory.Create( + sp.GetRequiredService>().Value.MasterKey)); + + services.TryAddSingleton(sp => + new AesGcmEnvelopeCipher(sp.GetRequiredService())); + + services.TryAddSingleton(sp => + new SqliteSecretStore(sp.GetRequiredService())); + + services.TryAddSingleton(); + + services.TryAddSingleton(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() ?? NoOpAuditWriter.Instance; + ISecretActorAccessor? actorAccessor = sp.GetService(); + TimeProvider? timeProvider = sp.GetService(); + TimeSpan ttl = sp.GetRequiredService>().Value.ResolveCacheTtl; + + return new DefaultSecretResolver( + sp.GetRequiredService(), + sp.GetRequiredService(), + 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())); + + // Hosted service that runs migrations on startup when SecretsOptions.RunMigrationsOnStartup. + services.AddHostedService(); + + return services; + } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/SecretsOptions.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/SecretsOptions.cs new file mode 100644 index 0000000..20c779c --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/SecretsOptions.cs @@ -0,0 +1,37 @@ +using ZB.MOM.WW.Secrets.MasterKey; + +namespace ZB.MOM.WW.Secrets; + +/// +/// 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 . +/// +public sealed class SecretsOptions +{ + /// The path to the SQLite database backing the secret store. Defaults to secrets.db. + public string SqlitePath { get; set; } = "secrets.db"; + + /// The master-key (KEK) resolution configuration. Defaults to a fresh . + public MasterKeyOptions MasterKey { get; set; } = new(); + + /// + /// When (the default), the SQLite schema migration runs at application + /// startup via the hosted service. The migration is idempotent, so repeated restarts are safe. + /// + public bool RunMigrationsOnStartup { get; set; } = true; + + /// + /// 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. + /// + public TimeSpan ResolveCacheTtl { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Opt-in switch for the UI reveal path (consumed by the deferred ZB.MOM.WW.Secrets.Ui + /// package). Defaults to — secrets are write-only from the UI unless a + /// deployment explicitly opts in. + /// + public bool RevealEnabled { get; set; } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DependencyInjection/AddZbSecretsTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DependencyInjection/AddZbSecretsTests.cs new file mode 100644 index 0000000..a9542ed --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DependencyInjection/AddZbSecretsTests.cs @@ -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; + +/// +/// Verifies that wires the whole +/// core: every seam resolves, a real secret round-trips through the composed graph, and a missing +/// master key fails closed. +/// +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 + { + ["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()); + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + + ISecretReplicator? replicator = provider.GetService(); + Assert.NotNull(replicator); + Assert.IsType(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().MigrateAsync(CancellationToken.None); + + var name = new SecretName("db/password"); + const string plaintext = "super-secret-value"; + + ISecretCipher cipher = provider.GetRequiredService(); + ISecretStore store = provider.GetRequiredService(); + StoredSecret row = cipher.Encrypt(name, plaintext, SecretContentType.Text); + await store.UpsertAsync(row, CancellationToken.None); + + ISecretResolver resolver = provider.GetRequiredService(); + 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(); + + Assert.Throws(() => keyProvider.GetMasterKey()); + } + finally + { + File.Delete(dbPath); + } + } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj index 89c1e1b..e96d479 100644 --- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj @@ -10,6 +10,8 @@ + +