diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretCacheInvalidator.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretCacheInvalidator.cs new file mode 100644 index 0000000..5433171 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ISecretCacheInvalidator.cs @@ -0,0 +1,8 @@ +namespace ZB.MOM.WW.Secrets.Abstractions; + +/// Evicts a cached decrypted secret so the next resolve re-reads it. Called by write paths after a rotate/delete. +public interface ISecretCacheInvalidator +{ + /// Evicts the cache entry for (no-op if absent). + void Invalidate(SecretName name); +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/ConfirmDeleteModal.razor b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/ConfirmDeleteModal.razor index 2fe91de..c5b7996 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/ConfirmDeleteModal.razor +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/ConfirmDeleteModal.razor @@ -96,6 +96,11 @@ if (deleted) { + // Evict any cached plaintext so the ${secret:} / ISecretResolver path does not serve a + // stale value after this delete for the remainder of its TTL. Optional seam — a no-op + // when the host has not registered an invalidator. + Services.GetService()?.Invalidate(Name); + await OnDeleted.InvokeAsync(); } } diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretEditor.razor b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretEditor.razor index 62567cb..0a51c0a 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretEditor.razor +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretEditor.razor @@ -169,6 +169,11 @@ if (saved) { + // Evict any cached plaintext so the ${secret:} / ISecretResolver path does not serve a + // stale value after this rotate/add for the remainder of its TTL. Optional seam — a no-op + // when the host has not registered an invalidator. + Services.GetService()?.Invalidate(name); + await OnSaved.InvokeAsync(); } } diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/AssemblyMarker.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/AssemblyMarker.cs deleted file mode 100644 index 4217325..0000000 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/AssemblyMarker.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace ZB.MOM.WW.Secrets; - -/// -/// Internal marker giving the assembly a compilable input while the store and -/// resolver are scaffolded. Replace with real implementation as it is added. -/// -internal static class AssemblyMarker -{ -} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DefaultSecretResolver.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DefaultSecretResolver.cs index 6875fc7..7152b91 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DefaultSecretResolver.cs +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DefaultSecretResolver.cs @@ -32,7 +32,7 @@ namespace ZB.MOM.WW.Secrets; /// ciphertext and any value are never included. /// /// -public sealed class DefaultSecretResolver : ISecretResolver +public sealed class DefaultSecretResolver : ISecretResolver, ISecretCacheInvalidator { private const string SystemActor = "system"; private const string AuditCategory = "Secrets"; @@ -125,8 +125,8 @@ public sealed class DefaultSecretResolver : ISecretResolver /// /// Evicts the cached plaintext for , if any. The admin / write path - /// calls this immediately after a rotate or delete so a stale value is not served for the - /// remainder of its TTL. Not part of ; cheap and idempotent. + /// calls this (via ) immediately after a rotate or delete + /// so a stale value is not served for the remainder of its TTL. Cheap and idempotent. /// /// The secret whose cache entry to remove. public void Invalidate(SecretName name) => _cache.TryRemove(name.Value, out _); 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 index 4d73a5b..20c7187 100644 --- 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 @@ -56,7 +56,10 @@ public static class SecretsServiceCollectionExtensions services.TryAddSingleton(); - services.TryAddSingleton(sp => + // ONE shared resolver instance backs both the read seam (ISecretResolver) and the + // cache-invalidation seam (ISecretCacheInvalidator) so a write-path invalidation hits the + // exact cache the resolver reads from. + 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. @@ -74,6 +77,9 @@ public static class SecretsServiceCollectionExtensions timeProvider); }); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => sp.GetRequiredService()); + // Migrator: singleton, constructed from the already-registered connection factory. Needed // before any store operations so the schema exists. services.TryAddSingleton(sp => 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 index 20c779c..5d5ee34 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/SecretsOptions.cs +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/SecretsOptions.cs @@ -4,8 +4,8 @@ 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 +/// lives, how the master key (KEK) is resolved, whether the schema migration runs on startup, and +/// how long decrypted values are cached. Bound from a /// configuration section by . /// public sealed class SecretsOptions @@ -27,11 +27,4 @@ public sealed class SecretsOptions /// 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/DefaultSecretResolverTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DefaultSecretResolverTests.cs index 631b2fe..1c0c24f 100644 --- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DefaultSecretResolverTests.cs +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DefaultSecretResolverTests.cs @@ -163,6 +163,23 @@ public class DefaultSecretResolverTests Assert.Equal(2, store.GetCount); } + [Fact] + public async Task Invalidate_ViaCacheInvalidatorSeam_ForcesReload() + { + var (resolver, store, _, cipher, _) = NewSut(); + var name = new SecretName("sql/foo"); + store.Seed(MakeRow(cipher, name, "hunter2")); + + // Exercise the SAME seam the write path (Ui rotate/delete) uses: ISecretCacheInvalidator. + ISecretCacheInvalidator invalidator = resolver; + + await resolver.GetAsync(name, CancellationToken.None); // caches (count 1) + invalidator.Invalidate(name); + await resolver.GetAsync(name, CancellationToken.None); // cache evicted → hits the store again + + Assert.Equal(2, store.GetCount); + } + [Fact] public async Task Get_DefaultActorIsSystem_WhenNoAccessor() { 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 index a9542ed..bea5dff 100644 --- 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 @@ -57,6 +57,33 @@ public sealed class AddZbSecretsTests } } + [Fact] + public void CacheInvalidator_And_Resolver_AreSameSingletonInstance() + { + 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(); + + var resolver = provider.GetService(); + var invalidator = provider.GetService(); + + Assert.NotNull(resolver); + Assert.NotNull(invalidator); + // Same object: an invalidation MUST hit the exact cache the resolver reads from. + Assert.Same(resolver, invalidator); + } + finally + { + Environment.SetEnvironmentVariable(envVar, null); + File.Delete(dbPath); + } + } + [Fact] public async Task Resolver_Works_EndToEnd_ThroughDi() { diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/PlaceholderTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/PlaceholderTests.cs deleted file mode 100644 index 8464965..0000000 --- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/PlaceholderTests.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace ZB.MOM.WW.Secrets.Tests; - -public class PlaceholderTests -{ - [Fact] - public void Builds() => Assert.True(true); -}