Integrate OCORE/OCORE_web-based secret management using Azure Key Vault and DPAPI cache. Update appsettings.json to remove plaintext secrets and list managed keys. Register secret management in Program.cs. Update .gitignore for secret files. Add documentation for naming conventions and migration, plus a PowerShell script for initial secret upload. Centralizes and secures secret handling across the app.
This commit is contained in:
@@ -31,3 +31,15 @@
|
||||
- Whenever possible, prefer OCORE_web_pdf / OCORE PDF functions for PDF-related tasks over rewriting.
|
||||
- Do not use OCMS or OCMS_sharp; use only OCORE or OCORE_web.
|
||||
|
||||
## Azure Key Vault — Secret Naming
|
||||
- Secret names must satisfy the pattern `^[0-9a-zA-Z-]+$` (alphanumerics and hyphens only; no underscores, dots, or spaces).
|
||||
- Hierarchy levels are separated by `--` (double hyphen), which maps to `:` in `IConfiguration`.
|
||||
- Underscores within a name segment are encoded as a single `-` in Key Vault and decoded back to `_` when the key is reconstructed.
|
||||
- The app prefix `fuchs` is prepended to every secret name.
|
||||
- Format: `{appname}--{Section}--{key-with-hyphens-for-underscores}`
|
||||
- Examples:
|
||||
- `fuchs--ConnectionStrings--ocms-ConnectionString` → `ConnectionStrings:ocms_ConnectionString`
|
||||
- `fuchs--Fuchs--SMS-APIKey` → `Fuchs:SMS_APIKey`
|
||||
- `fuchs--Fuchs--Email--Main--password` → `Fuchs:Email:Main:password`
|
||||
- When adding new secrets: replace every `_` in the original config key with `-` for the Key Vault name, and add the entry to `ManagedSecretKeys` in `appsettings.json` (using the same hyphenated form without the `fuchs--` prefix).
|
||||
|
||||
|
||||
@@ -25,3 +25,9 @@ dist/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
# Secret Management DPAPI cache
|
||||
secrets.cache
|
||||
**/secrets.cache
|
||||
|
||||
# Secret values file (never commit)
|
||||
Scripts/secrets.json
|
||||
|
||||
@@ -0,0 +1,933 @@
|
||||
# Agent Instructions: Key Vault + DPAPI — Multi-App Architektur (15 Projekte, 1 Server)
|
||||
|
||||
## Ausgangslage
|
||||
|
||||
- 15 Anwendungen (Web Apps, Console Apps, Windows Services, Worker Services)
|
||||
- Alle laufen auf demselben Windows-Server
|
||||
- Ein gemeinsamer Azure Key Vault
|
||||
- Secrets sind aktuell in `appsettings.json`, `web.config` oder Umgebungsvariablen
|
||||
- Zwei bestehende Shared Projects: **OCORE** und **OCORE_web**
|
||||
|
||||
---
|
||||
|
||||
## Aufteilung zwischen OCORE und OCORE_web
|
||||
|
||||
```
|
||||
OCORE OCORE_web
|
||||
────────────────────────────── ──────────────────────────────
|
||||
Alles was NICHT Web-spezifisch Nur was WebApplicationBuilder
|
||||
ist. Funktioniert für: benötigt. Funktioniert für:
|
||||
|
||||
- Console Apps - ASP.NET Core Web Apps
|
||||
- Windows Services - ASP.NET Core Web APIs
|
||||
- Worker Services - Razor Pages / MVC
|
||||
- WinForms / WPF
|
||||
- Web Apps (via OCORE_web)
|
||||
|
||||
Enthält: Enthält:
|
||||
AppSecretsOptions SecretManagementWebExtensions
|
||||
DpapiSecretsCache (eine einzige Datei)
|
||||
DpapiCacheConfigurationSource
|
||||
KeyVaultSyncService
|
||||
PrefixKeyVaultSecretManager
|
||||
SecretManagementExtensions
|
||||
(IHostBuilder)
|
||||
```
|
||||
|
||||
> OCORE_web referenziert OCORE — d.h. Web Apps brauchen nur OCORE_web
|
||||
> zu referenzieren, OCORE kommt transitiv mit.
|
||||
|
||||
---
|
||||
|
||||
## Architektur-Übersicht
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Azure Key Vault │
|
||||
│ │
|
||||
│ crm--ConnectionStrings--Database → CRM Web App │
|
||||
│ crm--ExternalApi--ApiKey → CRM Web App │
|
||||
│ erp--ConnectionStrings--Database → ERP Web App │
|
||||
│ erp--Smtp--Password → ERP Web App │
|
||||
│ importer--ConnectionStrings--Db → Importer Console App │
|
||||
│ scheduler--ConnectionStrings--Db → Scheduler Windows Svc │
|
||||
│ ... │
|
||||
└──────────────────────────┬──────────────────────────────────────┘
|
||||
│ Managed Identity (Server-Level)
|
||||
┌──────────────────────────▼──────────────────────────────────────┐
|
||||
│ Windows Server │
|
||||
│ │
|
||||
│ C:\inetpub\crm\ secrets.cache (DPAPI, crm-Entropy) │
|
||||
│ C:\inetpub\erp\ secrets.cache (DPAPI, erp-Entropy) │
|
||||
│ C:\services\importer\ secrets.cache (DPAPI, importer-Entropy) │
|
||||
│ C:\services\scheduler\secrets.cache (DPAPI, scheduler-Entropy)│
|
||||
│ ... │
|
||||
│ │
|
||||
│ OCORE.dll ← alle 15 Apps referenzieren dies │
|
||||
│ OCORE_web.dll ← nur Web Apps referenzieren dies zusätzlich │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Wichtige Designentscheidungen
|
||||
|
||||
| Thema | Entscheidung | Begründung |
|
||||
|---|---|---|
|
||||
| Key Vault | Ein gemeinsamer | Einfacher zu verwalten, RBAC auf Secret-Ebene möglich |
|
||||
| Namespacing | `{appname}--{key}` Präfix | Verhindert Kollisionen zwischen Apps |
|
||||
| DPAPI Scope | `LocalMachine` + App-spezifische Entropy | Isolation: App A kann Cache von App B nicht lesen |
|
||||
| Basiscode | OCORE | Kein Web-Framework nötig, funktioniert überall |
|
||||
| Web-Extension | OCORE_web | Nur eine Datei, dünne Schicht über OCORE |
|
||||
| App-Identity | Eine Server-Managed-Identity | Alle Apps teilen dieselbe Identity, RBAC per Secret |
|
||||
|
||||
---
|
||||
|
||||
## Teil 1: OCORE — NuGet-Pakete hinzufügen
|
||||
|
||||
```bash
|
||||
cd OCORE
|
||||
dotnet add package Azure.Extensions.AspNetCore.Configuration.Secrets
|
||||
dotnet add package Azure.Identity
|
||||
dotnet add package Azure.Security.KeyVault.Secrets
|
||||
dotnet add package Microsoft.Extensions.Hosting.Abstractions
|
||||
dotnet add package Microsoft.Extensions.Configuration.Abstractions
|
||||
dotnet add package Microsoft.Extensions.Logging.Abstractions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Teil 2: OCORE — Dateien anlegen
|
||||
|
||||
Neue Ordnerstruktur **innerhalb von OCORE** (bestehende Struktur bleibt unverändert):
|
||||
|
||||
```
|
||||
OCORE/
|
||||
... (bestehende Dateien)
|
||||
Secrets/
|
||||
Models/
|
||||
AppSecretsOptions.cs
|
||||
Cache/
|
||||
DpapiSecretsCache.cs
|
||||
DpapiCacheConfigurationSource.cs
|
||||
Sync/
|
||||
KeyVaultSyncService.cs
|
||||
Extensions/
|
||||
SecretManagementExtensions.cs ← IHostBuilder Extension + gemeinsame Logik
|
||||
PrefixKeyVaultSecretManager.cs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.1 OCORE/Secrets/Models/AppSecretsOptions.cs
|
||||
|
||||
```csharp
|
||||
namespace OCORE.Secrets.Models;
|
||||
|
||||
public class AppSecretsOptions
|
||||
{
|
||||
public const string SectionName = "SecretManagement";
|
||||
|
||||
/// <summary>
|
||||
/// Vollständige URI des Azure Key Vault.
|
||||
/// Beispiel: https://company-vault.vault.azure.net/
|
||||
/// </summary>
|
||||
public string VaultUri { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// App-Name als Präfix für alle Secrets in Key Vault.
|
||||
/// Beispiel: "crm" → Secret "crm--ConnectionStrings--Database"
|
||||
/// Nur Kleinbuchstaben und Bindestriche (Key Vault Konvention).
|
||||
/// </summary>
|
||||
public string AppName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Pfad zur DPAPI-Cache-Datei (relativ zum App-Verzeichnis).
|
||||
/// </summary>
|
||||
public string CacheFilePath { get; set; } = "secrets.cache";
|
||||
|
||||
/// <summary>
|
||||
/// Sync-Intervall in Stunden.
|
||||
/// </summary>
|
||||
public int SyncIntervalHours { get; set; } = 6;
|
||||
|
||||
/// <summary>
|
||||
/// Secret-Namen ohne App-Präfix.
|
||||
/// Beispiel: ["ConnectionStrings--Database", "ExternalApi--ApiKey"]
|
||||
/// </summary>
|
||||
public List<string> ManagedSecretKeys { get; set; } = [];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.2 OCORE/Secrets/Cache/DpapiSecretsCache.cs
|
||||
|
||||
```csharp
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace OCORE.Secrets.Cache;
|
||||
|
||||
public interface IDpapiSecretsCache
|
||||
{
|
||||
Task WriteAsync(Dictionary<string, string> secrets);
|
||||
Task<Dictionary<string, string>> ReadAsync();
|
||||
bool Exists();
|
||||
}
|
||||
|
||||
public class DpapiSecretsCache : IDpapiSecretsCache
|
||||
{
|
||||
private readonly string _filePath;
|
||||
private readonly byte[] _entropy;
|
||||
private readonly ILogger<DpapiSecretsCache> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Entropy wird aus AppName abgeleitet — verhindert dass eine andere App
|
||||
/// auf demselben Server den Cache dieser App lesen kann.
|
||||
/// </summary>
|
||||
public DpapiSecretsCache(string filePath, string appName, ILogger<DpapiSecretsCache> logger)
|
||||
{
|
||||
_filePath = filePath;
|
||||
_entropy = Encoding.UTF8.GetBytes($"dpapi-entropy-{appName}");
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task WriteAsync(Dictionary<string, string> secrets)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(secrets);
|
||||
var encrypted = ProtectedData.Protect(json, _entropy, DataProtectionScope.LocalMachine);
|
||||
await File.WriteAllBytesAsync(_filePath, encrypted);
|
||||
_logger.LogInformation("[OCORE.Secrets] DPAPI Cache geschrieben: {Count} Secrets → {Path}",
|
||||
secrets.Count, _filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[OCORE.Secrets] DPAPI Cache konnte nicht geschrieben werden: {Path}", _filePath);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, string>> ReadAsync()
|
||||
{
|
||||
if (!Exists()) return [];
|
||||
|
||||
try
|
||||
{
|
||||
var encrypted = await File.ReadAllBytesAsync(_filePath);
|
||||
var decrypted = ProtectedData.Unprotect(encrypted, _entropy, DataProtectionScope.LocalMachine);
|
||||
return JsonSerializer.Deserialize<Dictionary<string, string>>(decrypted) ?? [];
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"[OCORE.Secrets] DPAPI Entschlüsselung fehlgeschlagen — AppName korrekt? {Path}", _filePath);
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[OCORE.Secrets] DPAPI Cache Lesefehler: {Path}", _filePath);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public bool Exists() => File.Exists(_filePath);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.3 OCORE/Secrets/Cache/DpapiCacheConfigurationSource.cs
|
||||
|
||||
```csharp
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace OCORE.Secrets.Cache;
|
||||
|
||||
public class DpapiCacheConfigurationSource : IConfigurationSource
|
||||
{
|
||||
private readonly string _filePath;
|
||||
private readonly string _appName;
|
||||
|
||||
public DpapiCacheConfigurationSource(string filePath, string appName)
|
||||
{
|
||||
_filePath = filePath;
|
||||
_appName = appName;
|
||||
}
|
||||
|
||||
public IConfigurationProvider Build(IConfigurationBuilder builder)
|
||||
=> new DpapiCacheConfigurationProvider(_filePath, _appName);
|
||||
}
|
||||
|
||||
public class DpapiCacheConfigurationProvider : ConfigurationProvider
|
||||
{
|
||||
private readonly string _filePath;
|
||||
private readonly string _appName;
|
||||
|
||||
public DpapiCacheConfigurationProvider(string filePath, string appName)
|
||||
{
|
||||
_filePath = filePath;
|
||||
_appName = appName;
|
||||
}
|
||||
|
||||
public override void Load()
|
||||
{
|
||||
if (!File.Exists(_filePath)) return;
|
||||
|
||||
try
|
||||
{
|
||||
using var loggerFactory = LoggerFactory.Create(b => b.AddConsole());
|
||||
var logger = loggerFactory.CreateLogger<DpapiSecretsCache>();
|
||||
var cache = new DpapiSecretsCache(_filePath, _appName, logger);
|
||||
var secrets = cache.ReadAsync().GetAwaiter().GetResult();
|
||||
|
||||
// "crm--ConnectionStrings--Database" → "ConnectionStrings:Database"
|
||||
var prefix = $"{_appName}--";
|
||||
Data = secrets
|
||||
.Where(kvp => kvp.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
.ToDictionary(
|
||||
kvp => kvp.Key[prefix.Length..].Replace("--", ":"),
|
||||
kvp => (string?)kvp.Value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[OCORE.Secrets] DPAPI Cache Ladefehler: {ex.Message}");
|
||||
Data = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.4 OCORE/Secrets/Sync/KeyVaultSyncService.cs
|
||||
|
||||
```csharp
|
||||
using Azure.Security.KeyVault.Secrets;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OCORE.Secrets.Cache;
|
||||
using OCORE.Secrets.Models;
|
||||
|
||||
namespace OCORE.Secrets.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Läuft als BackgroundService in jedem Host-Typ:
|
||||
/// Web Apps, Console Apps, Windows Services, Worker Services.
|
||||
/// Synct Secrets aus Key Vault → DPAPI-Cache, periodisch.
|
||||
/// </summary>
|
||||
public class KeyVaultSyncService : BackgroundService
|
||||
{
|
||||
private readonly SecretClient _secretClient;
|
||||
private readonly IDpapiSecretsCache _cache;
|
||||
private readonly IConfigurationRoot _configRoot;
|
||||
private readonly AppSecretsOptions _options;
|
||||
private readonly ILogger<KeyVaultSyncService> _logger;
|
||||
|
||||
public KeyVaultSyncService(
|
||||
SecretClient secretClient,
|
||||
IDpapiSecretsCache cache,
|
||||
IConfiguration configuration,
|
||||
IOptions<AppSecretsOptions> options,
|
||||
ILogger<KeyVaultSyncService> logger)
|
||||
{
|
||||
_secretClient = secretClient;
|
||||
_cache = cache;
|
||||
_configRoot = (IConfigurationRoot)configuration;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await SyncAsync(stoppingToken);
|
||||
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromHours(_options.SyncIntervalHours));
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
{
|
||||
await SyncAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SyncAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("[OCORE.Secrets:{App}] Key Vault Sync gestartet", _options.AppName);
|
||||
var synced = new Dictionary<string, string>();
|
||||
|
||||
foreach (var key in _options.ManagedSecretKeys)
|
||||
{
|
||||
var fullName = $"{_options.AppName}--{key}";
|
||||
try
|
||||
{
|
||||
var secret = await _secretClient.GetSecretAsync(fullName, version: null, cancellationToken);
|
||||
synced[fullName] = secret.Value.Value;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("[OCORE.Secrets:{App}] Secret '{Name}' nicht geladen: {Error}",
|
||||
_options.AppName, fullName, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
if (synced.Count > 0)
|
||||
{
|
||||
await _cache.WriteAsync(synced);
|
||||
_configRoot.Reload();
|
||||
_logger.LogInformation("[OCORE.Secrets:{App}] Sync OK: {Count}/{Total}",
|
||||
_options.AppName, synced.Count, _options.ManagedSecretKeys.Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("[OCORE.Secrets:{App}] Sync: Keine Secrets geladen — Cache unverändert",
|
||||
_options.AppName);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.5 OCORE/Secrets/Extensions/PrefixKeyVaultSecretManager.cs
|
||||
|
||||
```csharp
|
||||
using Azure.Extensions.AspNetCore.Configuration.Secrets;
|
||||
using Azure.Security.KeyVault.Secrets;
|
||||
|
||||
namespace OCORE.Secrets.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Filtert beim Key Vault Laden nur Secrets mit dem App-Präfix
|
||||
/// und konvertiert den Namen in einen IConfiguration-Key.
|
||||
/// "crm--ConnectionStrings--Database" → "ConnectionStrings:Database"
|
||||
/// </summary>
|
||||
public class PrefixKeyVaultSecretManager : KeyVaultSecretManager
|
||||
{
|
||||
private readonly string _prefix;
|
||||
|
||||
public PrefixKeyVaultSecretManager(string appName)
|
||||
=> _prefix = $"{appName}--";
|
||||
|
||||
public override bool Load(SecretProperties secret)
|
||||
=> secret.Name.StartsWith(_prefix, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public override string GetKey(KeyVaultSecret secret)
|
||||
=> secret.Name[_prefix.Length..].Replace("--", ":");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.6 OCORE/Secrets/Extensions/SecretManagementExtensions.cs
|
||||
|
||||
```csharp
|
||||
using Azure.Identity;
|
||||
using Azure.Security.KeyVault.Secrets;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using OCORE.Secrets.Cache;
|
||||
using OCORE.Secrets.Models;
|
||||
using OCORE.Secrets.Sync;
|
||||
|
||||
namespace OCORE.Secrets.Extensions;
|
||||
|
||||
public static class SecretManagementExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Für Console Apps, Worker Services, Windows Services, WinForms/WPF.
|
||||
/// Verwendung: Host.CreateDefaultBuilder(args).AddSecretManagement()
|
||||
/// </summary>
|
||||
public static IHostBuilder AddSecretManagement(this IHostBuilder hostBuilder)
|
||||
{
|
||||
hostBuilder.ConfigureAppConfiguration((context, config) =>
|
||||
{
|
||||
var built = config.Build();
|
||||
var options = built.GetSection(AppSecretsOptions.SectionName).Get<AppSecretsOptions>()
|
||||
?? new AppSecretsOptions();
|
||||
|
||||
if (!IsConfigured(options)) return;
|
||||
|
||||
var cacheFilePath = ResolveCachePath(
|
||||
context.HostingEnvironment.ContentRootPath, options.CacheFilePath);
|
||||
|
||||
// 1. DPAPI-Cache (Fallback)
|
||||
config.Add(new DpapiCacheConfigurationSource(cacheFilePath, options.AppName));
|
||||
|
||||
// 2. Key Vault (primär — überschreibt Cache wenn erreichbar)
|
||||
TryAddKeyVault(config, options);
|
||||
});
|
||||
|
||||
hostBuilder.ConfigureServices((context, services) =>
|
||||
{
|
||||
RegisterCoreServices(services, context.Configuration);
|
||||
});
|
||||
|
||||
return hostBuilder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gemeinsame Service-Registrierung — wird von OCORE und OCORE_web verwendet.
|
||||
/// </summary>
|
||||
public static void RegisterCoreServices(IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var options = configuration
|
||||
.GetSection(AppSecretsOptions.SectionName)
|
||||
.Get<AppSecretsOptions>() ?? new AppSecretsOptions();
|
||||
|
||||
if (!IsConfigured(options)) return;
|
||||
|
||||
services.Configure<AppSecretsOptions>(
|
||||
configuration.GetSection(AppSecretsOptions.SectionName));
|
||||
|
||||
services.AddSingleton(new SecretClient(
|
||||
new Uri(options.VaultUri),
|
||||
new DefaultAzureCredential()));
|
||||
|
||||
services.AddSingleton<IDpapiSecretsCache>(sp =>
|
||||
new DpapiSecretsCache(
|
||||
ResolveCachePath(AppContext.BaseDirectory, options.CacheFilePath),
|
||||
options.AppName,
|
||||
sp.GetRequiredService<ILogger<DpapiSecretsCache>>()));
|
||||
|
||||
services.AddHostedService<KeyVaultSyncService>();
|
||||
}
|
||||
|
||||
public static void TryAddKeyVault(IConfigurationBuilder config, AppSecretsOptions options)
|
||||
{
|
||||
try
|
||||
{
|
||||
config.AddAzureKeyVault(
|
||||
new Uri(options.VaultUri),
|
||||
new DefaultAzureCredential(),
|
||||
new PrefixKeyVaultSecretManager(options.AppName));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[OCORE.Secrets] Key Vault nicht erreichbar: {ex.Message}");
|
||||
Console.WriteLine("[OCORE.Secrets] Starte mit DPAPI-Cache");
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsConfigured(AppSecretsOptions options)
|
||||
=> !string.IsNullOrEmpty(options.VaultUri) && !string.IsNullOrEmpty(options.AppName);
|
||||
|
||||
public static string ResolveCachePath(string basePath, string cacheFilePath)
|
||||
=> Path.IsPathRooted(cacheFilePath)
|
||||
? cacheFilePath
|
||||
: Path.Combine(basePath, cacheFilePath);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Teil 3: OCORE_web — Einzige neue Datei
|
||||
|
||||
OCORE_web referenziert OCORE bereits. Nur eine neue Datei hinzufügen:
|
||||
|
||||
```
|
||||
OCORE_web/
|
||||
... (bestehende Dateien)
|
||||
Secrets/
|
||||
SecretManagementWebExtensions.cs ← einzige neue Datei in OCORE_web
|
||||
```
|
||||
|
||||
### 3.1 OCORE_web/Secrets/SecretManagementWebExtensions.cs
|
||||
|
||||
```csharp
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using OCORE.Secrets.Extensions;
|
||||
using OCORE.Secrets.Models;
|
||||
|
||||
namespace OCORE_web.Secrets;
|
||||
|
||||
public static class SecretManagementWebExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Für ASP.NET Core Web Apps und Web APIs.
|
||||
/// Verwendung: builder.AddSecretManagement()
|
||||
/// </summary>
|
||||
public static WebApplicationBuilder AddSecretManagement(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
var options = builder.Configuration
|
||||
.GetSection(AppSecretsOptions.SectionName)
|
||||
.Get<AppSecretsOptions>() ?? new AppSecretsOptions();
|
||||
|
||||
if (!SecretManagementExtensions.IsConfigured(options)) return builder;
|
||||
|
||||
var cacheFilePath = SecretManagementExtensions.ResolveCachePath(
|
||||
builder.Environment.ContentRootPath, options.CacheFilePath);
|
||||
|
||||
// 1. DPAPI-Cache (Fallback)
|
||||
builder.Configuration.Add(
|
||||
new OCORE.Secrets.Cache.DpapiCacheConfigurationSource(cacheFilePath, options.AppName));
|
||||
|
||||
// 2. Key Vault (primär)
|
||||
SecretManagementExtensions.TryAddKeyVault(builder.Configuration, options);
|
||||
|
||||
// 3. Services — aus OCORE (kein duplizierter Code)
|
||||
SecretManagementExtensions.RegisterCoreServices(builder.Services, builder.Configuration);
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Teil 4: Verwendung je App-Typ
|
||||
|
||||
### Web App (referenziert OCORE_web)
|
||||
|
||||
```csharp
|
||||
// Program.cs
|
||||
using OCORE_web.Secrets;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.AddSecretManagement(); // ← aus OCORE_web
|
||||
|
||||
var app = builder.Build();
|
||||
app.Run();
|
||||
```
|
||||
|
||||
### Console App / Worker Service / Windows Service (referenziert OCORE)
|
||||
|
||||
```csharp
|
||||
// Program.cs
|
||||
using OCORE.Secrets.Extensions;
|
||||
|
||||
// Console / Worker
|
||||
var host = Host.CreateDefaultBuilder(args)
|
||||
.AddSecretManagement() // ← aus OCORE
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.AddHostedService<MyWorker>();
|
||||
})
|
||||
.Build();
|
||||
|
||||
await host.RunAsync();
|
||||
```
|
||||
|
||||
```csharp
|
||||
// Windows Service — identisch, nur UseWindowsService() zusätzlich
|
||||
var host = Host.CreateDefaultBuilder(args)
|
||||
.UseWindowsService()
|
||||
.AddSecretManagement() // ← aus OCORE
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.AddHostedService<MyWindowsService>();
|
||||
})
|
||||
.Build();
|
||||
|
||||
await host.RunAsync();
|
||||
```
|
||||
|
||||
### WinForms / WPF (referenziert OCORE)
|
||||
|
||||
```csharp
|
||||
// Program.cs — Generic Host nachrüsten
|
||||
using OCORE.Secrets.Extensions;
|
||||
|
||||
var host = Host.CreateDefaultBuilder()
|
||||
.AddSecretManagement() // ← aus OCORE
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton<MainForm>();
|
||||
})
|
||||
.Build();
|
||||
|
||||
Application.Run(host.Services.GetRequiredService<MainForm>());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Teil 5: appsettings.json (identisch für alle App-Typen)
|
||||
|
||||
```json
|
||||
{
|
||||
"SecretManagement": {
|
||||
"VaultUri": "https://company-vault.vault.azure.net/",
|
||||
"AppName": "crm",
|
||||
"CacheFilePath": "secrets.cache",
|
||||
"SyncIntervalHours": 6,
|
||||
"ManagedSecretKeys": [
|
||||
"ConnectionStrings--Database",
|
||||
"ExternalApi--ApiKey"
|
||||
]
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"Database": "MANAGED_BY_KEYVAULT"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `AppName` ist der einzige Wert der sich zwischen den 15 Projekten unterscheidet.
|
||||
|
||||
---
|
||||
|
||||
## Teil 6: Key Vault Naming-Konvention
|
||||
|
||||
```
|
||||
Format: {appname}--{ConfigSection}--{ConfigKey}
|
||||
|
||||
Web Apps:
|
||||
crm--ConnectionStrings--Database
|
||||
crm--ExternalApi--ApiKey
|
||||
erp--ConnectionStrings--Database
|
||||
erp--Smtp--Password
|
||||
portal--Auth--JwtSecret
|
||||
|
||||
Non-Web Apps:
|
||||
importer--ConnectionStrings--Database
|
||||
scheduler--ConnectionStrings--Database
|
||||
scheduler--Smtp--Password
|
||||
```
|
||||
|
||||
### Naming-Regeln
|
||||
|
||||
- Nur alphanumerische Zeichen und einfache Bindestriche `-`
|
||||
- Hierarchie-Trenner: `--` (doppelter Bindestrich)
|
||||
- Kein Unterstrich, kein Punkt, kein Leerzeichen
|
||||
- Maximal 127 Zeichen
|
||||
|
||||
---
|
||||
|
||||
## Teil 7: Einmalige Migration — Alle Apps
|
||||
|
||||
### 7.1 Inventur-Script
|
||||
|
||||
```powershell
|
||||
# inventarisierung.ps1
|
||||
param([string]$SolutionRoot = "C:\Projects\MySolution")
|
||||
|
||||
$patterns = @(
|
||||
'password\s*=\s*["\'][^"\']+["\']',
|
||||
'connectionstring\s*=\s*["\'][^"\']+["\']',
|
||||
'apikey\s*=\s*["\'][^"\']+["\']',
|
||||
'secret\s*=\s*["\'][^"\']+["\']',
|
||||
'pwd\s*=\s*[^;]+'
|
||||
)
|
||||
|
||||
Get-ChildItem -Path $SolutionRoot -Recurse -Include "*.json","*.config","*.cs" |
|
||||
Where-Object { $_.FullName -notmatch '\\(bin|obj|\.git)\\' } |
|
||||
ForEach-Object {
|
||||
$file = $_
|
||||
$content = Get-Content $file.FullName -Raw -ErrorAction SilentlyContinue
|
||||
foreach ($pattern in $patterns) {
|
||||
if ($content -match $pattern) {
|
||||
Write-Host "⚠️ $($file.FullName)" -ForegroundColor Yellow
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 Migration Script
|
||||
|
||||
```powershell
|
||||
# migrate-all-to-keyvault.ps1
|
||||
param([Parameter(Mandatory)][string]$VaultName)
|
||||
|
||||
Connect-AzAccount
|
||||
|
||||
$allSecrets = @{
|
||||
# Web Apps
|
||||
"crm--ConnectionStrings--Database" = "Server=PROD-SQL;Database=CrmDb;..."
|
||||
"crm--ExternalApi--ApiKey" = "crm-api-key"
|
||||
"erp--ConnectionStrings--Database" = "Server=PROD-SQL;Database=ErpDb;..."
|
||||
"erp--Smtp--Password" = "smtp-passwort"
|
||||
"portal--ConnectionStrings--Database" = "Server=PROD-SQL;Database=PortalDb;..."
|
||||
"portal--Auth--JwtSecret" = "jwt-secret"
|
||||
|
||||
# Non-Web Apps (OCORE)
|
||||
"importer--ConnectionStrings--Database" = "Server=PROD-SQL;Database=ImportDb;..."
|
||||
"scheduler--ConnectionStrings--Database" = "Server=PROD-SQL;Database=SchedDb;..."
|
||||
"scheduler--Smtp--Password" = "smtp-passwort"
|
||||
|
||||
# Weitere Apps ergänzen...
|
||||
}
|
||||
|
||||
$total = $allSecrets.Count
|
||||
$done = 0
|
||||
|
||||
foreach ($name in $allSecrets.Keys) {
|
||||
$value = ConvertTo-SecureString $allSecrets[$name] -AsPlainText -Force
|
||||
Set-AzKeyVaultSecret -VaultName $VaultName -Name $name -SecretValue $value
|
||||
$done++
|
||||
Write-Progress -Activity "Migriere Secrets" -Status "$name" -PercentComplete (($done/$total)*100)
|
||||
Write-Host "✅ [$done/$total] $name"
|
||||
}
|
||||
|
||||
Write-Host "`nMigration abgeschlossen." -ForegroundColor Green
|
||||
```
|
||||
|
||||
### 7.3 Bereinigung
|
||||
|
||||
```powershell
|
||||
# bereinigung.ps1
|
||||
$projektOrdner = @(
|
||||
"C:\Projects\CRM",
|
||||
"C:\Projects\ERP",
|
||||
"C:\Projects\Portal",
|
||||
"C:\Projects\Importer",
|
||||
"C:\Projects\Scheduler"
|
||||
# Alle Pfade eintragen
|
||||
)
|
||||
|
||||
foreach ($ordner in $projektOrdner) {
|
||||
$file = Join-Path $ordner "appsettings.json"
|
||||
if (Test-Path $file) {
|
||||
$json = Get-Content $file -Raw | ConvertFrom-Json
|
||||
if ($json.ConnectionStrings) {
|
||||
$json.ConnectionStrings.PSObject.Properties |
|
||||
ForEach-Object { $_.Value = "MANAGED_BY_KEYVAULT" }
|
||||
}
|
||||
$json | ConvertTo-Json -Depth 10 | Set-Content $file
|
||||
Write-Host "✅ Bereinigt: $file"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Teil 8: Server-Setup (einmalig)
|
||||
|
||||
### 8.1 Managed Identity + Key Vault Zugriff
|
||||
|
||||
```powershell
|
||||
# server-setup.ps1 — einmalig als Admin
|
||||
|
||||
# Managed Identity der VM abrufen
|
||||
$identity = Get-AzVM -ResourceGroupName "MyRG" -Name "PROD-SERVER-01"
|
||||
$objectId = $identity.Identity.PrincipalId
|
||||
|
||||
# Key Vault: Server darf lesen, nicht schreiben
|
||||
Set-AzKeyVaultAccessPolicy `
|
||||
-VaultName "company-vault" `
|
||||
-ObjectId $objectId `
|
||||
-PermissionsToSecrets Get, List
|
||||
|
||||
Write-Host "✅ Key Vault Zugriff konfiguriert für Object ID: $objectId"
|
||||
```
|
||||
|
||||
### 8.2 Schreibrechte für Cache-Dateien
|
||||
|
||||
```powershell
|
||||
# Für IIS Web Apps (App Pool User)
|
||||
$webApps = @("CRM", "ERP", "Portal")
|
||||
foreach ($pool in $webApps) {
|
||||
$appPath = "C:\inetpub\$pool"
|
||||
$user = "IIS AppPool\$pool"
|
||||
$acl = Get-Acl $appPath
|
||||
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
|
||||
$user, "Modify", "ContainerInherit,ObjectInherit", "None", "Allow")
|
||||
$acl.SetAccessRule($rule)
|
||||
Set-Acl -Path $appPath -AclObject $acl
|
||||
Write-Host "✅ Web App Schreibrecht: $user → $appPath"
|
||||
}
|
||||
|
||||
# Für Windows Services / Console Apps (LocalSystem oder dedizierter Service-User)
|
||||
$serviceApps = @("Importer", "Scheduler")
|
||||
foreach ($svc in $serviceApps) {
|
||||
$appPath = "C:\services\$svc"
|
||||
# LocalSystem hat standardmäßig Schreibrecht — nur prüfen ob Ordner existiert
|
||||
if (-not (Test-Path $appPath)) {
|
||||
New-Item -ItemType Directory -Path $appPath
|
||||
Write-Host "✅ Service-Ordner erstellt: $appPath"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Teil 9: .gitignore (Solution-Level)
|
||||
|
||||
```gitignore
|
||||
# Secret Management
|
||||
secrets.cache
|
||||
**/secrets.cache
|
||||
|
||||
# Konfigurationsdateien mit echten Werten
|
||||
appsettings.Production.json
|
||||
appsettings.Staging.json
|
||||
|
||||
# Zertifikate
|
||||
*.pfx
|
||||
*.p12
|
||||
*.key
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Teil 10: Pro-Projekt Checkliste
|
||||
|
||||
### Web Apps (OCORE_web)
|
||||
|
||||
```
|
||||
[ ] 1. AppName festlegen (kurz, eindeutig, Kleinbuchstaben)
|
||||
[ ] 2. OCORE_web bereits referenziert (transitiv: OCORE kommt mit)
|
||||
[ ] 3. Program.cs:
|
||||
using OCORE_web.Secrets;
|
||||
builder.AddSecretManagement();
|
||||
[ ] 4. appsettings.json: SecretManagement-Block mit korrektem AppName
|
||||
[ ] 5. ManagedSecretKeys vollständig auflisten
|
||||
[ ] 6. Secrets in Key Vault mit {appname}-- Präfix vorhanden
|
||||
[ ] 7. Klartext-Secrets aus appsettings.json / web.config entfernt
|
||||
[ ] 8. secrets.cache in .gitignore
|
||||
[ ] 9. Logs nach Deploy prüfen:
|
||||
"[OCORE.Secrets:crm] Sync OK" → ✅
|
||||
"[OCORE.Secrets:crm] Key Vault nicht erreichbar" → ❌
|
||||
```
|
||||
|
||||
### Non-Web Apps (OCORE)
|
||||
|
||||
```
|
||||
[ ] 1. AppName festlegen (kurz, eindeutig, Kleinbuchstaben)
|
||||
[ ] 2. OCORE bereits referenziert (vorhanden)
|
||||
[ ] 3. Program.cs:
|
||||
using OCORE.Secrets.Extensions;
|
||||
Host.CreateDefaultBuilder(args).AddSecretManagement()
|
||||
(Windows Service: zusätzlich .UseWindowsService())
|
||||
[ ] 4. appsettings.json: SecretManagement-Block mit korrektem AppName
|
||||
[ ] 5. ManagedSecretKeys vollständig auflisten
|
||||
[ ] 6. Secrets in Key Vault mit {appname}-- Präfix vorhanden
|
||||
[ ] 7. Klartext-Secrets aus Konfigurationsdateien entfernt
|
||||
[ ] 8. secrets.cache in .gitignore
|
||||
[ ] 9. Logs prüfen nach erstem Start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung der Artefakte
|
||||
|
||||
```
|
||||
OCORE/Secrets/ ← Einmalig anlegen
|
||||
Models/AppSecretsOptions.cs
|
||||
Cache/DpapiSecretsCache.cs
|
||||
Cache/DpapiCacheConfigurationSource.cs
|
||||
Sync/KeyVaultSyncService.cs
|
||||
Extensions/PrefixKeyVaultSecretManager.cs
|
||||
Extensions/SecretManagementExtensions.cs ← IHostBuilder + shared helpers
|
||||
|
||||
OCORE_web/Secrets/ ← Einmalig anlegen (1 Datei)
|
||||
SecretManagementWebExtensions.cs ← WebApplicationBuilder Extension
|
||||
|
||||
Pro Web-Projekt (15x oder subset):
|
||||
Program.cs → +2 Zeilen (using + builder.AddSecretManagement())
|
||||
appsettings.json → SecretManagement-Block, keine Secrets mehr
|
||||
|
||||
Pro Non-Web-Projekt:
|
||||
Program.cs → +2 Zeilen (using + .AddSecretManagement())
|
||||
appsettings.json → SecretManagement-Block, keine Secrets mehr
|
||||
|
||||
Scripts/
|
||||
inventarisierung.ps1 ← Wo liegen aktuell Secrets?
|
||||
migrate-all-to-keyvault.ps1 ← Einmalige Migration aller Apps
|
||||
bereinigung.ps1 ← Platzhalter setzen nach Migration
|
||||
server-setup.ps1 ← Managed Identity + ACLs (einmalig)
|
||||
```
|
||||
@@ -1,5 +1,6 @@
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Logging;
|
||||
using OCORE_web.Secrets;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
@@ -28,6 +29,9 @@ public class Program
|
||||
.SetMinimumLevel(LogLevel.Debug)
|
||||
.AddFuchsLogging();
|
||||
|
||||
// Key Vault + DPAPI secret management (must run before FuchsOcmsIntranet.Initialize)
|
||||
builder.AddSecretManagement();
|
||||
|
||||
// Initialize the Fuchs intranet singleton with configuration
|
||||
FuchsOcmsIntranet.Initialize(builder.Configuration);
|
||||
|
||||
|
||||
+24
-8
@@ -1,4 +1,20 @@
|
||||
{
|
||||
"SecretManagement": {
|
||||
"VaultUri": "https://pcwkeys.vault.azure.net/",
|
||||
"AppName": "fuchs",
|
||||
"CacheFilePath": "secrets.cache",
|
||||
"SyncIntervalHours": 6,
|
||||
"ManagedSecretKeys": [
|
||||
"ConnectionStrings--ocms-ConnectionString",
|
||||
"ConnectionStrings--fuchs-fds-ConnectionString",
|
||||
"Fuchs--SMS-APIKey",
|
||||
"Fuchs--Email--Main--password",
|
||||
"Fuchs--Email--Fds--password",
|
||||
"Fuchs--Email--Service--password",
|
||||
"Fuchs--fuchs-captcha-TOTP",
|
||||
"Fuchs--fuchs-intranet-TOTP"
|
||||
]
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
@@ -7,8 +23,8 @@
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"ocms_ConnectionString": "Data Source=MSSQL4.NBG4.DOMAINXYZ.DE,10439;Initial Catalog=site_fuchs_dev;Persist Security Info=False;TrustServerCertificate=true;Encrypt=true;User ID=fuchs_web;password='Bt5pL/cJg9oxb5';Connect Timeout=60;Load Balance Timeout=240;Max Pool Size=500;",
|
||||
"fuchs_fds_ConnectionString": "Data Source=MSSQL4.NBG4.DOMAINXYZ.DE,10439;Initial Catalog=site_fuchs_dev;Persist Security Info=False;TrustServerCertificate=true;Encrypt=true;User ID=fuchs_dev;password='!Po@cGZ5bUn37khO';Connect Timeout=60;Load Balance Timeout=240;Max Pool Size=500;"
|
||||
"ocms_ConnectionString": "MANAGED_BY_KEYVAULT",
|
||||
"fuchs_fds_ConnectionString": "MANAGED_BY_KEYVAULT"
|
||||
},
|
||||
"Fuchs": {
|
||||
"ocms_guid": "00094b8f-a822-4e9c-b627-87802f93fca8",
|
||||
@@ -16,9 +32,9 @@
|
||||
"ocms_default_locale": "de",
|
||||
"fuchs_guid": "cbfc57b3-6b85-4bbc-ab68-3b2c7408af5e",
|
||||
"fuchs_intranet_guid": "cbfc57b3-6b85-4bbc-ab68-3b2c7408af5e",
|
||||
"fuchs_captcha_TOTP": "4OXKGB3KS3VZNIUTTQLHECRUVN7ZDEFGSXYVU56D7UCKQZK7VHK7ZN",
|
||||
"fuchs_intranet_TOTP": "ZNQIUF4KC5XSL2ZXK6VQIZYG74SAMW7FDAGT7ZOVYFJCXBJ47RQW3O",
|
||||
"SMS_APIKey": "VLbm04ILlDby4EHjqolI9L95bAnfsipJcli0uvppMBHVq0BI1YR2gvpbKJRWDINu",
|
||||
"fuchs_captcha_TOTP": "MANAGED_BY_KEYVAULT",
|
||||
"fuchs_intranet_TOTP": "MANAGED_BY_KEYVAULT",
|
||||
"SMS_APIKey": "MANAGED_BY_KEYVAULT",
|
||||
"Email": {
|
||||
"Main": {
|
||||
"alias": "Sebastian Fuchs - Bad und Heizung",
|
||||
@@ -29,7 +45,7 @@
|
||||
"port": 587,
|
||||
"security": "StartTls",
|
||||
"username": "anfrage@sanitaerfuchs.de",
|
||||
"password": "DsCG8wxc4!Cu9"
|
||||
"password": "MANAGED_BY_KEYVAULT"
|
||||
},
|
||||
"Fds": {
|
||||
"alias": "Sebastian Fuchs - Bad und Heizung",
|
||||
@@ -40,7 +56,7 @@
|
||||
"port": 587,
|
||||
"security": "StartTls",
|
||||
"username": "rechnungen@sanitaerfuchs.de",
|
||||
"password": "8M9#s7TVg6b"
|
||||
"password": "MANAGED_BY_KEYVAULT"
|
||||
},
|
||||
"Service": {
|
||||
"alias": "ProcessWeb Service",
|
||||
@@ -51,7 +67,7 @@
|
||||
"port": 587,
|
||||
"security": "StartTls",
|
||||
"username": "service@emails.processweb.de",
|
||||
"password": "Uk84za4Qzba4ij"
|
||||
"password": "MANAGED_BY_KEYVAULT"
|
||||
},
|
||||
"TestAddresses": "st.ott@web.de,info@processweb.de"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Migrates Fuchs Intranet secrets to Azure Key Vault "pcwkeys".
|
||||
|
||||
.DESCRIPTION
|
||||
Reads secret values from a JSON file and uploads them to Key Vault
|
||||
under the "fuchs--" prefix convention used by the OCORE SecretManagement library.
|
||||
|
||||
Secret naming: fuchs--{ConfigSection}--{ConfigKey}
|
||||
e.g. "fuchs--ConnectionStrings--ocms_ConnectionString"
|
||||
|
||||
The JSON file must have the format:
|
||||
{
|
||||
"fuchs--ConnectionStrings--ocms_ConnectionString": "actual-value",
|
||||
...
|
||||
}
|
||||
|
||||
.NOTES
|
||||
Run once as part of the initial Key Vault migration.
|
||||
NEVER commit the secrets JSON file - it is listed in .gitignore.
|
||||
Requires: Az PowerShell module (Install-Module Az -Scope CurrentUser)
|
||||
Requires: Key Vault Secrets Officer role (or legacy Access Policy: Set + Get + List)
|
||||
|
||||
.EXAMPLE
|
||||
.\migrate-fuchs-secrets-to-keyvault.ps1 -SecretsFile .\Scripts\secrets.json
|
||||
.\migrate-fuchs-secrets-to-keyvault.ps1 -SecretsFile .\Scripts\secrets.json -WhatIf
|
||||
#>
|
||||
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $SecretsFile,
|
||||
|
||||
[string] $VaultName = "pcwkeys"
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Login check
|
||||
# ---------------------------------------------------------------------------
|
||||
$ctx = Get-AzContext -ErrorAction SilentlyContinue
|
||||
if (-not $ctx) {
|
||||
Write-Host "Not logged in - running Connect-AzAccount..." -ForegroundColor Yellow
|
||||
Connect-AzAccount
|
||||
}
|
||||
else {
|
||||
Write-Host "Using Azure account: $($ctx.Account.Id)" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Load secrets from JSON file
|
||||
# ---------------------------------------------------------------------------
|
||||
if (-not (Test-Path $SecretsFile)) {
|
||||
Write-Host ""
|
||||
Write-Host "ERROR: Secrets file not found: $SecretsFile" -ForegroundColor Red
|
||||
Write-Host "Create it from the template: Scripts\secrets.json" -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
try {
|
||||
$json = Get-Content $SecretsFile -Raw -Encoding UTF8 -ErrorAction Stop
|
||||
$parsed = $json | ConvertFrom-Json -ErrorAction Stop
|
||||
$secrets = [ordered]@{}
|
||||
$parsed.PSObject.Properties | ForEach-Object { $secrets[$_.Name] = $_.Value }
|
||||
}
|
||||
catch {
|
||||
Write-Host ""
|
||||
Write-Host "ERROR: Failed to parse $SecretsFile - $($_.Exception.Message)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Loaded $($secrets.Count) secrets from: $SecretsFile" -ForegroundColor Cyan
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Validate - abort if any value is still empty
|
||||
# ---------------------------------------------------------------------------
|
||||
$empty = $secrets.Keys | Where-Object { [string]::IsNullOrWhiteSpace($secrets[$_]) }
|
||||
if ($empty) {
|
||||
Write-Host ""
|
||||
Write-Host "ERROR: The following secrets have no value set:" -ForegroundColor Red
|
||||
$empty | ForEach-Object { Write-Host " $_" -ForegroundColor Red }
|
||||
Write-Host ""
|
||||
Write-Host "Fill in the values in secrets.json, then run again." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Write secrets to Key Vault
|
||||
# ---------------------------------------------------------------------------
|
||||
$total = $secrets.Count
|
||||
$done = 0
|
||||
$failed = @()
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Migrating $total secrets to Key Vault '$VaultName'..." -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
foreach ($name in $secrets.Keys) {
|
||||
$done++
|
||||
$pct = [int](($done / $total) * 100)
|
||||
Write-Progress -Activity "Uploading secrets to $VaultName" -Status "$name" -PercentComplete $pct
|
||||
|
||||
if ($PSCmdlet.ShouldProcess($VaultName, "Set secret '$name'")) {
|
||||
try {
|
||||
$secureValue = ConvertTo-SecureString $secrets[$name] -AsPlainText -Force
|
||||
$null = Set-AzKeyVaultSecret -VaultName $VaultName -Name $name -SecretValue $secureValue
|
||||
Write-Host " [OK] [$done/$total] $name" -ForegroundColor Green
|
||||
}
|
||||
catch {
|
||||
Write-Host " [FAIL] [$done/$total] $name - $($_.Exception.Message)" -ForegroundColor Red
|
||||
$failed += $name
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host " [WhatIf] Would set: $name" -ForegroundColor DarkCyan
|
||||
}
|
||||
}
|
||||
|
||||
Write-Progress -Activity "Uploading secrets" -Completed
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Summary
|
||||
# ---------------------------------------------------------------------------
|
||||
Write-Host ""
|
||||
if ($failed.Count -eq 0) {
|
||||
Write-Host "Migration complete - $done/$total secrets written." -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host "Migration finished with $($failed.Count) error(s):" -ForegroundColor Yellow
|
||||
$failed | ForEach-Object { Write-Host " $_" -ForegroundColor Red }
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Next steps:" -ForegroundColor Cyan
|
||||
Write-Host " 1. Verify appsettings.json has VaultUri set to https://pcwkeys.vault.azure.net/"
|
||||
Write-Host " 2. Start the app and confirm '[OCORE.Secrets:fuchs] Sync OK' appears in logs"
|
||||
Write-Host " 3. Clear the secret values from secrets.json after a successful run"
|
||||
Reference in New Issue
Block a user