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)
{
// 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();
}
}
@@ -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<ISecretCacheInvalidator>()?.Invalidate(name);
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.
/// </para>
/// </remarks>
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
/// <summary>
/// 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
/// remainder of its TTL. Not part of <see cref="ISecretResolver"/>; cheap and idempotent.
/// calls this (via <see cref="ISecretCacheInvalidator"/>) immediately after a rotate or delete
/// so a stale value is not served for the remainder of its TTL. Cheap and idempotent.
/// </summary>
/// <param name="name">The secret whose cache entry to remove.</param>
public void Invalidate(SecretName name) => _cache.TryRemove(name.Value, out _);
@@ -56,7 +56,10 @@ public static class SecretsServiceCollectionExtensions
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
// 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<ISecretResolver>(sp => sp.GetRequiredService<DefaultSecretResolver>());
services.TryAddSingleton<ISecretCacheInvalidator>(sp => sp.GetRequiredService<DefaultSecretResolver>());
// Migrator: singleton, constructed from the already-registered connection factory. Needed
// before any store operations so the schema exists.
services.TryAddSingleton(sp =>
@@ -4,8 +4,8 @@ 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
/// 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 <see cref="DependencyInjection.SecretsServiceCollectionExtensions.AddZbSecrets"/>.
/// </summary>
public sealed class SecretsOptions
@@ -27,11 +27,4 @@ public sealed class SecretsOptions
/// 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; }
}