chore(secrets): wire cache invalidation on rotate/delete, drop dead RevealEnabled, remove scaffold leftovers

This commit is contained in:
Joseph Doherty
2026-07-15 17:39:55 -04:00
parent 294f53b49a
commit e20812cbae
10 changed files with 74 additions and 29 deletions
@@ -0,0 +1,8 @@
namespace ZB.MOM.WW.Secrets.Abstractions;
/// <summary>Evicts a cached decrypted secret so the next resolve re-reads it. Called by write paths after a rotate/delete.</summary>
public interface ISecretCacheInvalidator
{
/// <summary>Evicts the cache entry for <paramref name="name"/> (no-op if absent).</summary>
void Invalidate(SecretName name);
}
@@ -96,6 +96,11 @@
if (deleted) 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<ISecretCacheInvalidator>()?.Invalidate(Name);
await OnDeleted.InvokeAsync(); await OnDeleted.InvokeAsync();
} }
} }
@@ -169,6 +169,11 @@
if (saved) 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<ISecretCacheInvalidator>()?.Invalidate(name);
await OnSaved.InvokeAsync(); await OnSaved.InvokeAsync();
} }
} }
@@ -1,9 +0,0 @@
namespace ZB.MOM.WW.Secrets;
/// <summary>
/// Internal marker giving the assembly a compilable input while the store and
/// resolver are scaffolded. Replace with real implementation as it is added.
/// </summary>
internal static class AssemblyMarker
{
}
@@ -32,7 +32,7 @@ namespace ZB.MOM.WW.Secrets;
/// ciphertext and any value are never included. /// ciphertext and any value are never included.
/// </para> /// </para>
/// </remarks> /// </remarks>
public sealed class DefaultSecretResolver : ISecretResolver public sealed class DefaultSecretResolver : ISecretResolver, ISecretCacheInvalidator
{ {
private const string SystemActor = "system"; private const string SystemActor = "system";
private const string AuditCategory = "Secrets"; private const string AuditCategory = "Secrets";
@@ -125,8 +125,8 @@ public sealed class DefaultSecretResolver : ISecretResolver
/// <summary> /// <summary>
/// Evicts the cached plaintext for <paramref name="name"/>, if any. The admin / write path /// Evicts the cached plaintext for <paramref name="name"/>, if any. The admin / write path
/// calls this immediately after a rotate or delete so a stale value is not served for the /// calls this (via <see cref="ISecretCacheInvalidator"/>) immediately after a rotate or delete
/// remainder of its TTL. Not part of <see cref="ISecretResolver"/>; cheap and idempotent. /// so a stale value is not served for the remainder of its TTL. Cheap and idempotent.
/// </summary> /// </summary>
/// <param name="name">The secret whose cache entry to remove.</param> /// <param name="name">The secret whose cache entry to remove.</param>
public void Invalidate(SecretName name) => _cache.TryRemove(name.Value, out _); public void Invalidate(SecretName name) => _cache.TryRemove(name.Value, out _);
@@ -56,7 +56,10 @@ public static class SecretsServiceCollectionExtensions
services.TryAddSingleton<ISecretReplicator, NoOpSecretReplicator>(); services.TryAddSingleton<ISecretReplicator, NoOpSecretReplicator>();
services.TryAddSingleton<ISecretResolver>(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<DefaultSecretResolver>(sp =>
{ {
// Resolve the audit writer LAZILY so this picks up the app's AddZbAudit registration // 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. // 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); timeProvider);
}); });
services.TryAddSingleton<ISecretResolver>(sp => sp.GetRequiredService<DefaultSecretResolver>());
services.TryAddSingleton<ISecretCacheInvalidator>(sp => sp.GetRequiredService<DefaultSecretResolver>());
// Migrator: singleton, constructed from the already-registered connection factory. Needed // Migrator: singleton, constructed from the already-registered connection factory. Needed
// before any store operations so the schema exists. // before any store operations so the schema exists.
services.TryAddSingleton(sp => services.TryAddSingleton(sp =>
@@ -4,8 +4,8 @@ namespace ZB.MOM.WW.Secrets;
/// <summary> /// <summary>
/// Application-level configuration for the ZB.MOM.WW secrets subsystem: where the SQLite store /// 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, /// lives, how the master key (KEK) is resolved, whether the schema migration runs on startup, and
/// how long decrypted values are cached, and whether the UI reveal path is enabled. Bound from a /// how long decrypted values are cached. Bound from a
/// configuration section by <see cref="DependencyInjection.SecretsServiceCollectionExtensions.AddZbSecrets"/>. /// configuration section by <see cref="DependencyInjection.SecretsServiceCollectionExtensions.AddZbSecrets"/>.
/// </summary> /// </summary>
public sealed class SecretsOptions public sealed class SecretsOptions
@@ -27,11 +27,4 @@ public sealed class SecretsOptions
/// default (30 seconds) because the cache holds plaintext secret material. /// default (30 seconds) because the cache holds plaintext secret material.
/// </summary> /// </summary>
public TimeSpan ResolveCacheTtl { get; set; } = TimeSpan.FromSeconds(30); 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; }
} }
@@ -163,6 +163,23 @@ public class DefaultSecretResolverTests
Assert.Equal(2, store.GetCount); 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] [Fact]
public async Task Get_DefaultActorIsSystem_WhenNoAccessor() public async Task Get_DefaultActorIsSystem_WhenNoAccessor()
{ {
@@ -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<ISecretResolver>();
var invalidator = provider.GetService<ISecretCacheInvalidator>();
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] [Fact]
public async Task Resolver_Works_EndToEnd_ThroughDi() public async Task Resolver_Works_EndToEnd_ThroughDi()
{ {
@@ -1,7 +0,0 @@
namespace ZB.MOM.WW.Secrets.Tests;
public class PlaceholderTests
{
[Fact]
public void Builds() => Assert.True(true);
}