Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 23 additions & 75 deletions src/Vault/Configuration/VaultConfigurationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,19 @@ namespace Vault.Configuration;
public static class VaultConfigurationExtensions
{
/// <summary>
/// Adds HashiCorp Vault as a configuration source.
/// Note: AddVault() must be called BEFORE this method to register VaultService.
/// Adds HashiCorp Vault as a configuration source with an existing VaultService.
/// Secrets are loaded immediately during Build() so they are available for
/// subsequent configuration such as Entity Framework connection strings.
/// </summary>
/// <param name="builder">The configuration builder.</param>
/// <param name="environment">The Vault environment to load (e.g., DEV, PROD).</param>
/// <param name="vaultService">VaultService instance to use.</param>
/// <param name="configureSource">Optional action to configure the source.</param>
/// <returns>The configuration builder for chaining.</returns>
public static IConfigurationBuilder AddVaultConfiguration(
this IConfigurationBuilder builder,
string environment,
IVaultService vaultService,
Action<VaultConfigurationSource>? configureSource = null)
{
if (builder == null)
Expand All @@ -35,29 +38,39 @@ public static IConfigurationBuilder AddVaultConfiguration(
nameof(environment));
}

if (vaultService == null)
{
throw new ArgumentNullException(nameof(vaultService));
}

var source = new VaultConfigurationSource
{
Environment = environment
Environment = environment,
VaultService = vaultService
};

configureSource?.Invoke(source);

// The source will create the provider and load secrets immediately in Build()
return builder.Add(source);
}

/// <summary>
/// Adds HashiCorp Vault as a configuration source with an existing VaultService.
/// Useful for tests or when VaultService is created manually.
/// Adds HashiCorp Vault as a configuration source with an existing VaultService and logger.
/// Secrets are loaded immediately during Build() so they are available for
/// subsequent configuration such as Entity Framework connection strings.
/// </summary>
/// <param name="builder">The configuration builder.</param>
/// <param name="environment">The Vault environment to load (e.g., DEV, PROD).</param>
/// <param name="vaultService">VaultService instance to use.</param>
/// <param name="logger">Optional logger for the configuration provider.</param>
/// <param name="configureSource">Optional action to configure the source.</param>
/// <returns>The configuration builder for chaining.</returns>
public static IConfigurationBuilder AddVaultConfiguration(
this IConfigurationBuilder builder,
string environment,
IVaultService vaultService,
ILogger<VaultConfigurationProvider>? logger,
Action<VaultConfigurationSource>? configureSource = null)
{
if (builder == null)
Expand All @@ -79,79 +92,14 @@ public static IConfigurationBuilder AddVaultConfiguration(

var source = new VaultConfigurationSource
{
Environment = environment
Environment = environment,
VaultService = vaultService,
Logger = logger
};

configureSource?.Invoke(source);

var provider = new VaultConfigurationProvider(source, vaultService);
builder.Add(new VaultConfigurationSourceWrapper(source, provider));

return builder;
}

/// <summary>
/// Inject VaultService into all existing VaultConfigurationProvider instances.
/// To be called after IConfiguration is built to initialize the providers.
/// </summary>
/// <param name="configuration">The built configuration.</param>
/// <param name="serviceProvider">The service provider containing VaultService.</param>
public static void InitializeVaultProviders(
this IConfiguration configuration,
IServiceProvider serviceProvider)
{
if (configuration == null)
{
throw new ArgumentNullException(nameof(configuration));
}

if (serviceProvider == null)
{
throw new ArgumentNullException(nameof(serviceProvider));
}

if (configuration is not IConfigurationRoot configurationRoot)
{
return;
}

var vaultService = serviceProvider.GetService<IVaultService>();
if (vaultService == null)
{
return;
}

var logger = serviceProvider.GetService<ILogger<VaultConfigurationProvider>>();

foreach (var provider in configurationRoot.Providers)
{
if (provider is VaultConfigurationProvider vaultProvider)
{
vaultProvider.SetVaultService(vaultService, logger);
vaultProvider.Load();
}
}
}

/// <summary>
/// Wrapper to allow manual provider injection.
/// </summary>
private class VaultConfigurationSourceWrapper : IConfigurationSource
{
private readonly VaultConfigurationSource _source;
private readonly VaultConfigurationProvider _provider;

public VaultConfigurationSourceWrapper(
VaultConfigurationSource source,
VaultConfigurationProvider provider)
{
_source = source;
_provider = provider;
}

public IConfigurationProvider Build(IConfigurationBuilder builder)
{
return _provider;
}
// The source will create the provider and load secrets immediately in Build()
return builder.Add(source);
}
}
111 changes: 50 additions & 61 deletions src/Vault/Configuration/VaultConfigurationProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,17 @@ public class VaultConfigurationProvider
: ConfigurationProvider, IDisposable
{
private readonly VaultConfigurationSource _source;
private readonly IVaultService? _vaultService;
private readonly IVaultService _vaultService;
private readonly ILogger? _logger;
private Timer? _reloadTimer;
private bool _disposed;

/// <summary>
/// Initializes a new instance of the <see cref="VaultConfigurationProvider"/> class.
/// Main constructor used when VaultService is already available.
/// </summary>
/// <param name="source">The configuration source.</param>
/// <param name="vaultService">The Vault service instance.</param>
/// <param name="logger">Optional logger for the provider.</param>
public VaultConfigurationProvider(
VaultConfigurationSource source,
IVaultService vaultService,
Expand All @@ -38,22 +40,6 @@ public VaultConfigurationProvider(
}
}

/// <summary>
/// Initializes a new instance of the <see cref="VaultConfigurationProvider"/> class.
/// Constructor for compatibility with IConfigurationSource.Build
/// VaultService will be injected later via SetVaultService.
/// </summary>
internal VaultConfigurationProvider(VaultConfigurationSource source)
{
_source = source ?? throw new ArgumentNullException(nameof(source));

if (string.IsNullOrWhiteSpace(_source.Environment))
{
throw new InvalidOperationException(
"Vault environment must be specified (e.g., DEV, PROD)");
}
}

/// <summary>
/// Load secrets from Vault.
/// </summary>
Expand All @@ -79,28 +65,6 @@ public void Dispose()
GC.SuppressFinalize(this);
}

/// <summary>
/// Inject VaultService after creation (used by extension method).
/// </summary>
internal void SetVaultService(IVaultService vaultService, ILogger<VaultConfigurationProvider>? logger = null)
{
if (_vaultService != null)
{
throw new InvalidOperationException("VaultService already set");
}

// Use reflection to assign the readonly field
var field = typeof(VaultConfigurationProvider).GetField(
"_vaultService",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
field?.SetValue(this, vaultService);

var loggerField = typeof(VaultConfigurationProvider).GetField(
"_logger",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
loggerField?.SetValue(this, logger);
}

/// <summary>
/// Convert an object value to string.
/// </summary>
Expand All @@ -120,35 +84,60 @@ internal void SetVaultService(IVaultService vaultService, ILogger<VaultConfigura
}

/// <summary>
/// Check if a value is JSON.
/// Check if a value is JSON (object or array).
/// Handles both string values and JsonElement values.
/// </summary>
private static bool IsJsonValue(object? value)
private static bool IsJsonValue(object? value, out string? jsonString)
{
if (value is not string str)
jsonString = null;

if (value == null)
{
return false;
}

str = str.Trim();
return (str.StartsWith("{") && str.EndsWith("}")) || (str.StartsWith("[") && str.EndsWith("]"));
}

private async Task LoadAsync()
{
if (_vaultService == null)
// Handle JsonElement from VaultSharp
if (value is JsonElement jsonElement)
{
var errorMessage = "VaultService is not initialized. " +
"Make sure to call AddVault() before AddVaultConfiguration()";
if (jsonElement.ValueKind == JsonValueKind.Object ||
jsonElement.ValueKind == JsonValueKind.Array)
{
jsonString = jsonElement.GetRawText();
return true;
}

if (_source.Optional)
if (jsonElement.ValueKind == JsonValueKind.String)
{
_logger?.LogWarning(errorMessage);
return;
var str = jsonElement.GetString()?.Trim();
if (!string.IsNullOrEmpty(str) &&
((str.StartsWith("{") && str.EndsWith("}")) ||
(str.StartsWith("[") && str.EndsWith("]"))))
{
jsonString = str;
return true;
}
}

throw new InvalidOperationException(errorMessage);
return false;
}

// Handle string values
if (value is string strValue)
{
var trimmed = strValue.Trim();
if ((trimmed.StartsWith("{") && trimmed.EndsWith("}")) ||
(trimmed.StartsWith("[") && trimmed.EndsWith("]")))
{
jsonString = trimmed;
return true;
}
}

return false;
}

private async Task LoadAsync()
{
try
{
_logger?.LogInformation(
Expand All @@ -162,9 +151,9 @@ private async Task LoadAsync()
foreach (var kvp in secrets)
{
// If the value is JSON, flatten it
if (IsJsonValue(kvp.Value))
if (IsJsonValue(kvp.Value, out var jsonString))
{
FlattenJsonValue(kvp.Key, kvp.Value, data);
FlattenJsonValue(kvp.Key, jsonString, data);
}
else
{
Expand Down Expand Up @@ -229,11 +218,11 @@ private void LoadAndNotifyChange()
}

/// <summary>
/// Flatten a JSON value into dotted keys with dot notation.
/// Flatten a JSON value into configuration keys.
/// </summary>
private void FlattenJsonValue(string parentKey, object? value, Dictionary<string, string?> data)
private void FlattenJsonValue(string parentKey, string? jsonString, Dictionary<string, string?> data)
{
if (value is not string jsonString)
if (string.IsNullOrEmpty(jsonString))
{
return;
}
Expand Down
27 changes: 26 additions & 1 deletion src/Vault/Configuration/VaultConfigurationSource.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Vault.Abstractions;

namespace Vault.Configuration;

Expand Down Expand Up @@ -29,11 +31,34 @@ public class VaultConfigurationSource
/// </summary>
public int ReloadIntervalSeconds { get; set; } = 300; // 5 minutes by default

/// <summary>
/// Gets or sets the Vault service instance to use for loading secrets.
/// </summary>
internal IVaultService? VaultService { get; set; }

/// <summary>
/// Gets or sets the logger for the configuration provider.
/// </summary>
internal ILogger<VaultConfigurationProvider>? Logger { get; set; }

/// <summary>
/// Build the configuration provider.
/// Secrets are loaded immediately to make them available for subsequent
/// configuration (e.g., connection strings for Entity Framework).
/// </summary>
/// <exception cref="InvalidOperationException">Thrown when VaultService is not set.</exception>
public IConfigurationProvider Build(IConfigurationBuilder builder)
{
return new VaultConfigurationProvider(this);
if (VaultService == null)
{
throw new InvalidOperationException(
"VaultService must be set. Use AddVault() or AddVaultConfiguration() with a VaultService instance.");
}

// Create provider with VaultService and load immediately
var provider = new VaultConfigurationProvider(this, VaultService, Logger);
provider.Load();

return provider;
}
Comment on lines 44 to 63
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Build method calls provider.Load() synchronously on line 60, which internally calls LoadAsync().GetAwaiter().GetResult(). This blocks the thread during configuration build and could cause deadlocks in some contexts (e.g., if called from a UI thread or in certain ASP.NET Core synchronization contexts). While this is intentional for immediate loading, consider documenting this blocking behavior in the XML documentation comment to warn users about potential performance implications during application startup.

Copilot uses AI. Check for mistakes.
}
Loading
Loading