Files
Fuchs_Intranet/Fuchs/Docs/keyvault-dpapi-ocore-implementation.md
T
Stefan cc2abc91d6
Playwright Tests / test (push) Waiting to run
Add Azure Key Vault + DPAPI secret management
Integrate OCORE/OCORE_web-based secret management using Azure Key Vault and DPAPI cache. Update appsettings.json to remove plaintext secrets and list managed keys. Register secret management in Program.cs. Update .gitignore for secret files. Add documentation for naming conventions and migration, plus a PowerShell script for initial secret upload. Centralizes and secures secret handling across the app.
2026-05-03 16:24:38 +02:00

29 KiB

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

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

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

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

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

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

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

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

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)

// 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)

// 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();
// 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)

// 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)

{
  "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

# 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

# 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

# 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

# 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

# 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)

# 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)