Why Resource Locks Matter

Azure resource locks are an important security feature that prevent accidental deletion or modification of important resources. However, manual implementation can be tedious and locks may be inadvertently removed, forgotten to put back, or not added to new resources. This post explains how to automate the process using Azure Automation runbooks.

Implementation Overview

Creating the Automation Runbook

  1. Create a new Azure Automation account or use an existing one
  2. Create a PowerShell runbook that will:
    • Scan for resources without locks
    • Apply appropriate lock types (CanNotDelete or ReadOnly)
    • Skip dynamic resources like AKS nodes

Sample PowerShell Script

# Azure Resource Lock Automation Script - Fixed Authentication
# This script processes locks in batches to avoid timeout limits

#Requires -Module Az.Accounts, Az.Resources

param(
    [string[]]$SubscriptionIds = @(),
    [string[]]$ExemptResourceGroups = @(),
    [string[]]$ExemptResources = @(),
    [string]$LockName = "DenyDelete",
    [string]$LockNotes = "Delete lock",
    [switch]$WhatIf = $false,
    [switch]$IncludeResources = $true,
    [int]$BatchSize = 10,
    [int]$MaxExecutionMinutes = 150,
    [string]$StateTableName = "LockAutomationState",
    [string]$StorageAccountName = "",
    [string]$StorageResourceGroup = "",
    [string[]]$TargetResourceTypes = @(
        "Microsoft.Compute/virtualMachines",
        "Microsoft.Compute/virtualMachineScaleSets", 
        "Microsoft.Sql/servers",
        "Microsoft.Sql/managedInstances",
        "Microsoft.DBforPostgreSQL/servers",
        "Microsoft.DBforMySQL/servers", 
        "Microsoft.DBforMariaDB/servers",
        "Microsoft.DocumentDB/databaseAccounts",
        "Microsoft.Storage/storageAccounts",
        "Microsoft.Network/virtualNetworks",
        "Microsoft.Network/networkSecurityGroups",
        "Microsoft.Network/routeTables", 
        "Microsoft.Network/publicIPAddresses",
        "Microsoft.Network/loadBalancers",
        "Microsoft.Network/applicationGateways",
        "Microsoft.Network/dnszones",
        "Microsoft.Network/privateDnsZones",
        "Microsoft.KeyVault/vaults",
        "Microsoft.RecoveryServices/vaults",
        "Microsoft.ContainerRegistry/registries",
        "Microsoft.Kubernetes/connectedClusters",
        "Microsoft.ContainerService/managedClusters",
        "Microsoft.Web/sites",
        "Microsoft.Web/serverfarms",
        "Microsoft.Logic/workflows",
        "Microsoft.DataFactory/factories",
        "Microsoft.Synapse/workspaces",
        "Microsoft.Network/natGateways",
        "Microsoft.Network/vpnGateways",
        "Microsoft.Purview/accounts",
        "Microsoft.Security/pricings",
        "Microsoft.OperationsManagement/solutions"
    )
)


# Global variables for tracking
$script:StartTime = Get-Date
$script:ProcessedCount = 0
$script:SuccessCount = 0
$script:SkippedCount = 0
$script:ErrorCount = 0
$script:BatchNumber = 0
$script:TimeoutReached = $false

# Check execution time limit
function Test-ExecutionTimeLimit {
    $elapsed = (Get-Date) - $script:StartTime
    $remainingMinutes = $MaxExecutionMinutes - $elapsed.TotalMinutes
    
    if ($remainingMinutes -le 5) {  # Stop with 5 minutes buffer
        $script:TimeoutReached = $true
        Write-Output "โš ๏ธ TIMEOUT WARNING: Only $([math]::Round($remainingMinutes, 1)) minutes remaining. Stopping execution."
        return $false
    }
    
    return $true
}

function Write-BatchSummary {
    param(
        [int]$BatchNum,
        [string]$SubscriptionId,
        [int]$BatchProcessed,
        [int]$BatchSuccess,
        [int]$BatchSkipped,
        [int]$BatchErrors,
        [datetime]$BatchStartTime
    )
    
    $batchElapsed = (Get-Date) - $BatchStartTime
    $totalElapsed = (Get-Date) - $script:StartTime
    
    Write-Output "`n๐Ÿ“Š --- Batch $BatchNum Summary ---"
    Write-Output "๐ŸŽฏ Subscription: $SubscriptionId"
    Write-Output "๐Ÿ“‹ Batch processed: $BatchProcessed"
    Write-Output "โœ… Batch successful: $BatchSuccess"
    Write-Output "โญ๏ธ  Batch skipped: $BatchSkipped"
    Write-Output "โŒ Batch errors: $BatchErrors"
    Write-Output "โฑ๏ธ  Batch time: $([math]::Round($batchElapsed.TotalMinutes, 1)) minutes"
    Write-Output "๐Ÿ• Total elapsed: $([math]::Round($totalElapsed.TotalMinutes, 1)) minutes"
    Write-Output "โณ Remaining time: $([math]::Round($MaxExecutionMinutes - $totalElapsed.TotalMinutes, 1)) minutes"
    Write-Output "๐Ÿ“Š --- End Batch Summary ---`n"
}

# Simplified state management (in-memory for this version)
$script:ProcessedResourceGroups = @{}

function Connect-ToAzure {
    try {
        Write-Output "๐Ÿ” Checking Azure authentication status..."
        
        # Check if we already have a PowerShell Az context
        $context = Get-AzContext -ErrorAction SilentlyContinue
        if ($context) {
            Write-Output "โœ“ Already connected to Azure PowerShell as: $($context.Account.Id)"
            Write-Output "โœ“ Current subscription: $($context.Subscription.Name) ($($context.Subscription.Id))"
            return $true
        }
        
        Write-Output "โš ๏ธ No existing Azure PowerShell context found"
        
        # Check if we're running in Azure Automation (has specific environment variables)
        $isAzureAutomation = $env:AUTOMATION_ASSET_ACCOUNTID -or $env:AUTOMATION_RESOURCE_GROUP
        
        if ($isAzureAutomation) {
            # Try to connect using Managed Identity (for Azure Automation)
            try {
                Write-Output "๐Ÿ”„ Detected Azure Automation environment. Attempting to connect using Managed Identity..."
                Connect-AzAccount -Identity -ErrorAction Stop
                $context = Get-AzContext
                Write-Output "โœ“ Successfully connected using Managed Identity"
                Write-Output "โœ“ Connected as: $($context.Account.Id)"
                return $true
            } catch {
                Write-Output "โŒ Managed Identity connection failed: $($_.Exception.Message)"
            }
        } else {
            # Running locally - try to import Azure CLI credentials
            Write-Output "๐Ÿ”„ Running locally. Attempting to import Azure CLI credentials..."
            try {
                # Try to connect using Azure CLI credentials
                Connect-AzAccount -UseDeviceAuthentication:$false -ErrorAction Stop
                $context = Get-AzContext
                Write-Output "โœ“ Successfully connected using existing credentials"
                Write-Output "โœ“ Connected as: $($context.Account.Id)"
                return $true
            } catch {
                Write-Output "โŒ Failed to connect using existing credentials: $($_.Exception.Message)"
                
                # Last resort - ask user to connect manually
                Write-Output "๐Ÿ’ก Please run 'Connect-AzAccount' first to authenticate to Azure PowerShell"
                Write-Output "๐Ÿ’ก Note: 'az login' is for Azure CLI, but this script requires Azure PowerShell authentication"
                Write-Error "Authentication required. Please run 'Connect-AzAccount' before running this script."
                return $false
            }
        }
        
        Write-Error "Authentication required. Please authenticate to Azure before running this script."
        return $false
        
    } catch {
        Write-Error "Failed to connect to Azure: $($_.Exception.Message)"
        return $false
    }
}

function Add-ResourceGroupLockOptimized {
    param(
        [string]$SubscriptionId,
        [string]$ResourceGroupName,
        [string]$LockName,
        [string]$Notes,
        [bool]$WhatIf
    )
    
    try {
        $script:ProcessedCount++
        
        # Quick check for existing lock
        $existingLock = Get-AzResourceLock -ResourceGroupName $ResourceGroupName -ErrorAction SilentlyContinue | 
                      Where-Object { $_.Properties.level -eq "CanNotDelete" } | 
                      Select-Object -First 1
        
        if ($existingLock) {
            Write-Output "โœ“ RG '$ResourceGroupName' already locked"
            $script:SkippedCount++
            return
        }
        
        if ($WhatIf) {
            Write-Output "WHATIF: Would lock RG '$ResourceGroupName'"
            return
        }
        
        New-AzResourceLock -ResourceGroupName $ResourceGroupName -LockName $LockName -LockLevel CanNotDelete -LockNotes $Notes -Force -ErrorAction Stop
        Write-Output "โœ“ Locked RG '$ResourceGroupName'"
        $script:SuccessCount++
        
    } catch {
        Write-Warning "Failed to lock RG '$ResourceGroupName': $($_.Exception.Message)"
        $script:ErrorCount++
    }
}

function Add-ResourceLockOptimized {
    param(
        [object]$Resource,
        [string]$LockName,
        [string]$Notes,
        [bool]$WhatIf,
        [string[]]$TargetResourceTypes
    )
    
    try {
        # Quick type check
        if ($Resource.ResourceType -notin $TargetResourceTypes) {
            return
        }
        
        $script:ProcessedCount++
        
        # Quick check for existing lock
        $existingLock = Get-AzResourceLock -ResourceName $Resource.Name -ResourceType $Resource.ResourceType -ResourceGroupName $Resource.ResourceGroupName -ErrorAction SilentlyContinue | 
                      Where-Object { $_.Properties.level -eq "CanNotDelete" } | 
                      Select-Object -First 1
        
        if ($existingLock) {
            $script:SkippedCount++
            return
        }
        
        if ($WhatIf) {
            Write-Output "WHATIF: Would lock resource '$($Resource.Name)' ($($Resource.ResourceType))"
            return
        }
        
        New-AzResourceLock -ResourceName $Resource.Name -ResourceType $Resource.ResourceType -ResourceGroupName $Resource.ResourceGroupName -LockName $LockName -LockLevel CanNotDelete -LockNotes $Notes -Force -ErrorAction Stop
        Write-Output "โœ“ Locked resource '$($Resource.Name)' ($($Resource.ResourceType))"
        $script:SuccessCount++
        
    } catch {
        Write-Warning "Failed to lock resource '$($Resource.Name)': $($_.Exception.Message)"
        $script:ErrorCount++
    }
}

function Test-ResourceGroupExemption {
    param([string]$ResourceGroupName, [string[]]$ExemptList)
    
    foreach ($exemption in $ExemptList) {
        if ($ResourceGroupName -like $exemption) {
            return $true
        }
    }
    return $false
}

# Main execution
try {
    Write-Output "=== Azure Resource Lock Automation - Batch Mode ==="
    Write-Output "Started at: $(Get-Date)"
    Write-Output "Max execution time: $MaxExecutionMinutes minutes"
    Write-Output "Batch size: $BatchSize resource groups"
    
    # Connect to Azure (or verify existing connection)
    if (-not (Connect-ToAzure)) {
        Write-Error "Failed to establish Azure connection. Exiting."
        exit 1
    }
    
    # Get subscriptions
    if ($SubscriptionIds.Count -eq 0) {
        Write-Output "๐Ÿ“‹ No specific subscriptions provided. Getting all enabled subscriptions..."
        Write-Output "๐Ÿ”„ Querying Azure for enabled subscriptions..."
        $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" }
        $SubscriptionIds = $subscriptions.Id
        Write-Output "โœ“ Found $($SubscriptionIds.Count) enabled subscriptions"
        
        # List the subscriptions we found
        foreach ($sub in $subscriptions) {
            Write-Output "   - $($sub.Name) ($($sub.Id))"
        }
    } else {
        Write-Output "๐Ÿ“‹ Using provided subscription IDs: $($SubscriptionIds.Count) subscription(s)"
    }
    
    Write-Output "`n๐Ÿš€ Starting processing of $($SubscriptionIds.Count) subscription(s)..."
    
    foreach ($subscriptionId in $SubscriptionIds) {
        # Check time limit before each subscription
        if (-not (Test-ExecutionTimeLimit)) {
            Write-Output "Time limit reached. Stopping subscription processing."
            break
        }
        
        Write-Output "`n๐ŸŽฏ === Processing Subscription: $subscriptionId ==="
        
        try {
            # Set context to the subscription
            Write-Output "๐Ÿ”„ Setting Azure context to subscription..."
            $context = Set-AzContext -SubscriptionId $subscriptionId -ErrorAction Stop
            Write-Output "โœ“ Set context to subscription: $($context.Subscription.Name)"
            
            # Get resource groups
            Write-Output "๐Ÿ“ฆ Getting resource groups from subscription..."
            $resourceGroups = Get-AzResourceGroup -ErrorAction Stop
            
            Write-Output "โœ“ Found $($resourceGroups.Count) resource groups in subscription"
            
            if ($resourceGroups.Count -eq 0) {
                Write-Output "โš ๏ธ No resource groups found in this subscription. Skipping..."
                continue
            }
            
            # Show exemption info if any
            if ($ExemptResourceGroups.Count -gt 0) {
                Write-Output "๐Ÿšซ Exempted resource group patterns: $($ExemptResourceGroups -join ', ')"
            }
            
            Write-Output "โณ Processing resource groups in batches of $BatchSize..."
            
            # Process in batches
            for ($i = 0; $i -lt $resourceGroups.Count; $i += $BatchSize) {
                # Check time limit before each batch
                if (-not (Test-ExecutionTimeLimit)) {
                    break
                }
                
                $script:BatchNumber++
                $batchStartTime = Get-Date
                $batchProcessed = 0
                $batchSuccess = 0
                $batchSkipped = 0
                $batchErrors = 0
                
                $batch = $resourceGroups | Select-Object -Skip $i -First $BatchSize
                Write-Output "๐Ÿ“‹ Processing batch $($script:BatchNumber): RGs $($i + 1) to $($i + $batch.Count) of $($resourceGroups.Count)"
                Write-Output "โฑ๏ธ  Batch started at: $(Get-Date -Format 'HH:mm:ss')"
                
                foreach ($rg in $batch) {
                    Write-Output "๐Ÿ” Processing RG: $($rg.ResourceGroupName)"
                    
                    # Check time limit during batch processing
                    if (-not (Test-ExecutionTimeLimit)) {
                        Write-Output "โฐ Time limit reached during batch processing. Stopping current batch."
                        break
                    }
                    
                    # Check exemptions
                    if (Test-ResourceGroupExemption -ResourceGroupName $rg.ResourceGroupName -ExemptList $ExemptResourceGroups) {
                        Write-Output "๐Ÿšซ Skipping exempted RG: $($rg.ResourceGroupName)"
                        $batchSkipped++
                        continue
                    }
                    
                    # Track counts before processing
                    $beforeProcessed = $script:ProcessedCount
                    $beforeSuccess = $script:SuccessCount
                    $beforeSkipped = $script:SkippedCount
                    $beforeErrors = $script:ErrorCount
                    
                    # Lock resource group
                    Write-Output "๐Ÿ”’ Checking/adding lock for RG: $($rg.ResourceGroupName)"
                    Add-ResourceGroupLockOptimized -SubscriptionId $subscriptionId -ResourceGroupName $rg.ResourceGroupName -LockName $LockName -Notes $LockNotes -WhatIf $WhatIf
                    
                    # Lock individual resources if enabled
                    if ($IncludeResources) {
                        try {
                            Write-Output "๐Ÿ“ฆ Getting resources from RG: $($rg.ResourceGroupName)"
                            $resources = Get-AzResource -ResourceGroupName $rg.ResourceGroupName -ErrorAction Stop
                            
                            if ($resources.Count -gt 0) {
                                Write-Output "   Found $($resources.Count) resources to evaluate"
                                
                                foreach ($resource in $resources) {
                                    # Check time limit during resource processing
                                    if (-not (Test-ExecutionTimeLimit)) {
                                        Write-Output "โฐ Time limit reached during resource processing. Stopping current RG."
                                        break
                                    }
                                    
                                    # Only show output for resources we're actually processing
                                    if ($resource.ResourceType -in $TargetResourceTypes) {
                                        Write-Output "   ๐Ÿ” Evaluating: $($resource.Name) ($($resource.ResourceType))"
                                    }
                                    
                                    Add-ResourceLockOptimized -Resource $resource -LockName $LockName -Notes $LockNotes -WhatIf $WhatIf -TargetResourceTypes $TargetResourceTypes
                                }
                            } else {
                                Write-Output "   No resources found in RG"
                            }
                        } catch {
                            Write-Warning "โŒ Failed to get resources from RG $($rg.ResourceGroupName): $($_.Exception.Message)"
                            $batchErrors++
                        }
                    }
                    
                    # Calculate batch-specific counts
                    $batchProcessed += ($script:ProcessedCount - $beforeProcessed)
                    $batchSuccess += ($script:SuccessCount - $beforeSuccess)
                    $batchSkipped += ($script:SkippedCount - $beforeSkipped)
                    $batchErrors += ($script:ErrorCount - $beforeErrors)
                    
                    # Break if time limit reached
                    if ($script:TimeoutReached) {
                        break
                    }
                }
                
                # Write batch summary
                Write-BatchSummary -BatchNum $script:BatchNumber -SubscriptionId $subscriptionId -BatchProcessed $batchProcessed -BatchSuccess $batchSuccess -BatchSkipped $batchSkipped -BatchErrors $batchErrors -BatchStartTime $batchStartTime
                
                # Break if time limit reached
                if ($script:TimeoutReached) {
                    break
                }
            }
            
        } catch {
            Write-Error "Failed to process subscription $subscriptionId : $($_.Exception.Message)"
            continue
        }
        
        # Break if time limit reached
        if ($script:TimeoutReached) {
            Write-Output "Time limit reached. Stopping all processing."
            break
        }
    }
    
    Write-Output "`n๐Ÿ === FINAL EXECUTION SUMMARY ==="
    Write-Output "๐Ÿ Completed at: $(Get-Date)"
    $totalElapsed = (Get-Date) - $script:StartTime
    Write-Output "โฑ๏ธ  Total execution time: $([math]::Round($totalElapsed.TotalMinutes, 1)) minutes"
    Write-Output "๐Ÿ“Š Batches processed: $($script:BatchNumber)"
    Write-Output "๐Ÿ“‹ Total items processed: $($script:ProcessedCount)"
    Write-Output "โœ… Successful locks: $($script:SuccessCount)"
    Write-Output "โญ๏ธ  Skipped (already locked): $($script:SkippedCount)"
    Write-Output "โŒ Errors: $($script:ErrorCount)"
    
    if ($script:TimeoutReached) {
        Write-Output "โš ๏ธ EXECUTION STOPPED DUE TO TIME LIMIT"
        Write-Output "๐Ÿ’ก Consider running again to continue processing remaining items."
    }
    
    if ($WhatIf) {
        Write-Output "๐Ÿ” *** This was a PREVIEW run. No actual changes were made. ***"
    }
    
    Write-Output "๐Ÿ === END SUMMARY ==="
    
} catch {
    Write-Error "Script execution failed: $($_.Exception.Message)"
    exit 1
}

## Scheduling the Automation

### Configure Recurring Schedule

1. Set up a schedule in Azure Automation
2. Configure the runbook to execute every 6 hours
3. Ensure proper permissions are assigned to the Automation Account

```powershell
$schedule = New-AzAutomationSchedule `
-AutomationAccountName "automatic-resource-locks" `
-Name "ResourceLockSchedule" `
-StartTime "2024-01-01T00:00:00" `
-HourInterval 6

Best Practices

  • Maintain an exclusion list for resources that shouldn’t be locked
  • Implement logging to track lock changes
  • Set up alerts for lock removal events
  • Regular review of locked resources to ensure proper protection

Monitoring and Maintenance

Regularly check the Automation account’s job history to ensure the runbook executes successfully. Monitor for any failures and adjust the script as needed based on your infrastructure changes.

Conclusion

Automating resource locks ensures consistent protection of critical Azure resources. By implementing this solution, you can maintain security compliance while reducing manual overhead.