diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 76aeb39..66f1a02 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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). + diff --git a/.gitignore b/.gitignore index ad90e1b..a510a17 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,10 @@ dist/ /playwright-report/ /blob-report/ /playwright/.cache/ -/playwright/.auth/ \ No newline at end of file +/playwright/.auth/ +# Secret Management DPAPI cache +secrets.cache +**/secrets.cache + +# Secret values file (never commit) +Scripts/secrets.json diff --git a/Fuchs/Docs/keyvault-dpapi-ocore-implementation.md b/Fuchs/Docs/keyvault-dpapi-ocore-implementation.md new file mode 100644 index 0000000..b087e9a --- /dev/null +++ b/Fuchs/Docs/keyvault-dpapi-ocore-implementation.md @@ -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"; + + /// + /// Vollständige URI des Azure Key Vault. + /// Beispiel: https://company-vault.vault.azure.net/ + /// + public string VaultUri { get; set; } = string.Empty; + + /// + /// 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). + /// + public string AppName { get; set; } = string.Empty; + + /// + /// Pfad zur DPAPI-Cache-Datei (relativ zum App-Verzeichnis). + /// + public string CacheFilePath { get; set; } = "secrets.cache"; + + /// + /// Sync-Intervall in Stunden. + /// + public int SyncIntervalHours { get; set; } = 6; + + /// + /// Secret-Namen ohne App-Präfix. + /// Beispiel: ["ConnectionStrings--Database", "ExternalApi--ApiKey"] + /// + public List 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 secrets); + Task> ReadAsync(); + bool Exists(); +} + +public class DpapiSecretsCache : IDpapiSecretsCache +{ + private readonly string _filePath; + private readonly byte[] _entropy; + private readonly ILogger _logger; + + /// + /// Entropy wird aus AppName abgeleitet — verhindert dass eine andere App + /// auf demselben Server den Cache dieser App lesen kann. + /// + public DpapiSecretsCache(string filePath, string appName, ILogger logger) + { + _filePath = filePath; + _entropy = Encoding.UTF8.GetBytes($"dpapi-entropy-{appName}"); + _logger = logger; + } + + public async Task WriteAsync(Dictionary 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> ReadAsync() + { + if (!Exists()) return []; + + try + { + var encrypted = await File.ReadAllBytesAsync(_filePath); + var decrypted = ProtectedData.Unprotect(encrypted, _entropy, DataProtectionScope.LocalMachine); + return JsonSerializer.Deserialize>(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(); + 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; + +/// +/// Läuft als BackgroundService in jedem Host-Typ: +/// Web Apps, Console Apps, Windows Services, Worker Services. +/// Synct Secrets aus Key Vault → DPAPI-Cache, periodisch. +/// +public class KeyVaultSyncService : BackgroundService +{ + private readonly SecretClient _secretClient; + private readonly IDpapiSecretsCache _cache; + private readonly IConfigurationRoot _configRoot; + private readonly AppSecretsOptions _options; + private readonly ILogger _logger; + + public KeyVaultSyncService( + SecretClient secretClient, + IDpapiSecretsCache cache, + IConfiguration configuration, + IOptions options, + ILogger 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(); + + 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; + +/// +/// 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" +/// +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 +{ + /// + /// Für Console Apps, Worker Services, Windows Services, WinForms/WPF. + /// Verwendung: Host.CreateDefaultBuilder(args).AddSecretManagement() + /// + public static IHostBuilder AddSecretManagement(this IHostBuilder hostBuilder) + { + hostBuilder.ConfigureAppConfiguration((context, config) => + { + var built = config.Build(); + var options = built.GetSection(AppSecretsOptions.SectionName).Get() + ?? 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; + } + + /// + /// Gemeinsame Service-Registrierung — wird von OCORE und OCORE_web verwendet. + /// + public static void RegisterCoreServices(IServiceCollection services, IConfiguration configuration) + { + var options = configuration + .GetSection(AppSecretsOptions.SectionName) + .Get() ?? new AppSecretsOptions(); + + if (!IsConfigured(options)) return; + + services.Configure( + configuration.GetSection(AppSecretsOptions.SectionName)); + + services.AddSingleton(new SecretClient( + new Uri(options.VaultUri), + new DefaultAzureCredential())); + + services.AddSingleton(sp => + new DpapiSecretsCache( + ResolveCachePath(AppContext.BaseDirectory, options.CacheFilePath), + options.AppName, + sp.GetRequiredService>())); + + services.AddHostedService(); + } + + 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 +{ + /// + /// Für ASP.NET Core Web Apps und Web APIs. + /// Verwendung: builder.AddSecretManagement() + /// + public static WebApplicationBuilder AddSecretManagement( + this WebApplicationBuilder builder) + { + var options = builder.Configuration + .GetSection(AppSecretsOptions.SectionName) + .Get() ?? 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(); + }) + .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(); + }) + .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(); + }) + .Build(); + +Application.Run(host.Services.GetRequiredService()); +``` + +--- + +## 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) +``` diff --git a/Fuchs/Program.cs b/Fuchs/Program.cs index e3a607e..5899e61 100644 --- a/Fuchs/Program.cs +++ b/Fuchs/Program.cs @@ -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); diff --git a/Fuchs/appsettings.json b/Fuchs/appsettings.json index 4a2ce3c..0a21eaf 100644 --- a/Fuchs/appsettings.json +++ b/Fuchs/appsettings.json @@ -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" } diff --git a/Scripts/migrate-fuchs-secrets-to-keyvault.ps1 b/Scripts/migrate-fuchs-secrets-to-keyvault.ps1 new file mode 100644 index 0000000..b2a4efb --- /dev/null +++ b/Scripts/migrate-fuchs-secrets-to-keyvault.ps1 @@ -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"