feat: Implement secret management using Azure Key Vault and DPAPI across OCORE and OCORE_web
Playwright Tests / test (push) Canceled after 0s
Playwright Tests / test (push) Canceled after 0s
This commit is contained in:
@@ -1,933 +0,0 @@
|
||||
# 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)
|
||||
```
|
||||
Reference in New Issue
Block a user