SyncAzKeyVaultWithUserSecrets.psm1
function Write-Status { [CmdletBinding()] param( [ValidateSet('Ok','Warning','Error','Info')] [string]$Status, [string]$Message ) $Colors = @{ Ok = 'Green'; Warning = 'Yellow'; Error = 'Red'; Info = $null } $Symbols = @{ Ok = '✓'; Warning = '⚠'; Error = '✕'; Info = '•' } $c = $Colors[$Status]; $s = $Symbols[$Status] if ($c) { Write-Host "$s $Message" -ForegroundColor $c } else { Write-Host "$s $Message" } } function Get-ProjectRootDirectory { $d = Get-Location while ($d) { if (Test-Path "$($d.Path)\*.csproj") { return $d } $d = $d.Parent } } function Get-LevenshteinDistance { param([string]$A,[string]$B) $la,$lb = $A.Length,$B.Length if (!$la) { return $lb }; if (!$lb) { return $la } $prev = 0..$lb; $curr = New-Object int[] ($lb + 1) for ($i = 1; $i -le $la; $i++) { $curr[0] = $i for ($j = 1; $j -le $lb; $j++) { $cost = if ($A[$i - 1] -eq $B[$j - 1]) { 0 } else { 1 } $curr[$j] = [math]::Min([math]::Min($curr[$j - 1] + 1,$prev[$j] + 1),$prev[$j - 1] + $cost) } $prev,$curr = $curr,$prev } $prev[$lb] } function Test-IsPotentialSecretKey { param([string]$Key) $Key -imatch '(secret|password|token|key|certificate|connectionstring)$' } function Add-FlattenedJsonKeys { param( [object]$JsonObject, [string]$Prefix, [hashtable]$KeyBag, [hashtable]$Visited, [ref]$Counter, [int]$Depth = 0, [int]$MaxDepth = 25 ) if ($Depth -gt $MaxDepth) { return } if (-not ($JsonObject -is [pscustomobject])) { return } $id = [System.Runtime.CompilerServices.RuntimeHelpers]::GetHashCode($JsonObject) if ($Visited.ContainsKey($id)) { return } $Visited[$id] = $true foreach ($p in $JsonObject.PSObject.Properties | Where-Object MemberType -EQ 'NoteProperty') { $key = if ($Prefix) { "$Prefix`:$($p.Name)" } else { $p.Name } $val = $p.Value switch ($val) { { $_ -is [pscustomobject] } { Add-FlattenedJsonKeys -JsonObject $val -Prefix $key -KeyBag $KeyBag -Visited $Visited ` -Counter $Counter -Depth ($Depth + 1) -MaxDepth $MaxDepth } { $_ -is [System.Collections.IEnumerable] -and -not ($_ -is [string]) } { $i = 0 foreach ($item in $val) { $indexed = "$key`[$i`]" if ($item -is [pscustomobject]) { Add-FlattenedJsonKeys -JsonObject $item -Prefix $indexed -KeyBag $KeyBag -Visited $Visited ` -Counter $Counter -Depth ($Depth + 1) -MaxDepth $MaxDepth } else { $KeyBag[$indexed] = $true } $i++ } } default { $KeyBag[$key] = $true } } $Counter.Value++ if ($Counter.Value % 250 -eq 0) { Write-Status Info " …$($Counter.Value) keys processed" } } } function Select-ConfigurationKey { param( [string]$SecretName, [hashtable]$AvailableKeys, [int]$Threshold = 8 ) $suggestions = $AvailableKeys.Keys | ForEach-Object { [pscustomobject]@{ Key = $_; Distance = Get-LevenshteinDistance $SecretName $_ } } | Where-Object Distance -LE $Threshold | Sort-Object Distance,Key if ($suggestions.Count -eq 0) { Write-Host "Could not find any existing appsettings entry matching '$SecretName'." return (Read-Host "Enter a config name to be used as a key for the local user secret (optionally: a comma-separated list of config names)") } if ($suggestions.Count -eq 1) { $suggested = $suggestions[0].Key $confirm = Read-Host "Use suggested config key '$suggested' for '$SecretName'? [Y/n/custom key]" switch ($confirm.ToLower()) { { $_ -eq '' -or $_ -eq 'y' -or $_ -eq 'yes' } { return $suggested } { $_ -eq 'n' -or $_ -eq 'no' } { return (Read-Host "Enter custom config name (or comma-separated list)") } default { return $confirm } } } Write-Host "`nMap secret '$SecretName':" -ForegroundColor Cyan for ($i = 0; $i -lt $suggestions.Count; $i++) { Write-Host "$($i+1)) $($suggestions[$i].Key)" } Write-Host '[Enter a number from the suggestions above. Optionally: Enter a custom key or a comma-separated list]' $input = Read-Host 'Which key(s) do you want to use for local user secrets?' if ($input -match '^[0-9]+$' -and 1 -le $input -and $input -le $suggestions.Count) { return $suggestions[[int]$input - 1].Key } return $input } function Find-SubscriptionsWithVault { param([string]$VaultName,[array]$Subscriptions) $matches = @() foreach ($s in $Subscriptions) { try { $null = Get-AzKeyVault -VaultName $VaultName -SubscriptionId $s.Id -ErrorAction Stop; $matches += $s } catch {} } $matches } function Ensure-NetworkAccess { param([string]$VaultName,[string]$ResourceGroup,[ref]$AddedIp) try { $ip = (Invoke-RestMethod -Uri 'https://api.ipify.org') } catch { Write-Status Warning 'Could not determine your public IP.'; return $null } $vault = Get-AzKeyVault -VaultName $VaultName -ResourceGroupName $ResourceGroup -ErrorAction Stop $exists = $vault.NetworkAcls.IpRules | Where-Object { $_.Value -eq "$ip/32" } if ($exists) { return $ip } # already allowed Write-Status Warning "Temporarily adding your IP $ip to Key Vault firewall" Update-AzKeyVaultNetworkRuleSet -VaultName $VaultName -ResourceGroupName $ResourceGroup ` -IpAddress $ip -ErrorAction Stop | Out-Null $AddedIp.Value = $ip return $ip } function Remove-TemporaryNetworkAccess { param( [string]$VaultName, [string]$ResourceGroup, [string]$Ip ) if (-not $Ip) { return } Write-Status Info "Removing temporary IP rule $Ip" # Newer Az.KeyVault (≥ 5.x) ships Remove-AzKeyVaultNetworkRuleSet $removeCmd = Get-Command Remove-AzKeyVaultNetworkRuleSet -ErrorAction SilentlyContinue if ($removeCmd) { Remove-AzKeyVaultNetworkRuleSet -VaultName $VaultName ` -ResourceGroupName $ResourceGroup ` -IpAddress $Ip -ErrorAction SilentlyContinue return } # Fallback for older modules – use Update-AzKeyVaultNetworkRuleSet if it supports –IpAddressToRemove $updateCmd = Get-Command Update-AzKeyVaultNetworkRuleSet -ErrorAction SilentlyContinue if ($updateCmd -and $updateCmd.Parameters.ContainsKey('IpAddressToRemove')) { Update-AzKeyVaultNetworkRuleSet -VaultName $VaultName ` -ResourceGroupName $ResourceGroup ` -IpAddressToRemove $Ip -ErrorAction SilentlyContinue | Out-Null return } Write-Status Warning "Your Az.KeyVault version can't remove IP rules automatically. Delete '$Ip' manually in the portal." } # --- Main function --- function Sync-AzKeyVaultWithUserSecrets { [CmdletBinding()] param([Parameter(Mandatory)] [string]$KeyVaultName) $root = Get-ProjectRootDirectory if (-not $root) { Write-Status Error 'No .csproj found.'; return } $csproj = (Get-ChildItem $root *.csproj | Select-Object -First 1).FullName Write-Status Ok "Found project: $csproj" if (-not (Select-String -Path $csproj -Pattern '<UserSecretsId>' -Quiet)) { Write-Status Info "Initializing dotnet user-secrets for project: $csproj" dotnet user-secrets init --project $csproj | Out-Null } $ErrorActionPreference = 'Stop' if (-not (Get-Module -ListAvailable Az.Accounts,Az.KeyVault)) { Write-Status Error 'Az modules are missing from your environment.'; return } Import-Module Az.Accounts,Az.KeyVault -ErrorAction Stop $subs = Get-AzSubscription | Sort-Object Name if (-not $subs) { Write-Status Error 'Run Connect-AzAccount.'; return } $candidateSubs = Find-SubscriptionsWithVault -VaultName $KeyVaultName -Subscriptions $subs if (-not $candidateSubs) { Write-Status Error "The Key Vault '$KeyVaultName' was not found in any subscription to which you have access."; return } $subscription = if ($candidateSubs.Count -eq 1) { $candidateSubs[0] } else { Write-Host "`nKey Vault found in multiple subscriptions:" -ForegroundColor Cyan for ($i = 0; $i -lt $candidateSubs.Count; $i++) { Write-Host "$($i+1)) $($candidateSubs[$i].Name)" } do { $c = Read-Host 'Choose' } until ($c -match '^[0-9]+$' -and 1 -le $c -and $c -le $candidateSubs.Count) $candidateSubs[$c - 1] } Set-AzContext -SubscriptionId $subscription.Id | Out-Null Write-Status Ok "Using subscription: $($subscription.Name)" $temporaryIp = $null try { $secrets = Get-AzKeyVaultSecret -VaultName $KeyVaultName -ErrorAction Stop } catch { $err = $_.Exception $statusCode = $null if ($err.PSObject.Properties['Response']) { $statusCode = $err.Response.StatusCode.value__ } elseif ($err.PSObject.Properties['ResponseMessage']) { $statusCode = $err.ResponseMessage.StatusCode.value__ } $forbidden = ($statusCode -eq 403) -or ($err.Message -match 'Forbidden') if ($forbidden -and $err.Message -match 'Client address is not authorized') { Write-Status Warning 'Key vault firewall blocked your current IP; trying to add a temporary rule …' $kv = Get-AzKeyVault -VaultName $KeyVaultName -ErrorAction Stop $ipRef = [ref]'' Ensure-NetworkAccess -VaultName $KeyVaultName -ResourceGroup $kv.ResourceGroupName -AddedIp $ipRef | Out-Null $temporaryIp = $ipRef.Value try { $secrets = Get-AzKeyVaultSecret -VaultName $KeyVaultName -ErrorAction Stop } catch { Write-Status Error 'Still forbidden access after your IP address was added. Check RBAC permissions.'; return } } elseif ($forbidden) { Write-Status Error 'Access denied – it seems like you lack Key Vault RBAC permissions.' return } else { throw } } finally { if ($temporaryIp) { $kv = Get-AzKeyVault -VaultName $KeyVaultName -ErrorAction SilentlyContinue Remove-TemporaryNetworkAccess -VaultName $KeyVaultName -ResourceGroup $kv.ResourceGroupName -Ip $temporaryIp } } if (-not $secrets) { Write-Status Error 'Key Vault is empty.'; return } Write-Status Ok "Found $($secrets.Count) secrets in selected Key Vault." $jsonFiles = Get-ChildItem $root -Recurse -Filter 'appsettings*.json' | Where-Object { $_.FullName -notmatch '\\(bin|obj|node_modules)\\' } if (-not $jsonFiles) { Write-Status Warning 'No appsettings*.json in project.'; return } Write-Host "`nSelect appsettings file(s):" -ForegroundColor Cyan for ($i = 0; $i -lt $jsonFiles.Count; $i++) { Write-Host "$($i+1)) $($jsonFiles[$i].FullName)" } Write-Host 'a) All files' do { $sel = Read-Host 'Choose (single option or comma-separated list)' } until ($sel -match '^[0-9,]+$' -or $sel -eq 'a') $selected = if ($sel -eq 'a') { $jsonFiles } else { $idx = $sel -split ',' | ForEach-Object { [int]$_ - 1 }; $jsonFiles[$idx] } $keys = @{}; $visited = @{}; $counter = 0 foreach ($f in $selected) { Add-FlattenedJsonKeys (Get-Content $f.FullName -Raw | ConvertFrom-Json) '' $keys $visited ([ref]$counter) } Write-Status Ok "Found $($keys.Count) distinct config keys among the selected appsettings." $mappedKeys = @() foreach ($secret in $secrets) { $keysCsv = Select-ConfigurationKey -SecretName $secret.Name -AvailableKeys $keys $localKeyValues = $keysCsv -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ } $value = (Get-AzKeyVaultSecret -VaultName $KeyVaultName -Name $secret.Name).SecretValueText foreach ($configName in $localKeyValues) { $value | & dotnet user-secrets set $configName --project $csproj -- | Out-Null Write-Status Ok "Saved key vault value '$($secret.Name)' as local secret '$configName'" $mappedKeys += $configName } } $unmapped = $keys.Keys | ForEach-Object { $_.Trim() } | Where-Object { Test-IsPotentialSecretKey $_ } | Where-Object { $_ -notin $mappedKeys } if ($unmapped) { Write-Status Warning 'There are some potential secrets in the appsettings which were not linked to any Key Vault entry:' $unmapped | Sort-Object | Get-Unique | ForEach-Object { " $_" } } else { Write-Status Ok 'All secrets described by appsettings have been mapped to user secrets.' } } Set-Alias -Name kv2local -Value Sync-AzKeyVaultWithUserSecrets -Scope Script Export-ModuleMember -Function Sync-AzKeyVaultWithUserSecrets -Alias kv2local |