Skip to content
This repository was archived by the owner on Aug 3, 2024. It is now read-only.

Commit 77e8ec5

Browse files
committed
Add RefreshableSecretReader to allow background thread secret refresh (#304)
Progress on NuGet/NuGetGallery#7337
1 parent f764bf5 commit 77e8ec5

12 files changed

Lines changed: 502 additions & 10 deletions

src/NuGet.Services.KeyVault/EmptySecretReader.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

4-
using System;
54
using System.Threading.Tasks;
65

76
namespace NuGet.Services.KeyVault
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
7+
namespace NuGet.Services.KeyVault
8+
{
9+
/// <summary>
10+
/// An interface that allows caching and refreshing the secrets fetched by secret readers.
11+
/// </summary>
12+
public interface IRefreshableSecretReaderFactory : ISecretReaderFactory
13+
{
14+
/// <summary>
15+
/// Refresh the values of the secrets that have already been read and cached. Since the cache is shared between
16+
/// all <see cref="ISecretReader"/> instances creates, this refresh applies to all secret readers created by
17+
/// this factory.
18+
/// </summary>
19+
/// <param name="token">A cancellation token.</param>
20+
Task RefreshAsync(CancellationToken token);
21+
}
22+
}

src/NuGet.Services.KeyVault/ISecret.cs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,6 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
5-
using System.Collections.Generic;
6-
using System.Linq;
7-
using System.Text;
8-
using System.Threading.Tasks;
95

106
namespace NuGet.Services.KeyVault
117
{

src/NuGet.Services.KeyVault/ISecretReader.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

4-
using System;
54
using System.Threading.Tasks;
65

76
namespace NuGet.Services.KeyVault

src/NuGet.Services.KeyVault/KeyVaultSecret.cs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,6 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
5-
using System.Collections.Generic;
6-
using System.Linq;
7-
using System.Text;
8-
using System.Threading.Tasks;
95

106
namespace NuGet.Services.KeyVault
117
{

src/NuGet.Services.KeyVault/NuGet.Services.KeyVault.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
<Compile Include="CachingSecretReader.cs" />
5454
<Compile Include="CachingSecretReaderFactory.cs" />
5555
<Compile Include="CertificateUtility.cs" />
56+
<Compile Include="IRefreshableSecretReaderFactory.cs" />
5657
<Compile Include="ISecret.cs" />
5758
<Compile Include="ISecretReaderFactory.cs" />
5859
<Compile Include="EmptySecretReader.cs" />
@@ -61,6 +62,9 @@
6162
<Compile Include="KeyVaultConfiguration.cs" />
6263
<Compile Include="KeyVaultReader.cs" />
6364
<Compile Include="KeyVaultSecret.cs" />
65+
<Compile Include="RefreshableSecretReader.cs" />
66+
<Compile Include="RefreshableSecretReaderFactory.cs" />
67+
<Compile Include="RefreshableSecretReaderSettings.cs" />
6468
<Compile Include="SecretInjector.cs" />
6569
<Compile Include="Properties\AssemblyInfo.cs" />
6670
<Compile Include="Properties\AssemblyInfo.*.cs" />
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections.Concurrent;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
9+
namespace NuGet.Services.KeyVault
10+
{
11+
/// <summary>
12+
/// A secret reader that separates the refreshing of secret values from the reading of them from an in-memory
13+
/// cache. Although the <see cref="GetSecretAsync(string)"/> and <see cref="GetSecretObjectAsync(string)"/> methods
14+
/// are asynchronous in definition they do not result an any asynchronous operations when a secret has already been
15+
/// cached with a previous invocation. The <see cref="RefreshAsync"/> method is used to refresh the values of
16+
/// secrets that have already been cached.
17+
/// </summary>
18+
public class RefreshableSecretReader : ISecretReader
19+
{
20+
private readonly ISecretReader _secretReader;
21+
private readonly ConcurrentDictionary<string, ISecret> _cache;
22+
private readonly RefreshableSecretReaderSettings _settings;
23+
24+
public RefreshableSecretReader(
25+
ISecretReader secretReader,
26+
ConcurrentDictionary<string, ISecret> cache,
27+
RefreshableSecretReaderSettings settings)
28+
{
29+
_secretReader = secretReader ?? throw new ArgumentNullException(nameof(secretReader));
30+
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
31+
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
32+
}
33+
34+
public async Task RefreshAsync(CancellationToken token)
35+
{
36+
foreach (var secretName in _cache.Keys)
37+
{
38+
if (token.IsCancellationRequested)
39+
{
40+
return;
41+
}
42+
43+
await UncachedGetSecretObjectAsync(secretName);
44+
}
45+
}
46+
47+
public Task<string> GetSecretAsync(string secretName)
48+
{
49+
if (TryGetCachedSecretObject(secretName, out var secret))
50+
{
51+
return Task.FromResult(secret.Value);
52+
}
53+
54+
return UncachedGetSecretAsync(secretName);
55+
}
56+
57+
public Task<ISecret> GetSecretObjectAsync(string secretName)
58+
{
59+
if (TryGetCachedSecretObject(secretName, out var secret))
60+
{
61+
return Task.FromResult(secret);
62+
}
63+
64+
return UncachedGetSecretObjectAsync(secretName);
65+
}
66+
67+
private async Task<string> UncachedGetSecretAsync(string secretName)
68+
{
69+
var secretObject = await UncachedGetSecretObjectAsync(secretName);
70+
return secretObject.Value;
71+
}
72+
73+
private async Task<ISecret> UncachedGetSecretObjectAsync(string secretName)
74+
{
75+
var secretObject = await _secretReader.GetSecretObjectAsync(secretName);
76+
_cache.AddOrUpdate(secretName, secretObject, (_, __) => secretObject);
77+
return secretObject;
78+
}
79+
80+
private bool TryGetCachedSecretObject(string secretName, out ISecret secret)
81+
{
82+
if (_cache.TryGetValue(secretName, out secret))
83+
{
84+
return true;
85+
}
86+
87+
if (_settings.BlockUncachedReads)
88+
{
89+
throw new InvalidOperationException($"The secret '{secretName}' is not cached.");
90+
}
91+
92+
secret = null;
93+
return false;
94+
}
95+
}
96+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections.Concurrent;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
9+
namespace NuGet.Services.KeyVault
10+
{
11+
/// <summary>
12+
/// Wraps existing secret reader factory to provide a caching layer where the cache refresh is controlled by
13+
/// the <see cref="RefreshAsync"/> method on the factory created <see cref="ISecretReader"/> instances.
14+
/// </summary>
15+
public class RefreshableSecretReaderFactory : IRefreshableSecretReaderFactory
16+
{
17+
private readonly ISecretReaderFactory _underlyingFactory;
18+
private readonly ConcurrentDictionary<string, ISecret> _cache;
19+
private readonly RefreshableSecretReaderSettings _settings;
20+
21+
public RefreshableSecretReaderFactory(ISecretReaderFactory underlyingFactory, RefreshableSecretReaderSettings settings)
22+
{
23+
_underlyingFactory = underlyingFactory ?? throw new ArgumentNullException(nameof(underlyingFactory));
24+
_cache = new ConcurrentDictionary<string, ISecret>();
25+
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
26+
}
27+
28+
public async Task RefreshAsync(CancellationToken token)
29+
{
30+
await GetRefreshableSecretReader().RefreshAsync(token);
31+
}
32+
33+
public ISecretInjector CreateSecretInjector(ISecretReader secretReader)
34+
{
35+
return _underlyingFactory.CreateSecretInjector(secretReader);
36+
}
37+
38+
public ISecretReader CreateSecretReader()
39+
{
40+
return GetRefreshableSecretReader();
41+
}
42+
43+
private RefreshableSecretReader GetRefreshableSecretReader()
44+
{
45+
var innerSecretReader = _underlyingFactory.CreateSecretReader();
46+
return new RefreshableSecretReader(innerSecretReader, _cache, _settings);
47+
}
48+
}
49+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
6+
namespace NuGet.Services.KeyVault
7+
{
8+
/// <summary>
9+
/// The purpose of this class is to allow a <see cref="RefreshableSecretReaderFactory"/> to dynamically control the
10+
/// settings of the <see cref="RefreshableSecretReader"/> instance that it creates. This does not follow the
11+
/// Microsoft.Extensions.Options (e.g. IOptionsSnapshot, IOptions) pattern because this is meant to initialized and
12+
/// modified at runtime.
13+
/// </summary>
14+
public class RefreshableSecretReaderSettings
15+
{
16+
/// <summary>
17+
/// Prevent <see cref="RefreshableSecretReader.GetSecretAsync(string)"/> or
18+
/// <see cref="RefreshableSecretReader.GetSecretObjectAsync(string)"/> from getting secrets from the underlying
19+
/// secret reader. If one of these methods is executed and the provided secret is not found, an
20+
/// <see cref="InvalidOperationException"/> will be thrown. In a web application, this should be enabled during
21+
/// startup so that requests encounter an exception instead of reading a secret from KeyVault in a context that
22+
/// may cause a deadlock. It's better to throw an exception than deadlock.
23+
/// </summary>
24+
public bool BlockUncachedReads { get; set; }
25+
}
26+
}

tests/NuGet.Services.KeyVault.Tests/NuGet.Services.KeyVault.Tests.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
<Compile Include="CachingSecretReaderFacts.cs" />
4242
<Compile Include="KeyVaultReaderFormatterFacts.cs" />
4343
<Compile Include="Properties\AssemblyInfo.cs" />
44+
<Compile Include="RefreshableSecretReaderFactoryFacts.cs" />
45+
<Compile Include="RefreshableSecretReaderFacts.cs" />
4446
<Compile Include="SecretReaderFacts.cs" />
4547
</ItemGroup>
4648
<ItemGroup>

0 commit comments

Comments
 (0)