Skip to content

Commit

Permalink
Merge pull request #1 from wdolek/feature/introduce-adding-multiple-s…
Browse files Browse the repository at this point in the history
…ecrets

Add multiple secrets as source at once
  • Loading branch information
wdolek authored Aug 1, 2024
2 parents c4a97fb + cd2d825 commit 3cfb355
Show file tree
Hide file tree
Showing 6 changed files with 201 additions and 32 deletions.
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,23 @@ builder.Configuration.AddSecretsManager(
c => c.ConfigurationKeyPrefix = "AppSecrets");
```

In order to add multiple secrets while sharing same configuration,
it's possible to use another overload of `AddSecretsManager` method:

```csharp
// add AWS Secrets Manager Configuration Provider for multiple secrets
builder.Configuration.AddSecretsManager(["my-first-secret", "my-second-secret"]);
```

## Configuration

### Optional secret

When adding a configuration source, it is mandatory by default - meaning if the secret is not found or it's not possible
to load it, an exception is thrown. To make it optional, set `isOptional` to `true`:
When adding a configuration source, given secret is mandatory by default - meaning if the secret is not found, or it's not possible
to fetch it, an exception is thrown. To make it optional, set `IsOptional` property to `true`:

```csharp
builder.Configuration.AddSecretsManager("my-secret-secrets", isOptional: true);
builder.Configuration.AddSecretsManager("my-secret-secrets", c => c.IsOptional = true);
```

### Secret Version
Expand Down Expand Up @@ -88,7 +96,7 @@ When binding your option type, make sure path is considered or that you bind to

### Secret processing (parsing and tokenizing)

By default AWS Secrets Manager stores secret as simple key-value JSON object - and thus JSON processor is set as default.
By default, AWS Secrets Manager stores secret as simple key-value JSON object - and thus JSON processor is set as default.
In some cases, a user may want to specify a custom format, either a complex JSON object or even an XML document.

In order to support such scenarios, it is possible to specify custom secret processor:
Expand All @@ -103,13 +111,13 @@ builder.Configuration.AddSecretsManager(
});
```

There's helper class [`SecretsProcessor<T>`](W4k.Extensions.Configuration.Aws.SecretsManager/SecretsProcessor.cs) which
There's helper class [`SecretProcessor<T>`](W4k.Extensions.Configuration.Aws.SecretsManager/SecretProcessor.cs) which
can be used to simplify implementation of custom processor (by providing implementation of [`ISecretStringParser<T>`](W4k.Extensions.Configuration.Aws.SecretsManager/Abstractions/ISecretStringParser.cs) and [`IConfigurationTokenizer<T>`](W4k.Extensions.Configuration.Aws.SecretsManager/Abstractions/IConfigurationTokenizer.cs)).

### Configuration key transformation

It is possible to hook into the configuration key transformation, which is used to transform the tokenized configuration key.
By default only [`KeyDelimiterTransformer`](W4k.Extensions.Configuration.Aws.SecretsManager/ConfigurationKeyTransformer.cs) is used.
By default, only [`KeyDelimiterTransformer`](W4k.Extensions.Configuration.Aws.SecretsManager/ConfigurationKeyTransformer.cs) is used.

`KeyDelimiterTransformer` transforms "`__`" to configuration key delimiter, "`:`".

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public void LoadSecret()
.GetSecretValueAsync(Arg.Any<GetSecretValueRequest>(), Arg.Any<CancellationToken>())
.Returns(InitialSecretValueResponse);

var source = new SecretsManagerConfigurationSource(TestOptions, secretsManagerStub, isOptional: false);
var source = new SecretsManagerConfigurationSource(TestOptions, secretsManagerStub);
var provider = new SecretsManagerConfigurationProvider(source);

// act
Expand All @@ -47,14 +47,12 @@ public void LoadSecret()
public void ThrowWhenLoadingFails()
{
// arrange
var isOptional = false;

var secretsManagerStub = Substitute.For<IAmazonSecretsManager>();
secretsManagerStub
.GetSecretValueAsync(Arg.Any<GetSecretValueRequest>(), Arg.Any<CancellationToken>())
.Throws(new ResourceNotFoundException("(╯‵□′)╯︵┻━┻"));
var source = new SecretsManagerConfigurationSource(TestOptions, secretsManagerStub, isOptional);

var source = new SecretsManagerConfigurationSource(TestOptions, secretsManagerStub);
var provider = new SecretsManagerConfigurationProvider(source);

// act
Expand All @@ -65,14 +63,15 @@ public void ThrowWhenLoadingFails()
public void NotThrowWhenLoadingFailsButSourceIsOptional()
{
// arrange
var isOptional = true;

var secretsManagerStub = Substitute.For<IAmazonSecretsManager>();
secretsManagerStub
.GetSecretValueAsync(Arg.Any<GetSecretValueRequest>(), Arg.Any<CancellationToken>())
.Throws(new ResourceNotFoundException("(╯‵□′)╯︵┻━┻"));

var source = new SecretsManagerConfigurationSource(TestOptions, secretsManagerStub, isOptional);

var source = new SecretsManagerConfigurationSource(
new SecretsManagerConfigurationProviderOptions("le-secret") { IsOptional = true },
secretsManagerStub);

var provider = new SecretsManagerConfigurationProvider(source);

// act
Expand All @@ -99,7 +98,7 @@ public async Task NotifyRefreshChangeOnNewValue()
.GetSecretValueAsync(Arg.Any<GetSecretValueRequest>(), Arg.Any<CancellationToken>())
.Returns(InitialSecretValueResponse, refreshedResponse);

var source = new SecretsManagerConfigurationSource(TestOptions, secretsManagerStub, isOptional: false);
var source = new SecretsManagerConfigurationSource(TestOptions, secretsManagerStub);
var provider = new SecretsManagerConfigurationProvider(source);

// act
Expand Down Expand Up @@ -130,7 +129,7 @@ public async Task NotNotifyRefreshChangeOnSameValue()
.GetSecretValueAsync(Arg.Any<GetSecretValueRequest>(), Arg.Any<CancellationToken>())
.Returns(InitialSecretValueResponse, InitialSecretValueResponse);

var source = new SecretsManagerConfigurationSource(TestOptions, secretsManagerStub, isOptional: false);
var source = new SecretsManagerConfigurationSource(TestOptions, secretsManagerStub);
var provider = new SecretsManagerConfigurationProvider(source);

// act
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public interface IConfigurationRefresher
/// <summary>
/// Gets whether configuration source is optional.
/// </summary>
[Obsolete("Use Options.IsOptional instead.")]
bool IsOptional { get; }

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public SecretsManagerConfigurationProvider(SecretsManagerConfigurationSource sou
}

public SecretsManagerConfigurationProviderOptions Options => _source.Options;
public bool IsOptional => _source.IsOptional;
public bool IsOptional => _source.Options.IsOptional;

public override void Load()
{
Expand All @@ -42,7 +42,7 @@ public override void Load()
}
catch
{
if (IsOptional)
if (Options.IsOptional)
{
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ public SecretsManagerConfigurationProviderOptions(string secretName)
/// </summary>
public string SecretName { get; }

/// <summary>
/// Gets or sets a value indicating whether secret is mandatory (throws when failed to load) or optional (silently ignores).
/// </summary>
public bool IsOptional { get; set; }

/// <summary>
/// Gets or sets secret version to fetch, if not provided, latest version of secret is fetched.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,20 @@
using Amazon.SecretsManager;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using Amazon.SecretsManager;
using Microsoft.Extensions.Configuration;

namespace W4k.Extensions.Configuration.Aws.SecretsManager;

internal class SecretsManagerConfigurationSource : IConfigurationSource
{
public SecretsManagerConfigurationSource(
SecretsManagerConfigurationProviderOptions options,
IAmazonSecretsManager client,
bool isOptional)
public SecretsManagerConfigurationSource(SecretsManagerConfigurationProviderOptions options, IAmazonSecretsManager client)
{
Options = options;
SecretsManager = client;
IsOptional = isOptional;
}


public SecretsManagerConfigurationProviderOptions Options { get; }
public IAmazonSecretsManager SecretsManager { get; }
public bool IsOptional { get; }

public IConfigurationProvider Build(IConfigurationBuilder builder) =>
new SecretsManagerConfigurationProvider(this);
Expand All @@ -39,7 +35,9 @@ public static class SecretsManagerConfigurationExtensions
/// <param name="secretName">Secret name or ID.</param>
/// <param name="configureOptions">A delegate that is invoked to set up the AWS Secrets Manager Configuration options.</param>
/// <param name="isOptional">Defines the configuration provider's response when a server loading error occurs. If set to false, the error is propagated. If set to true, the error is ignored and no settings are loaded from the AWS Secrets Manager Configuration.</param>
/// <returns>Instance of <see cref="IConfigurationBuilder"/></returns>
/// <returns>Instance of <see cref="IConfigurationBuilder"/>.</returns>
[SuppressMessage("ReSharper", "MethodOverloadWithOptionalParameter", Justification = "Binary compatibility.")]
[Obsolete("Use override without `isOptional` parameter, AddSecretsManager(IConfigurationBuilder, string, Action<SecretsManagerConfigurationProviderOptions>).")]
public static IConfigurationBuilder AddSecretsManager(
this IConfigurationBuilder builder,
string secretName,
Expand All @@ -58,7 +56,11 @@ public static IConfigurationBuilder AddSecretsManager(
/// <param name="client">AWS Secrets Manager client.</param>
/// <param name="configureOptions">A delegate that is invoked to set up the AWS Secrets Manager Configuration options.</param>
/// <param name="isOptional">Defines the configuration provider's response when a server loading error occurs. If set to false, the error is propagated. If set to true, the error is ignored and no settings are loaded from the AWS Secrets Manager Configuration.</param>
/// <returns>Instance of <see cref="IConfigurationBuilder"/></returns>
/// <returns>Instance of <see cref="IConfigurationBuilder"/>.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="secretName"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> is <see langword="null"/>.</exception>
[SuppressMessage("ReSharper", "MethodOverloadWithOptionalParameter", Justification = "Binary compatibility.")]
[Obsolete("Use override without `isOptional` parameter, AddSecretsManager(IConfigurationBuilder, string, IAmazonSecretsManager, Action<SecretsManagerConfigurationProviderOptions>).")]
public static IConfigurationBuilder AddSecretsManager(
this IConfigurationBuilder builder,
string secretName,
Expand All @@ -68,10 +70,164 @@ public static IConfigurationBuilder AddSecretsManager(
{
ArgumentNullException.ThrowIfNull(secretName);
ArgumentNullException.ThrowIfNull(client);

var providerOptions = new SecretsManagerConfigurationProviderOptions(secretName);

var providerOptions = new SecretsManagerConfigurationProviderOptions(secretName)
{
IsOptional = isOptional,
};

configureOptions?.Invoke(providerOptions);

return builder.Add(new SecretsManagerConfigurationSource(providerOptions, client, isOptional));
return builder.AddSecretsManager(providerOptions, client);
}

/// <summary>
/// Adds secrets manager as configuration source.
/// </summary>
/// <remarks>
/// This extension methods uses default <see cref="AmazonSecretsManagerClient"/> instance.
/// </remarks>
/// <param name="builder">Configuration builder.</param>
/// <param name="secretName">Secret name or ID.</param>
/// <param name="configureOptions">A delegate that is invoked to set up the AWS Secrets Manager Configuration options.</param>
/// <returns>Instance of <see cref="IConfigurationBuilder"/>.</returns>
public static IConfigurationBuilder AddSecretsManager(
this IConfigurationBuilder builder,
string secretName,
Action<SecretsManagerConfigurationProviderOptions>? configureOptions = null)
{
var client = new AmazonSecretsManagerClient();
return builder.AddSecretsManager(secretName, client, configureOptions);
}

/// <summary>
/// Adds secrets manager as configuration source.
/// </summary>
/// <param name="builder">Configuration builder.</param>
/// <param name="secretName">Secret name or ID.</param>
/// <param name="client">AWS Secrets Manager client.</param>
/// <param name="configureOptions">A delegate that is invoked to set up the AWS Secrets Manager Configuration options.</param>
/// <returns>Instance of <see cref="IConfigurationBuilder"/>.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="secretName"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> is <see langword="null"/>.</exception>
public static IConfigurationBuilder AddSecretsManager(
this IConfigurationBuilder builder,
string secretName,
IAmazonSecretsManager client,
Action<SecretsManagerConfigurationProviderOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(secretName);
ArgumentNullException.ThrowIfNull(client);

var options = new SecretsManagerConfigurationProviderOptions(secretName);
configureOptions?.Invoke(options);

return builder.AddSecretsManager(options, client);
}

/// <summary>
/// Adds secrets manager as configuration source.
/// </summary>
/// <remarks>
/// This extension methods uses default <see cref="AmazonSecretsManagerClient"/> instance.
/// </remarks>
/// <param name="builder">Configuration builder.</param>
/// <param name="secretNames">Collection of secret names to fetch from AWS Secrets Manager.</param>
/// <param name="configureOptions">A delegate that is invoked to set up the AWS Secrets Manager Configuration options.</param>
/// <returns>Instance of <see cref="IConfigurationBuilder"/>.</returns>
public static IConfigurationBuilder AddSecretsManager(
this IConfigurationBuilder builder,
IReadOnlyList<string> secretNames,
Action<SecretsManagerConfigurationProviderOptions>? configureOptions = null)
{
var client = new AmazonSecretsManagerClient();
return builder.AddSecretsManager(secretNames, client, configureOptions);
}

/// <summary>
/// Adds secrets manager as configuration source.
/// </summary>
/// <param name="builder">Configuration builder.</param>
/// <param name="secretNames">Collection of secret names to fetch from AWS Secrets Manager.</param>
/// <param name="client">AWS Secrets Manager client.</param>
/// <param name="configureOptions">A delegate that is invoked to set up the AWS Secrets Manager Configuration options.</param>
/// <returns>Instance of <see cref="IConfigurationBuilder"/>.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="secretNames"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="secretNames"/> is empty.</exception>
public static IConfigurationBuilder AddSecretsManager(
this IConfigurationBuilder builder,
IReadOnlyList<string> secretNames,
IAmazonSecretsManager client,
Action<SecretsManagerConfigurationProviderOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(secretNames);

if (secretNames.Count == 0)
{
ThrowOnEmptySecretNames(secretNames);
}
else if (secretNames.Count == 1)
{
CreateAndConfigureOptions(builder, secretNames[0]);
}
else
{
foreach (var secretName in secretNames)
{
CreateAndConfigureOptions(builder, secretName);
}
}

return builder;

void CreateAndConfigureOptions(IConfigurationBuilder cb, string secretName)
{
var options = new SecretsManagerConfigurationProviderOptions(secretName);
configureOptions?.Invoke(options);

cb.AddSecretsManager(options, client);
}
}

/// <summary>
/// Adds secrets manager as configuration source.
/// </summary>
/// <param name="builder">Configuration builder.</param>
/// <param name="options">Secrets Manager configuration provider options.</param>
/// <returns>Instance of <see cref="IConfigurationBuilder"/>.</returns>
public static IConfigurationBuilder AddSecretsManager(
this IConfigurationBuilder builder,
SecretsManagerConfigurationProviderOptions options)
{
var client = new AmazonSecretsManagerClient();
return builder.AddSecretsManager(options, client);
}

/// <summary>
/// Adds secrets manager as configuration source.
/// </summary>
/// <param name="builder">Configuration builder.</param>
/// <param name="options">Secrets Manager configuration provider options.</param>
/// <param name="client">AWS Secrets Manager client.</param>
/// <returns>Instance of <see cref="IConfigurationBuilder"/>.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="options"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> is <see langword="null"/>.</exception>
public static IConfigurationBuilder AddSecretsManager(
this IConfigurationBuilder builder,
SecretsManagerConfigurationProviderOptions options,
IAmazonSecretsManager client)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(client);

return builder.Add(new SecretsManagerConfigurationSource(options, client));
}

[DoesNotReturn]
private static void ThrowOnEmptySecretNames(
IReadOnlyCollection<string> secretNames,
[CallerArgumentExpression("secretNames")] string? paramName = null)
{
throw new ArgumentException("At least one secret name must be provided.", paramName);
}
}

0 comments on commit 3cfb355

Please sign in to comment.