Skip to content

Instantly share code, notes, and snippets.

@santisq
Last active May 20, 2025 17:24
Show Gist options
  • Save santisq/8666f3f78e39845797130995541aebbd to your computer and use it in GitHub Desktop.
Save santisq/8666f3f78e39845797130995541aebbd to your computer and use it in GitHub Desktop.

storageAccountKeyRotation.ps1

This automation finds and rotates Keys that are created after specified $dayLimit.
Additionally, finds and updates the Environment Variables (if any) of Function Apps associated with the rotated Key.

functionAppConnectionStrings.ps1

This automation finds and updates any Environment Variable having a connection string that does not match with their associated Storage Account connection string.

Required Modules

  • Az.Accounts
  • Az.ResourceGraph

Required RBAC

If using the Automation Account's Managed Identity, these automations will required the following roles over the container resource (Subscription / Resource Group) to work properly:

  • Reader and Data Access
  • Storage Account Contributor
  • Website Contributor
using namespace System.Collections
using namespace System.Collections.Generic
Connect-AzAccount -Identity -WA 0 | Out-Null
class ConnectionString {
static [bool] TryParse([DictionaryEntry] $entry, [ref] $details) {
if ($entry.Value -notmatch '^.+=.+;') {
return $false
}
$data = $entry.Value -replace '\s*;\s*', "`n" | ConvertFrom-StringData
if ($data.ContainsKey('accountName') -and $data.ContainsKey('accountKey')) {
$details.Value = [pscustomobject]@{
StorageAccount = $data['accountName']
Name = $entry.Key
Key = $data['accountKey']
}
return $true
}
return $false
}
}
function Get-FunctionAppSettings {
param(
[Parameter()]
[ValidateNotNull()]
[string[]] $SubscriptionScope)
$uri = 'https://management.azure.com/{0}/config/appsettings/list?api-version=2024-04-01'
$searchAzGraphSplat = @{
Query = "
resources
| where ['type'] == 'microsoft.web/sites'
and ['kind'] has 'functionapp'
| summarize functions = make_list(pack('id', id, 'name', name))"
}
if ($SubscriptionScope) {
$searchAzGraphSplat['Subscription'] = $SubscriptionScope
}
$req = Search-AzGraph @searchAzGraphSplat
foreach ($function in $req.Data[0].functions) {
$req = Invoke-AzRestMethod ($uri -f $function.id) -Method POST
if ($req.StatusCode -ne 200) {
"Failed to get App Settings for Function App '$($function.name)'. " +
"Status Code $($req.StatusCode)." | Write-Warning
continue
}
$setting = $req.Content | ConvertFrom-Json -AsHashtable
$stgAccounts = @{}
foreach ($pair in $setting['properties'].GetEnumerator()) {
$details = $null
if (-not [ConnectionString]::TryParse($pair, [ref] $details)) {
continue
}
foreach ($detail in $details) {
if (-not $stgAccounts.ContainsKey($detail.StorageAccount)) {
$stgAccounts[$detail.StorageAccount] = [List[object]]::new()
}
$stgAccounts[$detail.StorageAccount].Add($detail)
}
}
[pscustomobject]@{
Id = $function.id
Name = $function.name
Settings = $setting
StorageAccounts = $stgAccounts
}
}
}
function Get-StorageAccountKey {
param(
[Parameter(Mandatory)]
[string] $Id)
$uri = "https://management.azure.com${Id}/listKeys?api-version=2024-01-01"
$req = Invoke-AzRestMethod -Method POST -Uri $uri
$json = $req.Content | ConvertFrom-Json -AsHashtable
$json['keys'][0]['value']
}
function Update-FunctionAppSettings {
param(
[Parameter(Mandatory)]
[string] $Id,
[Parameter(Mandatory)]
[hashtable] $Properties)
$retries = 10
while ($retries--) {
$invokeAzRestMethodSplat = @{
Uri = "https://management.azure.com${Id}?api-version=2024-04-01"
Method = 'PUT'
Payload = @{ properties = $Properties } | ConvertTo-Json
}
$req = Invoke-AzRestMethod @invokeAzRestMethodSplat
if ($req.StatusCode -eq 200) {
return
}
Start-Sleep 1
}
"Failed to update properties for '$Id'. Status Code $($req.StatusCode)." |
Write-Warning
}
function Write-SkipMessage {
param(
[Parameter(Mandatory)]
[string] $Storage,
[Parameter(Mandatory)]
[string] $App)
$PSCmdlet.WriteWarning([string]::Format(
"Storage Account '{0}' associated with Function App '{1}' does not exist.",
$Storage, $App))
}
$accounts = Search-AzGraph "
resources
| where ['type'] == 'microsoft.storage/storageaccounts'
| summarize accounts = make_list(pack('key', name, 'value', id))"
$nameIdMap = @{}
$accounts.Data.accounts |
ForEach-Object { $nameIdMap[$_.key] = $_.value }
$nameKeyMap = @{}
$toUpdate = foreach ($app in Get-FunctionAppSettings) {
$variableUpdates = foreach ($stg in $app.StorageAccounts.Keys) {
if (-not $nameIdMap.ContainsKey($stg)) {
Write-SkipMessage $stg $app.Name
continue
}
if (-not $nameKeyMap.ContainsKey($stg)) {
$nameKeyMap[$stg] = Get-StorageAccountKey $nameIdMap[$stg]
}
foreach ($entry in $app.StorageAccounts[$stg]) {
if ($entry.Key -ne $nameKeyMap[$stg]) {
$newString = $app.Settings['properties'][$entry.Name].Replace(
$entry.Key, $nameKeyMap[$stg])
$app.Settings['properties'][$entry.Name] = $newString
$entry.Name
}
}
}
if ($variableUpdates) {
$app.Settings['VariableUpdates'] = $variableUpdates
$app
}
}
if (-not $toUpdate) {
return 'No Environment Variables updates required.'
}
$toUpdate | ForEach-Object {
$updateFunctionAppSettingsSplat = @{
Id = $_.Settings['id']
Properties = $_.Settings['properties']
}
Update-FunctionAppSettings @updateFunctionAppSettingsSplat
$null, $sub, $null, $rg, $null = $_.Id.Split(
'/', [System.StringSplitOptions]::RemoveEmptyEntries)
[pscustomobject]@{
SubscriptionId = $sub
ResourceGroup = $rg
FunctionApp = $_.Name
UpdatedEnvVars = $_.Settings['VariableUpdates'] -join ', '
}
} | Format-Table -AutoSize | Out-String
using namespace System.Collections
using namespace System.Collections.Generic
Connect-AzAccount -Identity -WA 0 | Out-Null
# Days ago to rotate keys
$dayLimit = 85
class ConnectionString {
static [bool] TryParse([DictionaryEntry] $entry, [ref] $details) {
if ($entry.Value -notmatch '^.+=.+;') {
return $false
}
$data = $entry.Value -replace '\s*;\s*', "`n" | ConvertFrom-StringData
if ($data.ContainsKey('accountName') -and $data.ContainsKey('accountKey')) {
$details.Value = [pscustomobject]@{
StorageAccount = $data['accountName']
Name = $entry.Key
Key = $data['accountKey']
}
return $true
}
return $false
}
}
function New-StorageAccountKey {
param(
[Parameter(Mandatory)]
[string] $Id,
[Parameter()]
[ValidateNotNullOrEmpty()]
[string[]] $KeyName)
if ($KeyName.Count -eq 0) {
$KeyName = 'key1', 'key2'
}
$uri = "https://management.azure.com${Id}/regenerateKey?api-version=2024-01-01"
$result = [ordered]@{}
foreach ($key in $KeyName) {
$body = @{ keyName = $key } | ConvertTo-Json
$req = Invoke-AzRestMethod $uri -Method POST -Payload $body
if ($req.StatusCode -ne 200) {
"Failed to rotate Key '$key' for Storage Account '$Id'. " +
"Status Code $($req.StatusCode)." | Write-Warning
}
$json = $req.Content | ConvertFrom-Json -AsHashtable
foreach ($keyResult in $json['keys']) {
if ($keyResult['keyName'] -eq $key) {
$result[$key] = $keyResult['value']
break
}
}
}
$result
}
function Get-FunctionAppSettings {
param(
[Parameter()]
[ValidateNotNull()]
[string[]] $SubscriptionScope)
$uri = 'https://management.azure.com/{0}/config/appsettings/list?api-version=2024-04-01'
$searchAzGraphSplat = @{
Query = "
resources
| where ['type'] == 'microsoft.web/sites'
and ['kind'] has 'functionapp'
| summarize functions = make_list(pack('id', id, 'name', name))"
}
if ($SubscriptionScope) {
$searchAzGraphSplat['Subscription'] = $SubscriptionScope
}
$req = Search-AzGraph @searchAzGraphSplat
foreach ($function in $req.Data[0].functions) {
$req = Invoke-AzRestMethod ($uri -f $function.id) -Method POST
if ($req.StatusCode -ne 200) {
"Failed to get App Settings for Function App '$($function.name)'. " +
"Status Code $($req.StatusCode)." | Write-Warning
continue
}
$setting = $req.Content | ConvertFrom-Json -AsHashtable
$stgAccounts = @{}
foreach ($pair in $setting['properties'].GetEnumerator()) {
$details = $null
if (-not [ConnectionString]::TryParse($pair, [ref] $details)) {
continue
}
foreach ($detail in $details) {
if (-not $stgAccounts.ContainsKey($detail.StorageAccount)) {
$stgAccounts[$detail.StorageAccount] = [List[object]]::new()
}
$stgAccounts[$detail.StorageAccount].Add($detail)
}
}
[pscustomobject]@{
Id = $function.id
Name = $function.name
Settings = $setting
StorageAccounts = $stgAccounts
}
}
}
function Get-StorageAccountsToRotate {
param(
[Parameter(Mandatory)]
[int] $DayLimit,
[Parameter()]
[ValidateNotNull()]
[string[]] $SubscriptionScope)
$searchAzGraphSplat = @{
Query = "
resources
| where type == 'microsoft.storage/storageaccounts'
| extend keyCreation = properties.keyCreationTime
| extend
key1 = now() - make_datetime(keyCreation.key1),
key2 = now() - make_datetime(keyCreation.key2)
| where key1 > ${DayLimit}d or key2 > ${DayLimit}d
| project subscriptionId, resourceGroup, id, name, key1, key2"
}
if ($SubscriptionScope) {
$searchAzGraphSplat['Subscription'] = $SubscriptionScope
}
do {
$req = Search-AzGraph @searchAzGraphSplat
$searchAzGraphSplat['SkipToken'] = $req.SkipToken
if ($req.Count -gt 0) {
$req.Data
}
}
while ($req.SkipToken)
}
function Update-FunctionAppSettings {
param(
[Parameter(Mandatory)]
[string] $Id,
[Parameter(Mandatory)]
[hashtable] $Properties)
$retries = 10
while ($retries--) {
$invokeAzRestMethodSplat = @{
Uri = "https://management.azure.com${Id}?api-version=2024-04-01"
Method = 'PUT'
Payload = @{ properties = $Properties } | ConvertTo-Json
}
$req = Invoke-AzRestMethod @invokeAzRestMethodSplat
if ($req.StatusCode -eq 200) {
return
}
Start-Sleep 1
}
"Failed to update properties for '$Id'. Status Code $($req.StatusCode)." |
Write-Warning
}
$getStorageAccountsToRotateSplat = @{
DayLimit = $dayLimit
}
$stgAccounts = Get-StorageAccountsToRotate @getStorageAccountsToRotateSplat
if (-not $stgAccounts) {
return 'No expiring Storage Account keys found.'
}
$apps = Get-FunctionAppSettings
$rotatedAccounts = foreach ($account in $stgAccounts) {
$newKeys = New-StorageAccountKey -Id $account.id
$key = $newKeys.Values | Select-Object -First 1
foreach ($app in $apps) {
if (-not $app.StorageAccounts.ContainsKey($account.name)) {
continue
}
foreach ($variable in $app.StorageAccounts[$account.name]) {
$connectionString = $app.Settings['properties'][$variable.Name].Replace(
$variable.Key, $key)
$app.Settings['properties'][$variable.Name] = $connectionString
if (-not $app.Settings.ContainsKey('updatedVars')) {
$app.Settings['updatedVars'] = [List[string]]::new()
}
$app.Settings['updatedVars'].Add($variable.Name)
}
}
$account
}
@"
Rotated Storage Accounts
------------------------
$($rotatedAccounts |
Format-Table subscriptionId, resourceGroup, name -AutoSize |
Out-String)
"@
$updatedVars = foreach ($app in $apps) {
if ($app.Settings['updatedVars'].Count -eq 0) {
continue
}
$updateFunctionAppSettingsSplat = @{
Id = $app.Settings['id']
Properties = $app.Settings['properties']
}
Update-FunctionAppSettings @updateFunctionAppSettingsSplat
[pscustomobject]@{
Name = $app.Name
UpdatedEnvVars = $app.Settings.updatedVars -join ', '
}
}
if (-not $updatedVars) {
return
}
@"
Updated Variables for Function Apps
-----------------------------------
$($updatedVars | Format-Table -AutoSize | Out-String)
"@
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment