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