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)
|
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 _);
|
||||||
|
|||||||
+7
-1
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
+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]
|
[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);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user