Skip to content

Commit fdd29e1

Browse files
authored
Secret injected IConfigurationSection and IConfiguration wrappers. (#10184)
* Secret injected IConfigurationSection and IConfiguration wrappers. * Injected Configure helper
1 parent e3d9813 commit fdd29e1

7 files changed

Lines changed: 334 additions & 8 deletions

File tree

Directory.Packages.props

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,19 +63,19 @@
6363
<PackageVersion Include="Microsoft.Data.Services.Client" Version="5.8.4" />
6464
<PackageVersion Include="Microsoft.Data.Services" Version="5.8.4" />
6565
<PackageVersion Include="Microsoft.Extensions.CommandLineUtils" Version="1.1.1" />
66-
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="2.2.0" />
67-
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="2.2.0" />
68-
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="2.2.0" />
69-
<PackageVersion Include="Microsoft.Extensions.Configuration.FileExtensions" Version="2.2.0" />
70-
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="2.2.0" />
66+
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
67+
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
68+
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
69+
<PackageVersion Include="Microsoft.Extensions.Configuration.FileExtensions" Version="8.0.1" />
70+
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
7171
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
7272
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
7373
<PackageVersion Include="Microsoft.Extensions.Http.Polly" Version="2.2.0" />
7474
<PackageVersion Include="Microsoft.Extensions.Http" Version="2.2.0" />
7575
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="2.2.0" />
7676
<PackageVersion Include="Microsoft.Extensions.Logging" Version="2.2.0" />
7777
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="2.2.0" />
78-
<PackageVersion Include="Microsoft.Extensions.Options" Version="2.2.0" />
78+
<PackageVersion Include="Microsoft.Extensions.Options" Version="8.0.2" />
7979
<PackageVersion Include="Microsoft.Extensions.Primitives" Version="2.2.0" />
8080
<PackageVersion Include="Microsoft.Identity.Client" Version="4.61.3" />
8181
<PackageVersion Include="Microsoft.IdentityModel.JsonWebTokens" Version="7.3.1" />
@@ -151,4 +151,4 @@
151151
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
152152
<PackageVersion Include="xunit" Version="2.9.0" />
153153
</ItemGroup>
154-
</Project>
154+
</Project>

src/NuGet.Services.Configuration/ConfigurationUtility.cs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// 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

44
using System;
55
using System.ComponentModel;
6+
using Microsoft.Extensions.Configuration;
7+
using Microsoft.Extensions.DependencyInjection;
8+
using Microsoft.Extensions.Logging;
9+
using Microsoft.Extensions.Options;
10+
using NuGet.Services.KeyVault;
611

712
namespace NuGet.Services.Configuration
813
{
@@ -25,5 +30,40 @@ public static T ConvertFromString<T>(string value)
2530

2631
throw new NotSupportedException("No converter exists from string to " + typeof(T).Name + "!");
2732
}
33+
34+
/// <summary>
35+
/// Injects secret into a string trying to use cached value first. If the value is absent
36+
/// in cache, falls back to actually querying underlying secret store.
37+
/// </summary>
38+
/// <param name="value">String to inject secret into.</param>
39+
/// <param name="secretInjector">Caching secret injector to use.</param>
40+
/// <param name="logger">Logger.</param>
41+
/// <returns>String with secrets injected.</returns>
42+
public static string InjectCachedSecret(string value, ICachingSecretInjector secretInjector, ILogger logger)
43+
{
44+
if (secretInjector.TryInjectCached(value, logger, out var injectedValue))
45+
{
46+
return injectedValue;
47+
}
48+
return secretInjector.Inject(value, logger);
49+
}
50+
51+
public static IServiceCollection ConfigureInjected<T>(this IServiceCollection services, string sectionPrefix)
52+
where T : class
53+
=> services.AddSingleton(sp => GetInjectedOptions<T>(sp, sectionPrefix));
54+
55+
private static IConfigureOptions<T> GetInjectedOptions<T>(IServiceProvider sp, string sectionPrefix)
56+
where T : class
57+
{
58+
return new ConfigureNamedOptions<T, IConfiguration>(
59+
Options.DefaultName,
60+
sp.GetRequiredService<IConfiguration>(),
61+
(settings, configuration) =>
62+
new SecretInjectedConfiguration(
63+
configuration.GetSection(sectionPrefix),
64+
sp.GetRequiredService<ICachingSecretInjector>(),
65+
sp.GetRequiredService<ILogger<SecretInjectedConfiguration>>())
66+
.Bind(settings));
67+
}
2868
}
2969
}

src/NuGet.Services.Configuration/NuGet.Services.Configuration.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
<ItemGroup>
1010
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
11+
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
1112
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
1213
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" />
1314
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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.Generic;
6+
using System.Linq;
7+
using Microsoft.Extensions.Configuration;
8+
using Microsoft.Extensions.Logging;
9+
using Microsoft.Extensions.Primitives;
10+
using NuGet.Services.KeyVault;
11+
12+
namespace NuGet.Services.Configuration
13+
{
14+
public class SecretInjectedConfiguration : IConfiguration
15+
{
16+
protected readonly IConfiguration _baseConfiguration;
17+
protected readonly ICachingSecretInjector _secretInjector;
18+
protected readonly ILogger _logger;
19+
20+
public SecretInjectedConfiguration(
21+
IConfiguration baseConfiguration,
22+
ICachingSecretInjector secretInjector,
23+
ILogger logger)
24+
{
25+
_baseConfiguration = baseConfiguration ?? throw new ArgumentNullException(nameof(baseConfiguration));
26+
_secretInjector = secretInjector ?? throw new ArgumentNullException(nameof(secretInjector));
27+
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
28+
}
29+
30+
public string this[string key]
31+
{
32+
get => ConfigurationUtility.InjectCachedSecret(_baseConfiguration[key], _secretInjector, _logger);
33+
set => _baseConfiguration[key] = value;
34+
}
35+
36+
public IEnumerable<IConfigurationSection> GetChildren() =>
37+
_baseConfiguration.GetChildren().Select(originalSection => new SecretInjectedConfigurationSection(originalSection, _secretInjector, _logger));
38+
39+
40+
public IChangeToken GetReloadToken() =>
41+
_baseConfiguration.GetReloadToken();
42+
43+
public IConfigurationSection GetSection(string key) =>
44+
new SecretInjectedConfigurationSection(_baseConfiguration.GetSection(key), _secretInjector, _logger);
45+
}
46+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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.Generic;
6+
using System.Linq;
7+
using System.Text;
8+
using System.Threading.Tasks;
9+
using Microsoft.Extensions.Configuration;
10+
using Microsoft.Extensions.Logging;
11+
using Microsoft.Extensions.Primitives;
12+
using NuGet.Services.KeyVault;
13+
14+
namespace NuGet.Services.Configuration
15+
{
16+
public class SecretInjectedConfigurationSection : SecretInjectedConfiguration, IConfigurationSection
17+
{
18+
public SecretInjectedConfigurationSection(
19+
IConfigurationSection baseSection,
20+
ICachingSecretInjector secretInjector,
21+
ILogger logger)
22+
: base(baseSection, secretInjector, logger)
23+
{
24+
}
25+
26+
private IConfigurationSection BaseSection => (IConfigurationSection)_baseConfiguration;
27+
28+
public string Key => BaseSection.Key;
29+
30+
public string Path => BaseSection.Path;
31+
32+
public string Value
33+
{
34+
get => ConfigurationUtility.InjectCachedSecret(BaseSection.Value, _secretInjector, _logger);
35+
set => BaseSection.Value = value;
36+
}
37+
}
38+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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.Generic;
6+
using System.Linq;
7+
using System.Text;
8+
using System.Threading.Tasks;
9+
using Microsoft.Extensions.Configuration;
10+
using Microsoft.Extensions.Logging;
11+
using Microsoft.Extensions.Primitives;
12+
using Moq;
13+
using NuGet.Services.KeyVault;
14+
using Xunit;
15+
16+
namespace NuGet.Services.Configuration.Tests
17+
{
18+
public class SecretInjectedConfigurationFacts
19+
{
20+
private SecretInjectedConfiguration _target;
21+
private Mock<IConfiguration> _configurationMock;
22+
private Mock<ICachingSecretInjector> _secretInjectorMock;
23+
private Mock<ILogger> _loggerMock;
24+
25+
[Fact]
26+
public void InjectsSecretsWhenUsingIndexer()
27+
{
28+
_configurationMock
29+
.SetupGet(c => c[It.IsAny<string>()])
30+
.Returns("SomeString");
31+
var expectedString = "InjectedString";
32+
_secretInjectorMock
33+
.Setup(si => si.TryInjectCached("SomeString", It.IsAny<ILogger>(), out expectedString))
34+
.Returns(true);
35+
36+
var result = _target["SomeString"];
37+
Assert.Equal(expectedString, result);
38+
_secretInjectorMock.Verify(si => si.Inject(It.IsAny<string>()), Times.Never);
39+
_secretInjectorMock.Verify(si => si.Inject(It.IsAny<string>(), It.IsAny<ILogger>()), Times.Never);
40+
_secretInjectorMock.Verify(si => si.InjectAsync(It.IsAny<string>()), Times.Never);
41+
_secretInjectorMock.Verify(si => si.InjectAsync(It.IsAny<string>(), It.IsAny<ILogger>()), Times.Never);
42+
}
43+
44+
[Fact]
45+
public void FallsBackToInjectWhenNotCached()
46+
{
47+
_configurationMock
48+
.SetupGet(c => c[It.IsAny<string>()])
49+
.Returns("SomeString");
50+
string outValue = null;
51+
_secretInjectorMock
52+
.Setup(si => si.TryInjectCached("SomeString", out outValue))
53+
.Returns(false);
54+
_secretInjectorMock
55+
.Setup(si => si.Inject("SomeString", It.IsAny<ILogger>()))
56+
.Returns("InjectedString");
57+
58+
var result = _target["SomeString"];
59+
Assert.Equal("InjectedString", result);
60+
}
61+
62+
[Fact]
63+
public void ChildrenAreSecretInjectedSections()
64+
{
65+
_configurationMock
66+
.Setup(c => c.GetChildren())
67+
.Returns([Mock.Of<IConfigurationSection>(), Mock.Of<IConfigurationSection>()]);
68+
69+
var children = _target.GetChildren();
70+
Assert.All(children, child => Assert.True(child is SecretInjectedConfigurationSection));
71+
}
72+
73+
[Fact]
74+
public void GetSectionReturnsInjectedSection()
75+
{
76+
_configurationMock
77+
.Setup(c => c.GetSection("TestSection"))
78+
.Returns(Mock.Of<IConfigurationSection>());
79+
80+
var section = _target.GetSection("TestSection");
81+
Assert.IsType<SecretInjectedConfigurationSection>(section);
82+
}
83+
84+
[Fact]
85+
public void PassesThroughChangeToken()
86+
{
87+
// Technically, we actually need is that when the original configuration
88+
// section change token notifies about a change returned token should
89+
// notify as well. But the implementation just passes it through, and it is
90+
// easier to test that instead. In an unlikely case that this behavior
91+
// changes we'd need to rewrite this test with proper assumptions in mind.
92+
93+
var changeToken = Mock.Of<IChangeToken>();
94+
_configurationMock
95+
.Setup(c => c.GetReloadToken())
96+
.Returns(changeToken);
97+
98+
var result = _target.GetReloadToken();
99+
Assert.Same(changeToken, result);
100+
}
101+
102+
public SecretInjectedConfigurationFacts()
103+
{
104+
_configurationMock = new Mock<IConfiguration>();
105+
_secretInjectorMock = new Mock<ICachingSecretInjector>();
106+
_loggerMock = new Mock<ILogger>();
107+
108+
_target = new SecretInjectedConfiguration(_configurationMock.Object, _secretInjectorMock.Object, _loggerMock.Object);
109+
}
110+
}
111+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
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.Generic;
6+
using System.Linq;
7+
using System.Text;
8+
using System.Threading.Tasks;
9+
using Microsoft.Extensions.Configuration;
10+
using Microsoft.Extensions.Logging;
11+
using Moq;
12+
using NuGet.Services.KeyVault;
13+
using Xunit;
14+
15+
namespace NuGet.Services.Configuration.Tests
16+
{
17+
public class SecretInjectedConfigurationSectionFacts
18+
{
19+
private Mock<IConfigurationSection> _configurationMock;
20+
private Mock<ICachingSecretInjector> _secretInjectorMock;
21+
private Mock<ILogger> _loggerMock;
22+
private SecretInjectedConfigurationSection _target;
23+
24+
[Fact]
25+
public void PassesThroughKey()
26+
{
27+
_configurationMock
28+
.SetupGet(c => c.Key)
29+
.Returns("TestKey");
30+
31+
Assert.Equal("TestKey", _target.Key);
32+
}
33+
34+
[Fact]
35+
public void PassesThroughPath()
36+
{
37+
_configurationMock
38+
.SetupGet(c => c.Path)
39+
.Returns("TestPath");
40+
41+
Assert.Equal("TestPath", _target.Path);
42+
}
43+
44+
[Fact]
45+
public void InjectsSecretsIntoValue()
46+
{
47+
_configurationMock
48+
.SetupGet(c => c.Value)
49+
.Returns("SomeString");
50+
var expectedString = "InjectedString";
51+
_secretInjectorMock
52+
.Setup(si => si.TryInjectCached("SomeString", It.IsAny<ILogger>(), out expectedString))
53+
.Returns(true);
54+
55+
var result = _target.Value;
56+
Assert.Equal(expectedString, result);
57+
_secretInjectorMock.Verify(si => si.Inject(It.IsAny<string>()), Times.Never);
58+
_secretInjectorMock.Verify(si => si.Inject(It.IsAny<string>(), It.IsAny<ILogger>()), Times.Never);
59+
_secretInjectorMock.Verify(si => si.InjectAsync(It.IsAny<string>()), Times.Never);
60+
_secretInjectorMock.Verify(si => si.InjectAsync(It.IsAny<string>(), It.IsAny<ILogger>()), Times.Never);
61+
}
62+
63+
[Fact]
64+
public void FallsBackToInjectWhenNotCached()
65+
{
66+
_configurationMock
67+
.SetupGet(c => c.Value)
68+
.Returns("SomeString");
69+
string outValue = null;
70+
_secretInjectorMock
71+
.Setup(si => si.TryInjectCached("SomeString", out outValue))
72+
.Returns(false);
73+
_secretInjectorMock
74+
.Setup(si => si.Inject("SomeString", It.IsAny<ILogger>()))
75+
.Returns("InjectedString");
76+
77+
var result = _target.Value;
78+
Assert.Equal("InjectedString", result);
79+
}
80+
81+
public SecretInjectedConfigurationSectionFacts()
82+
{
83+
_configurationMock = new Mock<IConfigurationSection>();
84+
_secretInjectorMock = new Mock<ICachingSecretInjector>();
85+
_loggerMock = new Mock<ILogger>();
86+
87+
_target = new SecretInjectedConfigurationSection(_configurationMock.Object, _secretInjectorMock.Object, _loggerMock.Object);
88+
}
89+
}
90+
}

0 commit comments

Comments
 (0)