SyncAzKeyVaultWithUserSecrets.psm1
function Write-KvStatus { param( [ValidateSet('Ok', 'Warning', 'Error', 'Info')] [string] $Status, [Parameter(Mandatory)] [string] $Message ) switch ($Status) { 'Ok' { Write-Verbose $Message } 'Info' { Write-Information $Message } 'Warning' { Write-Warning $Message } 'Error' { Write-Error $Message } } } function Get-ProjectRoot { $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-KvStatus Info " …$($Counter.Value) keys processed" } } } function Show-Preview { param([string]$Text) if ($null -eq $Text) { return '' } if ($Text.Length -gt 8) { return $Text.Substring(0, 4) + '…' } else { return $Text.Substring(0, 1) + '…' } } 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 Write-Host "A similar config key '$suggested' was found in appsettings for secret '$SecretName'." $confirm = Read-Host "Use suggested config key '$suggested' for '$SecretName'? [Y/n/custom key(s)]" 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 "`nSimilar config keys were found in appsettings for 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]' $userInput = Read-Host 'Which key(s) do you want to use for local user secrets?' if ($userInput -match '^[0-9]+$' -and 1 -le $userInput -and $userInput -le $suggestions.Count) { return $suggestions[[int]$userInput - 1].Key } return $userInput } function Find-SubscriptionsWithVault { param( [string]$VaultName, [array]$Subscriptions ) $matchingSubscriptionsAndRg = @() foreach ($sub in $Subscriptions) { try { $vault = Get-AzKeyVault -VaultName $VaultName -SubscriptionId $sub.Id -ErrorAction Stop $matchingSubscriptionsAndRg += [pscustomobject]@{ Subscription = $sub ResourceGroup = $vault.ResourceGroupName } } catch {} } return $matchingSubscriptionsAndRg } function Get-NetworkAclObject { param( [Parameter(Mandatory)][PSObject] $Vault ) if ($Vault.PSObject.Properties.Match('NetworkAcls')) { return $Vault.NetworkAcls } # Otherwise, if it has a .Properties member (old SDK shape), drill into that if ($Vault.PSObject.Properties.Match('Properties')) { $inner = $Vault.Properties if ($inner.PSObject.Properties.Match('NetworkAcls')) { return $inner.NetworkAcls } } throw "Cannot find a NetworkAcls property on the vault object." } function Get-VaultIpRules { param( [Parameter(Mandatory)][PSObject] $Vault, [Parameter(Mandatory)][string]$ResourceGroupName ) $networkAcls = if ($Vault.PSObject.Properties.Match('NetworkAcls')) { $Vault.NetworkAcls } elseif ($Vault.PSObject.Properties.Match('Properties') -and $Vault.Properties.PSObject.Properties.Match('NetworkAcls')) { $Vault.Properties.NetworkAcls } else { throw "Unable to locate NetworkAcls on vault object." } if (-not $networkAcls.PSObject.Properties.Match('IpRules')) { throw "No IpRules property found on NetworkAcls." } return $networkAcls.IpRules | ForEach-Object { $_.Value } } function Assert-KeyVaultNetworkAccess { param([string]$VaultName, [string]$ResourceGroupName, [ref]$AddedIp) try { $ip = Invoke-RestMethod -Uri 'https://api.ipify.org' } catch { Write-KvStatus Warning 'Could not detect your IP' return } $vault = Get-AzKeyVault -VaultName $VaultName -ResourceGroupName $ResourceGroupName -ErrorAction Stop $acl = Get-NetworkAclObject -Vault $vault $rules = Get-IpRuleValues -Acl $acl if ($rules -contains "$ip/32") { return # already allowed } Write-KvStatus Info "Adding IP $ip…" Update-AzKeyVaultNetworkRuleSet -VaultName $VaultName -ResourceGroupName $ResourceGroupName -IpAddress $ip $AddedIp.Value = $ip return $ip } function Remove-TemporaryNetworkAccess { param([string]$VaultName, [string]$ResourceGroupName, [string]$Ip) return unless $Ip Write-KvStatus Info "Removing IP $Ip" Remove-AzKeyVaultNetworkRule -Name $VaultName -ResourceGroupName $ResourceGroupName -IpAddress $Ip :contentReference[oaicite:6] { index=6 } } function Get-PlainSecret { param( [Parameter(Mandatory)] [string]$VaultName, [Parameter(Mandatory)] [string]$Name ) # Prefer the modern switch if the cmdlet supports it if ((Get-Command Get-AzKeyVaultSecret).Parameters.ContainsKey('AsPlainText')) { return Get-AzKeyVaultSecret -VaultName $VaultName -Name $Name -AsPlainText } $obj = Get-AzKeyVaultSecret -VaultName $VaultName -Name $Name if ($obj.PSObject.Properties.Match('SecretValueText')) { return $obj.SecretValueText # Az.KeyVault < 5.x } # convert SecureString safely if it is the only way if ($obj.PSObject.Properties.Match('SecretValue') -and $obj.SecretValue -is [securestring]) { try { $ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($obj.SecretValue) return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr) } finally { if ($ptr) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) } } } throw "Get-AzKeyVaultSecret returned an unexpected object; can't find plain-text value." } # --- Main function --- function Sync-AzKeyVaultWithUserSecrets { [CmdletBinding()] param([Parameter(Mandatory)] [string]$KeyVaultName) Set-StrictMode -Version Latest $root = Get-ProjectRoot if (-not $root) { Write-KvStatus Error 'No .csproj found.'; return } $csproj = (Get-ChildItem $root *.csproj | Select-Object -First 1).FullName Write-KvStatus Ok "Found project: $csproj" if (-not (Select-String -Path $csproj -Pattern '<UserSecretsId>' -Quiet)) { Write-KvStatus Info "Initializing dotnet user-secrets for project: $csproj" dotnet user-secrets init --project $csproj | Out-Null } $projectName = Split-Path $csproj -Leaf Write-KvStatus Ok "Current user-secrets for project $($projectName):" dotnet user-secrets list $ErrorActionPreference = 'Stop' if (-not (Get-Module -ListAvailable Az.Accounts, Az.KeyVault)) { Write-KvStatus 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-KvStatus Error 'Run Connect-AzAccount.'; return } $locations = Find-SubscriptionsWithVault -VaultName $KeyVaultName -Subscriptions $subs if (-not $locations) { Write-KvStatus Error "The Key Vault '$KeyVaultName' was not found in any subscription to which you have access." return } # if exactly one match, pick it automatically if ($locations.Count -eq 1) { $locationChoice = $locations[0] } else { Write-Host "`nKey Vault found in multiple subscriptions / resource-groups:" -ForegroundColor Cyan for ($i = 0; $i -lt $locations.Count; $i++) { $sub = $locations[$i].Subscription $rg = $locations[$i].ResourceGroup Write-Host "$($i+1)) Subscription: $($sub.Name) ResourceGroup: $rg" } do { $sel = Read-Host 'Choose a number' } until ($sel -match '^[0-9]+$' -and 1 -le $sel -and $sel -le $locations.Count) $locationChoice = $locations[[int]$sel - 1] } Set-AzContext -SubscriptionId $locationChoice.Subscription.Id | Out-Null Write-KvStatus Ok "Using subscription: $($locationChoice.Subscription.Name) / resource-group: $($locationChoice.ResourceGroup)" try { $vault = Get-AzKeyVault -VaultName $KeyVaultName -ErrorAction Stop } catch { Write-KvStatus Error "Key Vault '$KeyVaultName' not found or inaccessible." return } $rgName = $vault.ResourceGroupName Write-KvStatus Ok "Target Key Vault in resource group '$rgName'." $temporaryIp = $null try { $secrets = Get-AzKeyVaultSecret -VaultName $KeyVaultName -ErrorAction Stop } catch { $ex = $_.Exception $httpCode = $null if ($ex.PSObject.Properties['Response']) { $resp = $ex.Response if ($resp -and $resp.StatusCode) { $httpCode = [int]$resp.StatusCode.value__ } } elseif ($ex.PSObject.Properties['ResponseMessage']) { $respMsg = $ex.ResponseMessage if ($respMsg -and $respMsg.StatusCode) { $httpCode = [int]$respMsg.StatusCode.value__ } } $isForbidden = ($httpCode -eq 403) -or ($ex.Message -match 'Forbidden') if ($isForbidden -and $ex.Message -match 'Client address is not authorized') { Write-KvStatus Warning 'Firewall blocked your IP; adding temporary rule to accept your IP…' $ipRef = [ref]'' Assert-KeyVaultNetworkAccess -VaultName $KeyVaultName -ResourceGroup $locationChoice.ResourceGroup -AddedIp $ipRef $temporaryIp = $ipRef.Value # now retry try { $secrets = Get-AzKeyVaultSecret -VaultName $KeyVaultName -ErrorAction Stop } catch { Write-KvStatus Error 'Still forbidden after adding your IP. Check your RBAC rights.' return } } elseif ($isForbidden) { Write-KvStatus Error 'Access denied—insufficient RBAC permissions.' return } else { throw # not a 403, re-throw } } finally { if ($temporaryIp) { Remove-TemporaryNetworkAccess -VaultName $KeyVaultName -ResourceGroupName $locationChoice.ResourceGroupName -Ip $temporaryIp } } if (-not $secrets) { Write-KvStatus Error 'Key Vault is empty.'; return } Write-KvStatus 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-KvStatus 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-KvStatus Ok "Found $($keys.Count) distinct config keys among the selected appsettings." $mappedKeys = @() foreach ($secret in $secrets) { Write-Host '─────────────────────────────────────────────────' -ForegroundColor DarkGray $keysCsv = Select-ConfigurationKey -SecretName $secret.Name -AvailableKeys $keys $localKeyValues = @( $keysCsv -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' } ) if (-not $localKeyValues.Count) { continue } # user skipped it $plainTextSecretValue = Get-PlainSecret -VaultName $KeyVaultName -Name $secret.Name $preview = Show-Preview $plainTextSecretValue foreach ($cfg in $localKeyValues) { & dotnet user-secrets set $cfg $plainTextSecretValue --project $csproj | Out-Null Write-KvStatus Ok "Saved Key Vault secret '$($secret.Name)' → local '$cfg' (value: $preview)" $mappedKeys += $cfg } } $unmapped = $keys.Keys | ForEach-Object { $_.Trim() } | Where-Object { Test-IsPotentialSecretKey $_ } | Where-Object { $_ -notin $mappedKeys } if ($unmapped) { Write-KvStatus 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-KvStatus 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 |