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