16fac72858
Secrets-006: DefaultSecretResolver keeps a per-key invalidation generation; Invalidate bumps it before evicting, and a resolve that read the store under an older generation withdraws its insert, so a rotation mid-resolve is not undone for another TTL. Expired entries are removed on lookup and by a periodic TimeProvider sweep (every TTL, at least 1 s); a non-positive TTL caches nothing. The resolver is now IDisposable (stops the sweep timer). Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
175 lines
7.2 KiB
C#
175 lines
7.2 KiB
C#
using ZB.MOM.WW.Secrets.Abstractions;
|
|
using ZB.MOM.WW.Secrets.Crypto;
|
|
using ZB.MOM.WW.Secrets.Tests.Fakes;
|
|
|
|
namespace ZB.MOM.WW.Secrets.Tests;
|
|
|
|
/// <summary>
|
|
/// Secrets-006: an <see cref="DefaultSecretResolver.Invalidate"/> that lands while a resolve is
|
|
/// reading the store must not be undone by that resolve re-caching the old value, and expired
|
|
/// plaintext must not stay referenced for the life of the process.
|
|
/// </summary>
|
|
public class DefaultSecretResolverCacheTests
|
|
{
|
|
private static readonly TimeSpan Ttl = TimeSpan.FromSeconds(30);
|
|
|
|
private static StoredSecret Row(AesGcmEnvelopeCipher cipher, SecretName name, string value, long revision) =>
|
|
cipher.Encrypt(name, value, SecretContentType.Text) with
|
|
{
|
|
Revision = revision,
|
|
CreatedUtc = DateTimeOffset.UnixEpoch,
|
|
UpdatedUtc = DateTimeOffset.UnixEpoch,
|
|
};
|
|
|
|
/// <summary>A store whose next read is held until the test releases it.</summary>
|
|
private sealed class GatedStore : ISecretStore
|
|
{
|
|
private readonly CountingSecretStore _inner = new();
|
|
|
|
public TaskCompletionSource ReadStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
public TaskCompletionSource Release { get; private set; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
public bool Gate { get; set; }
|
|
|
|
public int Reads => _inner.GetCount;
|
|
|
|
public void Seed(StoredSecret row) => _inner.Seed(row);
|
|
|
|
public async Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct)
|
|
{
|
|
StoredSecret? row = await _inner.GetAsync(name, ct);
|
|
if (Gate)
|
|
{
|
|
Gate = false;
|
|
ReadStarted.TrySetResult();
|
|
await Release.Task;
|
|
}
|
|
|
|
return row;
|
|
}
|
|
|
|
public Task UpsertAsync(StoredSecret row, CancellationToken ct) => throw new NotSupportedException();
|
|
public Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct) => throw new NotSupportedException();
|
|
public Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct) => throw new NotSupportedException();
|
|
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) => throw new NotSupportedException();
|
|
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) => throw new NotSupportedException();
|
|
public Task<bool> ApplyRewrapAsync(StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) => throw new NotSupportedException();
|
|
}
|
|
|
|
/// <summary>A clock whose timers fire only when the test says so.</summary>
|
|
private sealed class ManualTimerTimeProvider : TimeProvider
|
|
{
|
|
public DateTimeOffset Now { get; set; } = DateTimeOffset.UnixEpoch;
|
|
|
|
public List<(TimerCallback Callback, object? State)> Timers { get; } = new();
|
|
|
|
public override DateTimeOffset GetUtcNow() => Now;
|
|
|
|
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
|
|
{
|
|
Timers.Add((callback, state));
|
|
return new NullTimer();
|
|
}
|
|
|
|
public void FireAll()
|
|
{
|
|
foreach ((TimerCallback callback, object? state) in Timers)
|
|
{
|
|
callback(state);
|
|
}
|
|
}
|
|
|
|
private sealed class NullTimer : ITimer
|
|
{
|
|
public bool Change(TimeSpan dueTime, TimeSpan period) => true;
|
|
public void Dispose() { }
|
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_invalidate_during_an_in_flight_resolve_is_not_undone_by_it()
|
|
{
|
|
var cipher = new AesGcmEnvelopeCipher(new FakeMasterKeyProvider("k1"));
|
|
var store = new GatedStore();
|
|
var name = new SecretName("db/central");
|
|
store.Seed(Row(cipher, name, "old-password", revision: 3));
|
|
using var resolver = new DefaultSecretResolver(store, cipher, new CapturingAuditWriter(), Ttl);
|
|
|
|
// A request thread misses the cache and reads revision 3 ...
|
|
store.Gate = true;
|
|
Task<string?> inFlight = resolver.GetAsync(name, CancellationToken.None);
|
|
await store.ReadStarted.Task;
|
|
|
|
// ... the operator rotates to revision 4 and the write path invalidates ...
|
|
store.Seed(Row(cipher, name, "new-password", revision: 4));
|
|
resolver.Invalidate(name);
|
|
|
|
// ... and only then does the request thread finish with the old value.
|
|
store.Release.TrySetResult();
|
|
Assert.Equal("old-password", await inFlight);
|
|
|
|
// The next resolve must go back to the store, not serve revision 3 for another TTL.
|
|
Assert.Equal("new-password", await resolver.GetAsync(name, CancellationToken.None));
|
|
Assert.Equal(2, store.Reads);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_expired_entry_is_dropped_when_it_is_looked_up()
|
|
{
|
|
var cipher = new AesGcmEnvelopeCipher(new FakeMasterKeyProvider("k1"));
|
|
var store = new CountingSecretStore();
|
|
var clock = new MutableTimeProvider(DateTimeOffset.UnixEpoch);
|
|
var name = new SecretName("db/central");
|
|
store.Seed(Row(cipher, name, "hunter2", 1));
|
|
using var resolver = new DefaultSecretResolver(store, cipher, new CapturingAuditWriter(), Ttl, null, clock);
|
|
|
|
await resolver.GetAsync(name, CancellationToken.None);
|
|
Assert.Equal(1, resolver.CachedCount);
|
|
|
|
// The secret is deleted out-of-band; the expired entry must not outlive the lookup.
|
|
store.Seed(Row(cipher, name, "hunter2", 2) with { IsDeleted = true });
|
|
clock.Advance(Ttl + TimeSpan.FromSeconds(1));
|
|
Assert.Null(await resolver.GetAsync(name, CancellationToken.None));
|
|
Assert.Equal(0, resolver.CachedCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_sweep_drops_expired_plaintext_that_is_never_looked_up_again()
|
|
{
|
|
var cipher = new AesGcmEnvelopeCipher(new FakeMasterKeyProvider("k1"));
|
|
var store = new CountingSecretStore();
|
|
var clock = new ManualTimerTimeProvider();
|
|
var name = new SecretName("db/central");
|
|
store.Seed(Row(cipher, name, "hunter2", 1));
|
|
using var resolver = new DefaultSecretResolver(store, cipher, new CapturingAuditWriter(), Ttl, null, clock);
|
|
|
|
await resolver.GetAsync(name, CancellationToken.None); // e.g. startup expansion
|
|
Assert.Single(clock.Timers);
|
|
|
|
clock.FireAll();
|
|
Assert.Equal(1, resolver.CachedCount); // positive control: not yet expired
|
|
|
|
clock.Now += Ttl;
|
|
clock.FireAll();
|
|
Assert.Equal(0, resolver.CachedCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_non_positive_ttl_caches_nothing()
|
|
{
|
|
var cipher = new AesGcmEnvelopeCipher(new FakeMasterKeyProvider("k1"));
|
|
var store = new CountingSecretStore();
|
|
var name = new SecretName("db/central");
|
|
store.Seed(Row(cipher, name, "hunter2", 1));
|
|
using var resolver = new DefaultSecretResolver(store, cipher, new CapturingAuditWriter(), TimeSpan.Zero);
|
|
|
|
await resolver.GetAsync(name, CancellationToken.None);
|
|
await resolver.GetAsync(name, CancellationToken.None);
|
|
|
|
Assert.Equal(0, resolver.CachedCount);
|
|
Assert.Equal(2, store.GetCount);
|
|
}
|
|
}
|