refactor(securestore): store entire connection strings in SecureStore

Eliminates placeholder substitution (${KEY}) in favor of storing complete
connection strings as single encrypted values. SecureStore now auto-creates
entries for all connection strings defined in appsettings. ConfigManager
editor reads/writes values directly to SecureStore.
This commit is contained in:
Joseph Doherty
2026-01-23 14:44:04 -05:00
parent ba54a87be5
commit bfc1c8064a
16 changed files with 462 additions and 279 deletions
@@ -71,11 +71,15 @@ public interface ISecureStoreManager
void RemoveSecret(string key);
/// <summary>
/// Ensures all required keys exist in the store, creating blank values for any missing keys.
/// Ensures all required entries exist in the store, creating blank values for any missing keys.
/// Handles both general required keys and connection string names.
/// </summary>
/// <param name="requiredKeys">List of keys that must exist.</param>
/// <param name="requiredKeys">List of general secret keys that must exist.</param>
/// <param name="connectionStringNames">List of connection string names that must exist.</param>
/// <returns>List of keys that were added.</returns>
IReadOnlyList<string> EnsureRequiredKeys(IEnumerable<string> requiredKeys);
IReadOnlyList<string> EnsureAllRequiredEntries(
IEnumerable<string> requiredKeys,
IEnumerable<string> connectionStringNames);
/// <summary>
/// Generates a new key file for use with store encryption.
@@ -193,7 +193,9 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable
}
/// <inheritdoc />
public IReadOnlyList<string> EnsureRequiredKeys(IEnumerable<string> requiredKeys)
public IReadOnlyList<string> EnsureAllRequiredEntries(
IEnumerable<string> requiredKeys,
IEnumerable<string> connectionStringNames)
{
ThrowIfDisposed();
@@ -201,11 +203,15 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable
throw new InvalidOperationException("No store is currently open.");
var addedKeys = new List<string>();
foreach (var key in requiredKeys)
var allRequired = requiredKeys
.Concat(connectionStringNames)
.Distinct();
foreach (var key in allRequired)
{
if (!_keys.Contains(key))
{
_logger.LogInformation("Adding missing required key: {Key}", key);
_logger.LogInformation("Adding missing required entry: {Key}", key);
SetSecret(key, string.Empty);
addedKeys.Add(key);
}
@@ -2,16 +2,18 @@ using System.Collections.ObjectModel;
using System.Windows.Input;
using JdeScoping.ConfigManager.Models;
using JdeScoping.ConfigManager.Services;
using JdeScoping.ConfigManager.Services.SecureStore;
namespace JdeScoping.ConfigManager.ViewModels.Forms;
/// <summary>
/// ViewModel for editing the ConnectionStrings configuration section.
/// Manages a collection of connection string entries with add, delete, validate, and test commands.
/// ViewModel for editing connection strings stored in SecureStore.
/// Connection string names come from configuration, values come from SecureStore.
/// </summary>
public class ConnectionStringsFormViewModel : ViewModelBase
{
private readonly ConnectionStringsSection _model;
private readonly ISecureStoreManager _secureStoreManager;
private readonly Action _onChanged;
private readonly IDialogService _dialogService;
private readonly IConnectionTestService _connectionTestService;
@@ -21,27 +23,42 @@ public class ConnectionStringsFormViewModel : ViewModelBase
/// <summary>
/// Initializes a new instance of the <see cref="ConnectionStringsFormViewModel"/> class.
/// </summary>
/// <param name="model">The connection strings section model.</param>
/// <param name="model">The connection strings section model (provides names).</param>
/// <param name="secureStoreManager">The SecureStore manager for reading/writing values.</param>
/// <param name="onChanged">The action to invoke when any property changes.</param>
/// <param name="dialogService">The dialog service for showing messages and confirmations.</param>
/// <param name="connectionTestService">The service for testing database connections.</param>
public ConnectionStringsFormViewModel(
ConnectionStringsSection model,
ISecureStoreManager secureStoreManager,
Action onChanged,
IDialogService dialogService,
IConnectionTestService connectionTestService)
{
_model = model ?? throw new ArgumentNullException(nameof(model));
_secureStoreManager = secureStoreManager ?? throw new ArgumentNullException(nameof(secureStoreManager));
_onChanged = onChanged ?? throw new ArgumentNullException(nameof(onChanged));
_dialogService = dialogService ?? throw new ArgumentNullException(nameof(dialogService));
_connectionTestService = connectionTestService ?? throw new ArgumentNullException(nameof(connectionTestService));
Connections = new ObservableCollection<ConnectionStringEntryViewModel>();
// Initialize view models from model entries
// Initialize view models from model entries, loading values from SecureStore
foreach (var entry in _model.Entries)
{
Connections.Add(new ConnectionStringEntryViewModel(entry, _onChanged));
// Load the actual connection string value from SecureStore
var secureStoreValue = _secureStoreManager.IsStoreOpen && !string.IsNullOrEmpty(entry.Name)
? TryGetSecret(entry.Name)
: null;
// Update entry's RawConnectionString with SecureStore value if available
if (!string.IsNullOrEmpty(secureStoreValue))
{
entry.RawConnectionString = secureStoreValue;
entry.Provider = ConnectionProvider.Generic; // Use Generic since we have the full string
}
Connections.Add(new ConnectionStringEntryViewModel(entry, OnEntryChanged));
}
// Initialize commands
@@ -51,6 +68,32 @@ public class ConnectionStringsFormViewModel : ViewModelBase
TestConnectionCommand = new AsyncRelayCommand(TestConnectionAsync, () => HasSelection && !IsTesting);
}
private string? TryGetSecret(string key)
{
try
{
return _secureStoreManager.GetSecret(key);
}
catch (KeyNotFoundException)
{
return null;
}
}
private void OnEntryChanged()
{
// When an entry changes, save its value to SecureStore
if (SelectedConnection != null && !string.IsNullOrEmpty(SelectedConnection.Name))
{
var connectionString = SelectedConnection.GeneratedConnectionString;
if (_secureStoreManager.IsStoreOpen)
{
_secureStoreManager.SetSecret(SelectedConnection.Name, connectionString);
}
}
_onChanged();
}
/// <summary>
/// Gets the collection of connection string entry view models.
/// </summary>
@@ -136,12 +179,19 @@ public class ConnectionStringsFormViewModel : ViewModelBase
{
var entry = new ConnectionStringEntry
{
Name = "NewConnection"
Name = "NewConnection",
Provider = ConnectionProvider.Generic
};
_model.Entries.Add(entry);
var viewModel = new ConnectionStringEntryViewModel(entry, _onChanged);
// Create empty entry in SecureStore
if (_secureStoreManager.IsStoreOpen)
{
_secureStoreManager.SetSecret(entry.Name, string.Empty);
}
var viewModel = new ConnectionStringEntryViewModel(entry, OnEntryChanged);
Connections.Add(viewModel);
SelectedConnection = viewModel;
@@ -160,11 +210,24 @@ public class ConnectionStringsFormViewModel : ViewModelBase
var name = SelectedConnection.Name;
var confirmed = await _dialogService.ShowConfirmationAsync(
"Delete Connection",
$"Delete connection '{name}'?");
$"Delete connection '{name}'? This will also remove the SecureStore entry.");
if (!confirmed)
return;
// Remove from SecureStore
if (_secureStoreManager.IsStoreOpen && !string.IsNullOrEmpty(name))
{
try
{
_secureStoreManager.RemoveSecret(name);
}
catch (KeyNotFoundException)
{
// Entry didn't exist in SecureStore, that's OK
}
}
// Find the model entry to remove
var modelEntry = _model.Entries.FirstOrDefault(e => e.Name == name);
if (modelEntry != null)
@@ -346,14 +346,18 @@ public class MainWindowViewModel : ViewModelBase
_secureStoreManager.OpenStore(storePath, keyFilePath);
}
// Ensure all required keys exist
if (secureStoreConfig.RequiredKeys?.Count > 0)
// Ensure all required entries exist (both RequiredKeys and connection strings)
var connectionStringNames = _appSettings?.ConnectionStrings?.Entries
.Select(e => e.Name)
.Where(n => !string.IsNullOrEmpty(n))
?? Enumerable.Empty<string>();
var requiredKeys = secureStoreConfig.RequiredKeys ?? new List<string>();
var addedKeys = _secureStoreManager.EnsureAllRequiredEntries(requiredKeys, connectionStringNames);
if (addedKeys.Count > 0)
{
var addedKeys = _secureStoreManager.EnsureRequiredKeys(secureStoreConfig.RequiredKeys);
if (addedKeys.Count > 0)
{
_logger?.LogInformation("Added {Count} missing required keys", addedKeys.Count);
}
_logger?.LogInformation("Added {Count} missing required SecureStore entries", addedKeys.Count);
}
}
catch (Exception ex)
@@ -575,6 +579,7 @@ public class MainWindowViewModel : ViewModelBase
"ExcelExport" => new ExcelExportFormViewModel(_appSettings.ExcelExport, MarkAsChanged),
"ConnectionStrings" when _dialogService != null => new ConnectionStringsFormViewModel(
_appSettings.ConnectionStrings,
_secureStoreManager,
MarkAsChanged,
_dialogService,
_connectionTestService),