chore(secrets): wire cache invalidation on rotate/delete, drop dead RevealEnabled, remove scaffold leftovers
This commit is contained in:
@@ -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 _);
|
||||
|
||||
+7
-1
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
+27
@@ -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]
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user