From e779fcc75b431496e2d76d8358c1ac352b24fecc Mon Sep 17 00:00:00 2001 From: SaiPrasannaKishor Date: Fri, 3 Apr 2026 11:02:01 -0700 Subject: [PATCH 1/5] Fix az.network module loading and enhance script parameters Fixed issue with az.network module auto load and improved error handling. Added parameters for Global Firewall mode and input file path, with prompts if not specified. --- .../ipssigupdate.ps1 | 59 ++++++++++--------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/Azure Firewall/Script - IDPS Override script/ipssigupdate.ps1 b/Azure Firewall/Script - IDPS Override script/ipssigupdate.ps1 index 83bd97d0..a044223a 100644 --- a/Azure Firewall/Script - IDPS Override script/ipssigupdate.ps1 +++ b/Azure Firewall/Script - IDPS Override script/ipssigupdate.ps1 @@ -5,6 +5,7 @@ # Updated: 3/6/2026 Modified to move away from direct REST API calls (413 payload limits / signatureOverrides route inconsistencies) # and use Az modules (Az.Accounts + Az.Network). # Updated: 3/12/2026 Added parameters for Global Firewall mode and input file path, with prompts if not specified. Made progress reporting more granular and informative. Added error handling around API calls. +# Updated: 4/1/2026 - Fixed issue with az.network module auto load # # requires a connect-azaccount first so we can get the token # @@ -20,20 +21,20 @@ # "9999999","ACTION" # # Signature and Action (Deny, Alert, Disabled) - + param( [Parameter(Mandatory = $false)] [string]$InputFile, - + # If not specified, script will leave the policy's global mode unchanged # Accepts: Off, Alert, Deny, Disabled (Disabled is normalized to Off) [Parameter(Mandatory = $false)] [ValidateSet("Off","Alert","Deny","Disabled")] [string]$GlobalMode ) - + Write-Host 'Format should be CSV, with headers "signatureId", "mode"' -ForegroundColor Yellow - + # (Change #1) InputFile parameter: if not passed, prompt like before $input_file = $InputFile if ([string]::IsNullOrWhiteSpace($input_file)) { @@ -43,7 +44,7 @@ if ([string]::IsNullOrWhiteSpace($input_file)) { Write-Host "`nNo filename specified. Exiting..." -ForegroundColor Red exit } - + # Get the Config Options from the config file $json = Get-Content -Path "ipsconfig.json" -Raw $config = $json | ConvertFrom-Json @@ -53,58 +54,60 @@ $fwp = $config.fwp $location = $config.location $rcg = $config.rcg $fw = $config.fw - + # File needs to have at minimum a header with "signatureId","mode" # Set mode to Disabled, Alert, or Deny # if using the file from ipssigs.ps1 it will have more fields, but we only care about the first two $content = Import-Csv -Path $input_file -Header "signatureId","mode","extra1","extra2","extra3","extra4","extra5","extra6","extra7","extra8","extra9","extra10" | Select-Object -Skip 1 # $content = Import-Csv -Path $input_file -Header "signatureId", "mode" - + Write-Host ("Processing Signature updates file - $input_file" ) -ForegroundColor Cyan Write-Host ("Rows read from CSV (including blanks that will be skipped): " + $content.Count) -ForegroundColor DarkGray - + # Pre-process CSV into compact objects (Id as UInt64, Mode normalized to Off/Alert/Deny) $items = $content | Where-Object { -not [string]::IsNullOrWhiteSpace($_.signatureId) -and -not [string]::IsNullOrWhiteSpace($_.mode) } | Select-Object ` @{Name='Id'; Expression={[UInt64]$_.signatureId.Trim()}}, ` @{Name='Mode'; Expression={ $m = $_.mode.Trim(); if ($m -ieq "Disabled") { "Off" } else { $m } }} - + $totalItems = $items.Count Write-Host ("Total signatures to upload (after filtering blanks): " + $totalItems) -ForegroundColor Yellow - + if ($totalItems -eq 0) { Write-Host "No valid signature rows found. Exiting..." -ForegroundColor Red exit } - + Write-Host "Building signature override objects (sequential, fast path)..." -ForegroundColor Cyan $progressId = 1 Write-Progress -Id $progressId -Activity "Building signature override objects" -Status "Starting..." -PercentComplete 0 - + $sig_overrides = New-Object System.Collections.Generic.List[object] $i = 0 foreach ($it in $items) { $i++ - - $o = [Microsoft.Azure.Commands.Network.Models.PSAzureFirewallPolicyIntrusionDetectionSignatureOverride]::new() - $o.Id = [UInt64]$it.Id - $o.Mode = [string]$it.Mode # Off/Alert/Deny accepted values + + $o = [PSCustomObject]@{ + Id = [UInt64]$it.Id + Mode = [string]$it.Mode + } $sig_overrides.Add($o) | Out-Null - + + if (($i % 2000) -eq 0 -or $i -eq $totalItems) { $pct = [math]::Round(($i / $totalItems) * 100, 2) Write-Progress -Id $progressId -Activity "Building signature override objects" -Status "$i / $totalItems complete" -PercentComplete $pct Write-Host ("Built $i override objects...") -ForegroundColor DarkGray } } - + Write-Progress -Id $progressId -Activity "Building signature override objects" -Completed Write-Host ("Built override objects: " + $sig_overrides.Count) -ForegroundColor Green - + # Retrieve the existing Firewall Policy object (local edit → push once) $policyId = "/subscriptions/$subs/resourceGroups/$rg/providers/Microsoft.Network/firewallPolicies/$fwp" - + Write-Host "Retrieving full Firewall Policy object (large policies take time)..." -ForegroundColor Yellow $sw = [System.Diagnostics.Stopwatch]::StartNew() try { @@ -118,7 +121,7 @@ catch { } $sw.Stop() Write-Host ("Firewall policy retrieved in " + $sw.Elapsed.ToString()) -ForegroundColor DarkGray - + # (Change #2) GlobalMode parameter: # - If specified, set it # - If not specified, leave it alone (use existing policy mode) @@ -133,31 +136,31 @@ if (-not [string]::IsNullOrWhiteSpace($GlobalMode)) { $effectiveGlobalMode = "Deny" } } - + # Build intrusion detection configuration with signature overrides # New-AzFirewallPolicyIntrusionDetection accepts -SignatureOverride Write-Host ("Building intrusion detection configuration (mode: $effectiveGlobalMode)") -ForegroundColor Cyan $intrusionDetection = New-AzFirewallPolicyIntrusionDetection -Mode $effectiveGlobalMode -SignatureOverride $sig_overrides.ToArray() - + # Push the updated policy once (do not rely on object property assignment; Set-AzFirewallPolicy supports -IntrusionDetection) Write-Host ("Submitting firewall policy update (async job - not waiting)...") -ForegroundColor Cyan - + try { $job = Set-AzFirewallPolicy -InputObject $policy -IntrusionDetection $intrusionDetection -AsJob - + Write-Host "Update submitted successfully (job started). Exiting without waiting." -ForegroundColor Green Write-Host ("Job Id: {0}" -f $job.Id) -ForegroundColor Yellow Write-Host ("Job Name: {0}" -f $job.Name) -ForegroundColor DarkGray - + Write-Host "`nCheck status later with:" -ForegroundColor Cyan Write-Host (" Get-Job -Id {0}" -f $job.Id) -ForegroundColor White Write-Host (" Receive-Job -Id {0} -Keep" -f $job.Id) -ForegroundColor White Write-Host (" (When finished) Remove-Job -Id {0}" -f $job.Id) -ForegroundColor White - + exit 0 } catch { Write-Host "`nError submitting the update job:" -ForegroundColor Red Write-Host $_.Exception.Message -ForegroundColor Red exit 1 -} \ No newline at end of file +} From 6c7ce39ab67a3a37555683b00043207f11fcbfd5 Mon Sep 17 00:00:00 2001 From: SaiPrasannaKishor Date: Thu, 9 Jul 2026 01:34:20 -0700 Subject: [PATCH 2/5] Create azuredeploy.json azuredeploy.json --- .../azuredeploy.json | 834 ++++++++++++++++++ 1 file changed, 834 insertions(+) create mode 100644 Azure Firewall/New Template - Logic App for Azure Firewall Backups with PS Script/azuredeploy.json diff --git a/Azure Firewall/New Template - Logic App for Azure Firewall Backups with PS Script/azuredeploy.json b/Azure Firewall/New Template - Logic App for Azure Firewall Backups with PS Script/azuredeploy.json new file mode 100644 index 00000000..e90df811 --- /dev/null +++ b/Azure Firewall/New Template - Logic App for Azure Firewall Backups with PS Script/azuredeploy.json @@ -0,0 +1,834 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "3.0.0.0", + "parameters": { + "Playbook_Name": { + "defaultValue": "BackUp-AzFW", + "type": "String", + "metadata": { + "displayName": "Logic App Name", + "description": "Name of the Logic App that runs the Azure Firewall backup process" + } + }, + "Storage_Account_Name": { + "defaultValue": "fwbackups", + "type": "String", + "metadata": { + "displayName": "Storage Account Name", + "description": "Name of the Storage Account to store backups (can be behind a firewall with Managed Identity)" + } + }, + "Firewall_Name": { + "defaultValue": "", + "type": "String", + "metadata": { + "displayName": "Azure Firewall Name", + "description": "Name of the Azure Firewall to backup" + } + }, + "Firewall_Policy_Name": { + "defaultValue": "", + "type": "String", + "metadata": { + "displayName": "Azure Firewall Policy Name", + "description": "Name of the Azure Firewall Policy to backup" + } + }, + "Subscription_Id": { + "defaultValue": "[subscription().subscriptionId]", + "type": "String", + "metadata": { + "displayName": "Firewall Subscription ID", + "description": "Subscription ID hosting the Azure Firewall and Policy" + } + }, + "Firewall_Resource_Group_Name": { + "defaultValue": "", + "type": "String", + "metadata": { + "displayName": "Firewall Resource Group", + "description": "Resource Group hosting the Azure Firewall" + } + }, + "Policy_Resource_Group_Name": { + "defaultValue": "", + "type": "String", + "metadata": { + "displayName": "Policy Resource Group (leave empty if same as Firewall RG)", + "description": "Resource Group hosting the Firewall Policy. Leave empty if it is the same as the Firewall Resource Group." + } + }, + "Backup_Frequency_Days": { + "defaultValue": 3, + "type": "Int", + "metadata": { + "displayName": "Backup Frequency (Days)", + "description": "How often (in days) the backup should run" + } + }, + "Backup_Start_Time": { + "defaultValue": "2024-01-01T02:00:00Z", + "type": "String", + "metadata": { + "displayName": "Backup Start Time (UTC)", + "description": "The time of day (UTC) when the backup runs. Format: yyyy-MM-ddTHH:mm:ssZ" + } + }, + "Retention_Days": { + "defaultValue": 30, + "type": "Int", + "metadata": { + "displayName": "Backup Retention (Days)", + "description": "Number of days to retain backup files before auto-deletion" + } + }, + "Enable_Write_Trigger": { + "defaultValue": false, + "type": "Bool", + "metadata": { + "displayName": "Trigger backup on every write operation", + "description": "When enabled, deploys an Activity Log Alert that triggers a backup on ANY change to the Azure Firewall Policy or its child resources - matched by the Microsoft.Network/firewallPolicies operation namespace (base policy, rule collection groups, drafts, draft deploys, IDPS signature overrides, deletes, and any future operations)." + } + }, + "Write_Trigger_Cooldown_Minutes": { + "defaultValue": 5, + "type": "Int", + "metadata": { + "displayName": "Write Trigger Cooldown (Minutes)", + "description": "Minimum minutes between write-triggered backups to avoid rapid-fire backups during bulk changes. Only applies when Write Trigger is enabled." + } + } + }, + "variables": { + "policyRG": "[if(empty(parameters('Policy_Resource_Group_Name')), parameters('Firewall_Resource_Group_Name'), parameters('Policy_Resource_Group_Name'))]", + "storageAccountId": "[resourceId('Microsoft.Storage/storageAccounts', parameters('Storage_Account_Name'))]", + "onWriteLogicAppName": "[concat(parameters('Playbook_Name'), '-OnWrite')]", + "actionGroupName": "[concat(parameters('Playbook_Name'), '-AG')]", + "alertName": "[concat(parameters('Playbook_Name'), '-WriteAlert')]", + "firewallResourceId": "[concat('/subscriptions/', parameters('Subscription_Id'), '/resourceGroups/', parameters('Firewall_Resource_Group_Name'), '/providers/Microsoft.Network/azureFirewalls/', parameters('Firewall_Name'))]", + "policyResourceId": "[concat('/subscriptions/', parameters('Subscription_Id'), '/resourceGroups/', variables('policyRG'), '/providers/Microsoft.Network/firewallPolicies/', parameters('Firewall_Policy_Name'))]", + "policyRGScope": "[concat('/subscriptions/', parameters('Subscription_Id'), '/resourceGroups/', variables('policyRG'))]", + "backupWorkflowParameters": { + "subscriptionId": { + "defaultValue": "[parameters('Subscription_Id')]", + "type": "String" + }, + "firewallResourceGroup": { + "defaultValue": "[parameters('Firewall_Resource_Group_Name')]", + "type": "String" + }, + "policyResourceGroup": { + "defaultValue": "[variables('policyRG')]", + "type": "String" + }, + "firewallName": { + "defaultValue": "[parameters('Firewall_Name')]", + "type": "String" + }, + "firewallPolicyName": { + "defaultValue": "[parameters('Firewall_Policy_Name')]", + "type": "String" + }, + "storageAccountName": { + "defaultValue": "[parameters('Storage_Account_Name')]", + "type": "String" + } + }, + "backupWorkflowActions": { + "Export_Firewall_Template": { + "runAfter": {}, + "type": "Http", + "inputs": { + "authentication": { + "type": "ManagedServiceIdentity" + }, + "body": { + "options": "IncludeParameterDefaultValue", + "resources": [ + "/subscriptions/@{parameters('subscriptionId')}/resourceGroups/@{parameters('policyResourceGroup')}/providers/Microsoft.Network/firewallPolicies/@{parameters('firewallPolicyName')}" + ] + }, + "method": "POST", + "uri": "https://management.azure.com/subscriptions/@{parameters('subscriptionId')}/resourcegroups/@{parameters('policyResourceGroup')}/exportTemplate?api-version=2021-04-01" + } + }, + "Check_Export_Success": { + "runAfter": { + "Export_Firewall_Template": [ + "Succeeded" + ] + }, + "type": "If", + "expression": { + "and": [ + { + "equals": [ + "@outputs('Export_Firewall_Template')['statusCode']", + 200 + ] + } + ] + }, + "actions": { + "Parse_Template": { + "runAfter": {}, + "type": "ParseJson", + "inputs": { + "content": "@body('Export_Firewall_Template')?['template']", + "schema": { + "type": "object" + } + } + }, + "Filter_RuleCollectionGroups": { + "runAfter": { + "Parse_Template": [ + "Succeeded" + ] + }, + "type": "Query", + "inputs": { + "from": "@body('Parse_Template')?['resources']", + "where": "@equals(item()?['type'], 'Microsoft.Network/firewallPolicies/ruleCollectionGroups')" + } + }, + "Filter_Other_Resources": { + "runAfter": { + "Parse_Template": [ + "Succeeded" + ] + }, + "type": "Query", + "inputs": { + "from": "@body('Parse_Template')?['resources']", + "where": "@and(not(equals(item()?['type'], 'Microsoft.Network/firewallPolicies/ruleCollectionGroups')), not(equals(item()?['type'], 'Microsoft.Network/azureFirewalls')))" + } + }, + "Extract_Policy_Param": { + "runAfter": { + "Filter_RuleCollectionGroups": [ + "Succeeded" + ] + }, + "type": "Compose", + "inputs": "@if(empty(body('Filter_RuleCollectionGroups')), '', first(split(replace(first(body('Filter_RuleCollectionGroups'))?['name'], '[concat(parameters(''', ''), '''')))", + "description": "Extract the policy parameter name from the first RCG name" + }, + "Build_Sequential_DependsOn": { + "runAfter": { + "Extract_Policy_Param": [ + "Succeeded" + ] + }, + "type": "Select", + "inputs": { + "from": "@range(0, length(body('Filter_RuleCollectionGroups')))", + "select": { + "apiVersion": "@body('Filter_RuleCollectionGroups')[item()]?['apiVersion']", + "type": "@body('Filter_RuleCollectionGroups')[item()]?['type']", + "name": "@body('Filter_RuleCollectionGroups')[item()]?['name']", + "location": "@body('Filter_RuleCollectionGroups')[item()]?['location']", + "properties": "@body('Filter_RuleCollectionGroups')[item()]?['properties']", + "dependsOn": "@if(equals(item(), 0), createArray(concat('[resourceId(''Microsoft.Network/firewallPolicies'', parameters(''', outputs('Extract_Policy_Param'), '''))]')), createArray(concat('[resourceId(''Microsoft.Network/firewallPolicies/ruleCollectionGroups'', parameters(''', outputs('Extract_Policy_Param'), '''), ''', replace(last(split(body('Filter_RuleCollectionGroups')[sub(item(),1)]?['name'], '''/')), ''')]', ''), ''')]')))" + } + }, + "description": "Chain each RCG sequentially to avoid race conditions" + }, + "Compose_Fixed_Template": { + "runAfter": { + "Build_Sequential_DependsOn": [ + "Succeeded" + ], + "Filter_Other_Resources": [ + "Succeeded" + ] + }, + "type": "Compose", + "inputs": { + "$schema": "@body('Parse_Template')?['$schema']", + "contentVersion": "@body('Parse_Template')?['contentVersion']", + "parameters": "@body('Parse_Template')?['parameters']", + "variables": "@body('Parse_Template')?['variables']", + "resources": "@union(body('Filter_Other_Resources'), body('Build_Sequential_DependsOn'))", + "outputs": "@body('Parse_Template')?['outputs']" + } + }, + "Store_Backup_To_Blob": { + "runAfter": { + "Compose_Fixed_Template": [ + "Succeeded" + ] + }, + "type": "Http", + "inputs": { + "authentication": { + "type": "ManagedServiceIdentity", + "audience": "https://storage.azure.com/" + }, + "body": "@outputs('Compose_Fixed_Template')", + "headers": { + "Content-Type": "application/json", + "x-ms-blob-type": "BlockBlob", + "x-ms-version": "2021-08-06" + }, + "method": "PUT", + "uri": "https://@{parameters('storageAccountName')}.blob.core.windows.net/firewall-backups/backup-@{parameters('firewallName')}-@{formatDateTime(utcNow(), 'yyyy-MM-dd-HHmmss')}.json" + } + } + }, + "else": { + "actions": { + "Export_Failed": { + "runAfter": {}, + "type": "Terminate", + "inputs": { + "runError": { + "code": "ExportFailed", + "message": "Failed to export Azure Firewall template. HTTP Status: @{outputs('Export_Firewall_Template')['statusCode']}. Response: @{body('Export_Firewall_Template')}" + }, + "runStatus": "Failed" + } + } + } + } + } + } + }, + "resources": [ + { + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2023-01-01", + "name": "[parameters('Storage_Account_Name')]", + "location": "[resourceGroup().location]", + "sku": { + "name": "Standard_LRS", + "tier": "Standard" + }, + "kind": "StorageV2", + "properties": { + "defaultToOAuthAuthentication": true, + "publicNetworkAccess": "Disabled", + "allowBlobPublicAccess": false, + "allowSharedKeyAccess": false, + "minimumTlsVersion": "TLS1_2", + "networkAcls": { + "bypass": "AzureServices", + "virtualNetworkRules": [], + "ipRules": [], + "defaultAction": "Deny" + }, + "supportsHttpsTrafficOnly": true, + "encryption": { + "requireInfrastructureEncryption": false, + "services": { + "blob": { + "keyType": "Account", + "enabled": true + } + }, + "keySource": "Microsoft.Storage" + }, + "accessTier": "Hot" + } + }, + { + "type": "Microsoft.Storage/storageAccounts/blobServices", + "apiVersion": "2023-01-01", + "name": "[concat(parameters('Storage_Account_Name'), '/default')]", + "dependsOn": [ + "[variables('storageAccountId')]" + ], + "properties": { + "deleteRetentionPolicy": { + "allowPermanentDelete": false, + "enabled": true, + "days": 7 + }, + "containerDeleteRetentionPolicy": { + "enabled": true, + "days": 7 + } + } + }, + { + "type": "Microsoft.Storage/storageAccounts/blobServices/containers", + "apiVersion": "2023-01-01", + "name": "[concat(parameters('Storage_Account_Name'), '/default/firewall-backups')]", + "dependsOn": [ + "[resourceId('Microsoft.Storage/storageAccounts/blobServices', parameters('Storage_Account_Name'), 'default')]", + "[variables('storageAccountId')]" + ], + "properties": { + "publicAccess": "None" + } + }, + { + "type": "Microsoft.Storage/storageAccounts/managementPolicies", + "apiVersion": "2023-01-01", + "name": "[concat(parameters('Storage_Account_Name'), '/default')]", + "dependsOn": [ + "[variables('storageAccountId')]" + ], + "properties": { + "policy": { + "rules": [ + { + "name": "DeleteOldBackups", + "enabled": true, + "type": "Lifecycle", + "definition": { + "filters": { + "blobTypes": ["blockBlob"], + "prefixMatch": ["firewall-backups/"] + }, + "actions": { + "baseBlob": { + "delete": { + "daysAfterModificationGreaterThan": "[parameters('Retention_Days')]" + } + } + } + } + } + ] + } + } + }, + { + "comments": "Scheduled backup Logic App — runs on recurrence", + "type": "Microsoft.Logic/workflows", + "apiVersion": "2019-05-01", + "name": "[parameters('Playbook_Name')]", + "location": "[resourceGroup().location]", + "dependsOn": [ + "[resourceId('Microsoft.Storage/storageAccounts/blobServices/containers', parameters('Storage_Account_Name'), 'default', 'firewall-backups')]" + ], + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "state": "Enabled", + "definition": { + "$schema": "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#", + "contentVersion": "1.0.0.0", + "parameters": "[variables('backupWorkflowParameters')]", + "triggers": { + "Recurrence": { + "recurrence": { + "frequency": "Day", + "interval": "[parameters('Backup_Frequency_Days')]", + "startTime": "[parameters('Backup_Start_Time')]" + }, + "type": "Recurrence" + } + }, + "actions": "[variables('backupWorkflowActions')]", + "outputs": {} + }, + "parameters": {} + } + }, + { + "comments": "Write-triggered backup Logic App — fires on Activity Log write events. Conditionally deployed.", + "type": "Microsoft.Logic/workflows", + "apiVersion": "2019-05-01", + "name": "[variables('onWriteLogicAppName')]", + "location": "[resourceGroup().location]", + "condition": "[parameters('Enable_Write_Trigger')]", + "dependsOn": [ + "[resourceId('Microsoft.Storage/storageAccounts/blobServices/containers', parameters('Storage_Account_Name'), 'default', 'firewall-backups')]" + ], + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "state": "Enabled", + "definition": { + "$schema": "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "subscriptionId": { + "defaultValue": "[parameters('Subscription_Id')]", + "type": "String" + }, + "firewallResourceGroup": { + "defaultValue": "[parameters('Firewall_Resource_Group_Name')]", + "type": "String" + }, + "policyResourceGroup": { + "defaultValue": "[variables('policyRG')]", + "type": "String" + }, + "firewallName": { + "defaultValue": "[parameters('Firewall_Name')]", + "type": "String" + }, + "firewallPolicyName": { + "defaultValue": "[parameters('Firewall_Policy_Name')]", + "type": "String" + }, + "storageAccountName": { + "defaultValue": "[parameters('Storage_Account_Name')]", + "type": "String" + }, + "cooldownMinutes": { + "defaultValue": "[parameters('Write_Trigger_Cooldown_Minutes')]", + "type": "Int" + } + }, + "triggers": { + "manual": { + "type": "Request", + "kind": "Http", + "runtimeConfiguration": { + "concurrency": { + "runs": 1 + } + }, + "inputs": { + "schema": { + "type": "object", + "properties": { + "schemaId": { "type": "string" }, + "data": { + "type": "object", + "properties": { + "essentials": { + "type": "object", + "properties": { + "alertId": { "type": "string" }, + "alertRule": { "type": "string" }, + "severity": { "type": "string" }, + "signalType": { "type": "string" }, + "monitorCondition": { "type": "string" }, + "firedDateTime": { "type": "string" }, + "description": { "type": "string" } + } + }, + "alertContext": { "type": "object" } + } + } + } + } + } + } + }, + "actions": { + "Check_Cooldown": { + "runAfter": {}, + "type": "Http", + "inputs": { + "authentication": { + "type": "ManagedServiceIdentity", + "audience": "https://storage.azure.com/" + }, + "method": "GET", + "uri": "https://@{parameters('storageAccountName')}.blob.core.windows.net/firewall-backups?restype=container&comp=list&prefix=backup-@{parameters('firewallName')}-", + "headers": { + "x-ms-version": "2021-08-06" + } + } + }, + "Parse_Last_Backup_Time": { + "runAfter": { + "Check_Cooldown": [ + "Succeeded" + ] + }, + "type": "Compose", + "inputs": "@xpath(xml(body('Check_Cooldown')), '//Last-Modified/text()')", + "description": "Extract last backup timestamp to enforce cooldown" + }, + "Evaluate_Cooldown": { + "runAfter": { + "Parse_Last_Backup_Time": [ + "Succeeded" + ] + }, + "type": "If", + "expression": { + "or": [ + { + "equals": [ + "@length(outputs('Parse_Last_Backup_Time'))", + 0 + ] + }, + { + "greater": [ + "@div(sub(ticks(utcNow()), ticks(parseDateTime(last(outputs('Parse_Last_Backup_Time'))))), 600000000)", + "@parameters('cooldownMinutes')" + ] + } + ] + }, + "actions": { + "Export_Firewall_Template": { + "runAfter": {}, + "type": "Http", + "inputs": { + "authentication": { + "type": "ManagedServiceIdentity" + }, + "body": { + "options": "IncludeParameterDefaultValue", + "resources": [ + "/subscriptions/@{parameters('subscriptionId')}/resourceGroups/@{parameters('policyResourceGroup')}/providers/Microsoft.Network/firewallPolicies/@{parameters('firewallPolicyName')}" + ] + }, + "method": "POST", + "uri": "https://management.azure.com/subscriptions/@{parameters('subscriptionId')}/resourcegroups/@{parameters('policyResourceGroup')}/exportTemplate?api-version=2021-04-01" + } + }, + "Check_Export_Success": { + "runAfter": { + "Export_Firewall_Template": [ + "Succeeded" + ] + }, + "type": "If", + "expression": { + "and": [ + { + "equals": [ + "@outputs('Export_Firewall_Template')['statusCode']", + 200 + ] + } + ] + }, + "actions": { + "Parse_Template": { + "runAfter": {}, + "type": "ParseJson", + "inputs": { + "content": "@body('Export_Firewall_Template')?['template']", + "schema": { + "type": "object" + } + } + }, + "Filter_RuleCollectionGroups": { + "runAfter": { + "Parse_Template": [ + "Succeeded" + ] + }, + "type": "Query", + "inputs": { + "from": "@body('Parse_Template')?['resources']", + "where": "@equals(item()?['type'], 'Microsoft.Network/firewallPolicies/ruleCollectionGroups')" + } + }, + "Filter_Other_Resources": { + "runAfter": { + "Parse_Template": [ + "Succeeded" + ] + }, + "type": "Query", + "inputs": { + "from": "@body('Parse_Template')?['resources']", + "where": "@and(not(equals(item()?['type'], 'Microsoft.Network/firewallPolicies/ruleCollectionGroups')), not(equals(item()?['type'], 'Microsoft.Network/azureFirewalls')))" + } + }, + "Extract_Policy_Param": { + "runAfter": { + "Filter_RuleCollectionGroups": [ + "Succeeded" + ] + }, + "type": "Compose", + "inputs": "@if(empty(body('Filter_RuleCollectionGroups')), '', first(split(replace(first(body('Filter_RuleCollectionGroups'))?['name'], '[concat(parameters(''', ''), '''')))", + "description": "Extract the policy parameter name from the first RCG name" + }, + "Build_Sequential_DependsOn": { + "runAfter": { + "Extract_Policy_Param": [ + "Succeeded" + ] + }, + "type": "Select", + "inputs": { + "from": "@range(0, length(body('Filter_RuleCollectionGroups')))", + "select": { + "apiVersion": "@body('Filter_RuleCollectionGroups')[item()]?['apiVersion']", + "type": "@body('Filter_RuleCollectionGroups')[item()]?['type']", + "name": "@body('Filter_RuleCollectionGroups')[item()]?['name']", + "location": "@body('Filter_RuleCollectionGroups')[item()]?['location']", + "properties": "@body('Filter_RuleCollectionGroups')[item()]?['properties']", + "dependsOn": "@if(equals(item(), 0), createArray(concat('[resourceId(''Microsoft.Network/firewallPolicies'', parameters(''', outputs('Extract_Policy_Param'), '''))]')), createArray(concat('[resourceId(''Microsoft.Network/firewallPolicies/ruleCollectionGroups'', parameters(''', outputs('Extract_Policy_Param'), '''), ''', replace(last(split(body('Filter_RuleCollectionGroups')[sub(item(),1)]?['name'], '''/')), ''')]', ''), ''')]')))" + } + }, + "description": "Chain each RCG sequentially to avoid race conditions" + }, + "Compose_Fixed_Template": { + "runAfter": { + "Build_Sequential_DependsOn": [ + "Succeeded" + ], + "Filter_Other_Resources": [ + "Succeeded" + ] + }, + "type": "Compose", + "inputs": { + "$schema": "@body('Parse_Template')?['$schema']", + "contentVersion": "@body('Parse_Template')?['contentVersion']", + "parameters": "@body('Parse_Template')?['parameters']", + "variables": "@body('Parse_Template')?['variables']", + "resources": "@union(body('Filter_Other_Resources'), body('Build_Sequential_DependsOn'))", + "outputs": "@body('Parse_Template')?['outputs']" + } + }, + "Store_Backup_To_Blob": { + "runAfter": { + "Compose_Fixed_Template": [ + "Succeeded" + ] + }, + "type": "Http", + "inputs": { + "authentication": { + "type": "ManagedServiceIdentity", + "audience": "https://storage.azure.com/" + }, + "body": "@outputs('Compose_Fixed_Template')", + "headers": { + "Content-Type": "application/json", + "x-ms-blob-type": "BlockBlob", + "x-ms-version": "2021-08-06" + }, + "method": "PUT", + "uri": "https://@{parameters('storageAccountName')}.blob.core.windows.net/firewall-backups/backup-@{parameters('firewallName')}-@{formatDateTime(coalesce(triggerBody()?['data']?['alertContext']?['eventTimestamp'], utcNow()), 'yyyy-MM-dd-HHmmss')}.json" + } + } + }, + "else": { + "actions": { + "Export_Failed": { + "runAfter": {}, + "type": "Terminate", + "inputs": { + "runError": { + "code": "ExportFailed", + "message": "Failed to export Azure Firewall template. HTTP Status: @{outputs('Export_Firewall_Template')['statusCode']}. Response: @{body('Export_Firewall_Template')}" + }, + "runStatus": "Failed" + } + } + } + } + } + }, + "else": { + "actions": { + "Cooldown_Skip": { + "runAfter": {}, + "type": "Terminate", + "inputs": { + "runStatus": "Cancelled" + }, + "description": "Skipped — another backup ran within the cooldown window" + } + } + } + } + }, + "outputs": {} + }, + "parameters": {} + } + }, + { + "comments": "Action Group that triggers the OnWrite Logic App when an Activity Log Alert fires", + "type": "Microsoft.Insights/actionGroups", + "apiVersion": "2023-01-01", + "name": "[variables('actionGroupName')]", + "location": "Global", + "condition": "[parameters('Enable_Write_Trigger')]", + "dependsOn": [ + "[resourceId('Microsoft.Logic/workflows', variables('onWriteLogicAppName'))]" + ], + "properties": { + "groupShortName": "FWBackupAG", + "enabled": true, + "logicAppReceivers": [ + { + "name": "TriggerFWBackup", + "resourceId": "[resourceId('Microsoft.Logic/workflows', variables('onWriteLogicAppName'))]", + "callbackUrl": "[if(parameters('Enable_Write_Trigger'), listCallbackUrl(resourceId('Microsoft.Logic/workflows/triggers', variables('onWriteLogicAppName'), 'manual'), '2019-05-01').value, 'https://placeholder')]", + "useCommonAlertSchema": true + } + ] + } + }, + { + "comments": "Activity Log Alert — fires on write operations to the Firewall or Firewall Policy", + "type": "Microsoft.Insights/activityLogAlerts", + "apiVersion": "2020-10-01", + "name": "[variables('alertName')]", + "location": "Global", + "condition": "[parameters('Enable_Write_Trigger')]", + "dependsOn": [ + "[resourceId('Microsoft.Insights/actionGroups', variables('actionGroupName'))]" + ], + "properties": { + "scopes": [ + "[variables('policyRGScope')]" + ], + "enabled": true, + "description": "Triggers a backup on any change to the Azure Firewall Policy or its child resources (policy writes, rule collection group writes/deletes, draft writes, draft deploys, IDPS signature overrides, etc.) - matched by the Microsoft.Network/firewallPolicies operation namespace", + "condition": { + "allOf": [ + { + "field": "category", + "equals": "Administrative" + }, + { + "anyOf": [ + { "field": "operationName", "equals": "Microsoft.Network/firewallPolicies/write" }, + { "field": "operationName", "equals": "Microsoft.Network/firewallPolicies/delete" }, + { "field": "operationName", "equals": "Microsoft.Network/firewallPolicies/deploy/action" }, + { "field": "operationName", "equals": "Microsoft.Network/firewallPolicies/ruleCollectionGroups/write" }, + { "field": "operationName", "equals": "Microsoft.Network/firewallPolicies/ruleCollectionGroups/delete" }, + { "field": "operationName", "equals": "Microsoft.Network/firewallPolicies/ruleGroups/write" }, + { "field": "operationName", "equals": "Microsoft.Network/firewallPolicies/ruleGroups/delete" }, + { "field": "operationName", "equals": "Microsoft.Network/firewallPolicies/firewallPolicyDrafts/write" }, + { "field": "operationName", "equals": "Microsoft.Network/firewallPolicies/firewallPolicyDrafts/delete" }, + { "field": "operationName", "equals": "Microsoft.Network/firewallPolicies/ruleCollectionGroups/ruleCollectionGroupDrafts/write" }, + { "field": "operationName", "equals": "Microsoft.Network/firewallPolicies/ruleCollectionGroups/ruleCollectionGroupDrafts/delete" } + ] + }, + { + "field": "status", + "equals": "Succeeded" + } + ] + }, + "actions": { + "actionGroups": [ + { + "actionGroupId": "[resourceId('Microsoft.Insights/actionGroups', variables('actionGroupName'))]" + } + ] + } + } + } + ], + "outputs": { + "logicAppPrincipalId": { + "type": "String", + "value": "[reference(resourceId('Microsoft.Logic/workflows', parameters('Playbook_Name')), '2019-05-01', 'full').identity.principalId]" + }, + "onWriteLogicAppPrincipalId": { + "condition": "[parameters('Enable_Write_Trigger')]", + "type": "String", + "value": "[if(parameters('Enable_Write_Trigger'), reference(resourceId('Microsoft.Logic/workflows', variables('onWriteLogicAppName')), '2019-05-01', 'full').identity.principalId, 'N/A')]" + }, + "postDeploymentSteps": { + "type": "String", + "value": "[concat('Assign the following roles to the Logic App Managed Identity (Object ID shown above):\n1. Contributor on: /subscriptions/', parameters('Subscription_Id'), '/resourceGroups/', parameters('Firewall_Resource_Group_Name'), '\n2. Storage Blob Data Contributor on: ', variables('storageAccountId'), '\n\nAzure CLI:\naz role assignment create --assignee --role \"Contributor\" --scope /subscriptions/', parameters('Subscription_Id'), '/resourceGroups/', parameters('Firewall_Resource_Group_Name'), '\naz role assignment create --assignee --role \"Storage Blob Data Contributor\" --scope ', variables('storageAccountId'), if(parameters('Enable_Write_Trigger'), concat('\n\n--- WRITE TRIGGER (OnWrite Logic App) ---\nRepeat the same role assignments for the OnWrite Logic App principal ID.\naz role assignment create --assignee --role \"Contributor\" --scope /subscriptions/', parameters('Subscription_Id'), '/resourceGroups/', parameters('Firewall_Resource_Group_Name'), '\naz role assignment create --assignee --role \"Storage Blob Data Contributor\" --scope ', variables('storageAccountId')), ''))]" + } + } +} From 53cd16e5632f0845069c826380752bdc0c10e11c Mon Sep 17 00:00:00 2001 From: SaiPrasannaKishor Date: Thu, 9 Jul 2026 01:38:04 -0700 Subject: [PATCH 3/5] Create Deploy-FWBackup.ps1 PS SCript --- .../Deploy-FWBackup.ps1 | 318 ++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 Azure Firewall/New Template - Logic App for Azure Firewall Backups with PS Script/Deploy-FWBackup.ps1 diff --git a/Azure Firewall/New Template - Logic App for Azure Firewall Backups with PS Script/Deploy-FWBackup.ps1 b/Azure Firewall/New Template - Logic App for Azure Firewall Backups with PS Script/Deploy-FWBackup.ps1 new file mode 100644 index 00000000..59db3b2f --- /dev/null +++ b/Azure Firewall/New Template - Logic App for Azure Firewall Backups with PS Script/Deploy-FWBackup.ps1 @@ -0,0 +1,318 @@ +<# +.SYNOPSIS + End-to-end deployer for the Azure Firewall Backup solution. + +.DESCRIPTION + One script that: + 1. Deploys the AzFW backup ARM template (recurrence + optional write-trigger). + 2. Assigns the required RBAC roles to BOTH Logic App managed identities. + 3. Grants the signed-in USER data-plane read access so they can list backups. + 4. Detects storage-account network rules and offers to add the caller's public IP + (with a clear alert that the storage must be reachable from your IP/network to + see the backups in the blob container). + 5. Prompts to run a baseline backup immediately. + 6. Optionally RESTORES the latest backup as a NEW named firewall policy and + repoints an existing firewall to it (guarded, with explicit confirmations). + +.NOTES + Requires: Azure CLI (az) logged in with rights to deploy + assign roles. + Idempotent: safe to re-run. Role assignments that already exist are skipped. +#> + +[CmdletBinding()] +param( + [string]$SubscriptionId = "", + [string]$ResourceGroup = "", + [string]$FirewallResourceGroup = "", + [string]$PolicyResourceGroup = "", + [string]$StorageAccountName = "", + [string]$FirewallName = "", + [string]$FirewallPolicyName = "", + [string]$PlaybookName = "", + [bool] $EnableWriteTrigger = $true, + [string]$TemplateFile = "", + [string]$BlobContainer = "" +) + +$ErrorActionPreference = "Stop" + +# Resolve script directory robustly (works via -File, dot-source, or interactive) +$scriptDir = if ($PSScriptRoot) { $PSScriptRoot } + elseif ($MyInvocation.MyCommand.Path) { Split-Path -Parent $MyInvocation.MyCommand.Path } + else { (Get-Location).Path } +if ([string]::IsNullOrWhiteSpace($TemplateFile)) { + $TemplateFile = Join-Path $scriptDir "backup-azfw-template-v3.json" +} + +# Normalize inputs: strip stray leading/trailing whitespace so values pasted with +# accidental spaces don't corrupt az lookups or ARM scope/resourceId strings. +foreach ($p in 'SubscriptionId','ResourceGroup','FirewallResourceGroup','PolicyResourceGroup', + 'StorageAccountName','FirewallName','FirewallPolicyName','PlaybookName', + 'TemplateFile','BlobContainer') { + $v = Get-Variable -Name $p -ValueOnly -ErrorAction SilentlyContinue + if ($v -is [string]) { Set-Variable -Name $p -Value ($v.Trim()) } +} + +# Resolve resource groups. The firewall and its policy can live in different RGs +# (and in a different RG than where this backup solution is deployed). +# $ResourceGroup -> deployment RG (Logic Apps, storage, alert, action group) +# $FirewallResourceGroup -> RG hosting the Azure Firewall (defaults to deployment RG) +# $PolicyResourceGroup -> RG hosting the Firewall Policy (defaults to firewall RG) +if ([string]::IsNullOrWhiteSpace($FirewallResourceGroup)) { $FirewallResourceGroup = $ResourceGroup } +if ([string]::IsNullOrWhiteSpace($PolicyResourceGroup)) { $PolicyResourceGroup = $FirewallResourceGroup } + +# ----------------------------- helpers ----------------------------- +function Write-Step ($m){ Write-Host "`n=== $m ===" -ForegroundColor Cyan } +function Write-Ok ($m){ Write-Host " [OK] $m" -ForegroundColor Green } +function Write-Warn2 ($m){ Write-Host " [!!] $m" -ForegroundColor Yellow } +function Write-Info ($m){ Write-Host " [--] $m" -ForegroundColor Gray } +function Confirm-Yes ($q){ ((Read-Host "$q [y/N]") -match '^(y|yes)$') } + +function Ensure-RoleAssignment { + param([string]$Assignee,[string]$Role,[string]$Scope,[string]$Label) + $existing = az role assignment list --assignee $Assignee --scope $Scope ` + --query "[?roleDefinitionName=='$Role'] | [0].id" -o tsv 2>$null + if ($existing) { Write-Ok "$Label already has '$Role'"; return } + az role assignment create --assignee $Assignee --role $Role --scope $Scope 1>$null 2>$null + if ($LASTEXITCODE -eq 0) { Write-Ok "Granted '$Role' to $Label" } + else { Write-Warn2 "Failed to grant '$Role' to $Label (may need Owner/UAA rights)" } +} + +# ----------------------------- 0. context ----------------------------- +Write-Step "Context" +az account set --subscription $SubscriptionId +$acct = az account show --query "{name:name, user:user.name}" -o json | ConvertFrom-Json +Write-Info "Subscription : $SubscriptionId" +Write-Info "Deployment RG: $ResourceGroup" +Write-Info "Firewall RG : $FirewallResourceGroup" +Write-Info "Policy RG : $PolicyResourceGroup" +Write-Info "Signed-in as : $($acct.user)" +if (-not (Test-Path $TemplateFile)) { throw "Template not found: $TemplateFile" } +Write-Info "Template : $TemplateFile" + +# ----------------------------- 1. deploy ----------------------------- +Write-Step "1/6 Deploying ARM template (incremental)" +$deployName = "fwbackup-$((Get-Date).ToString('yyyyMMdd-HHmmss'))" +$out = az deployment group create ` + --name $deployName ` + --resource-group $ResourceGroup ` + --template-file $TemplateFile ` + --parameters ` + Playbook_Name=$PlaybookName ` + Storage_Account_Name=$StorageAccountName ` + Firewall_Name=$FirewallName ` + Firewall_Policy_Name=$FirewallPolicyName ` + Firewall_Resource_Group_Name=$FirewallResourceGroup ` + Policy_Resource_Group_Name=$PolicyResourceGroup ` + Subscription_Id=$SubscriptionId ` + Enable_Write_Trigger=$($EnableWriteTrigger.ToString().ToLower()) ` + -o json | ConvertFrom-Json + +if ($out.properties.provisioningState -ne "Succeeded") { throw "Deployment failed: $($out.properties.provisioningState)" } +Write-Ok "Deployment '$deployName' succeeded" + +$recurrencePrincipal = $out.properties.outputs.logicAppPrincipalId.value +$onWritePrincipal = $out.properties.outputs.onWriteLogicAppPrincipalId.value +Write-Info "Recurrence Logic App identity : $recurrencePrincipal" +if ($EnableWriteTrigger) { Write-Info "OnWrite Logic App identity : $onWritePrincipal" } + +$rgScope = "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup" +$storageScope = "$rgScope/providers/Microsoft.Storage/storageAccounts/$StorageAccountName" + +# Contributor is needed on the RG(s) that host the firewall AND the policy so the +# Logic App identities can read them for backup and (re)create/repoint them on restore. +# These may differ from the deployment RG, so grant on both (deduplicated). +$contributorRgScopes = @( + "/subscriptions/$SubscriptionId/resourceGroups/$FirewallResourceGroup", + "/subscriptions/$SubscriptionId/resourceGroups/$PolicyResourceGroup" +) | Select-Object -Unique + +# ----------------------------- 2. Logic App roles ----------------------------- +Write-Step "2/6 Assigning roles to Logic App managed identities" +foreach ($scope in $contributorRgScopes) { + Ensure-RoleAssignment $recurrencePrincipal "Contributor" $scope "Recurrence app" +} +Ensure-RoleAssignment $recurrencePrincipal "Storage Blob Data Contributor" $storageScope "Recurrence app" +if ($EnableWriteTrigger -and $onWritePrincipal -and $onWritePrincipal -ne "N/A") { + foreach ($scope in $contributorRgScopes) { + Ensure-RoleAssignment $onWritePrincipal "Contributor" $scope "OnWrite app" + } + Ensure-RoleAssignment $onWritePrincipal "Storage Blob Data Contributor" $storageScope "OnWrite app" +} + +# ----------------------------- 3. user read access ----------------------------- +Write-Step "3/6 Granting the signed-in user data-plane read on the storage account" +$userObjectId = az ad signed-in-user show --query id -o tsv 2>$null +if ($userObjectId) { + Ensure-RoleAssignment $userObjectId "Storage Blob Data Reader" $storageScope "You ($($acct.user))" + Write-Info "This lets you LIST/READ backup blobs (data-plane) via portal/CLI with your AAD identity." +} else { + Write-Warn2 "Could not resolve signed-in user object id; skipping user role grant." +} + +# ----------------------------- 4. network reachability ----------------------------- +Write-Step "4/6 Storage network reachability" +$net = az storage account show -n $StorageAccountName -g $ResourceGroup --query networkRuleSet -o json | ConvertFrom-Json +$myIp = try { (Invoke-RestMethod -Uri "https://api.ipify.org?format=json" -TimeoutSec 10).ip } catch { $null } +Write-Info "Storage defaultAction : $($net.defaultAction)" +Write-Info "Your public IP : $(if($myIp){$myIp}else{'unknown'})" + +if ($net.defaultAction -eq "Deny") { + Write-Host "" + Write-Warn2 "ALERT: This storage account BLOCKS public access by default (defaultAction=Deny)." + Write-Warn2 " The Logic Apps write backups over the trusted Managed Identity path and WILL work," + Write-Warn2 " BUT you will NOT be able to see/list the backup blobs from your machine or the" + Write-Warn2 " Azure portal until your IP / network is allowed on the storage firewall." + $allowed = @($net.ipRules | ForEach-Object { $_.ipAddressOrRange }) + if ($myIp -and ($allowed -contains $myIp)) { + Write-Ok "Your IP $myIp is already allowed." + } else { + Write-Warn2 "ACTION REQUIRED: This script does NOT modify the storage firewall." + Write-Warn2 " To view/list backups, add your IP to the storage account's network rules yourself:" + Write-Info " az storage account network-rule add --account-name $StorageAccountName -g $ResourceGroup --ip-address $(if($myIp){$myIp}else{''})" + Write-Info " (or add it in the portal: Storage account > Networking > Firewalls and virtual networks)" + } +} else { + Write-Ok "Storage allows access (defaultAction=Allow) - you can list backups directly." +} + +# ----------------------------- 5. run a baseline backup ----------------------------- +Write-Step "5/6 Baseline backup" +if (Confirm-Yes " Run a backup NOW (manually trigger the recurrence Logic App)?") { + $trgUri = "https://management.azure.com$rgScope/providers/Microsoft.Logic/workflows/$PlaybookName/triggers/Recurrence/run?api-version=2019-05-01" + az rest --method post --uri $trgUri 1>$null + Write-Ok "Recurrence workflow triggered. Checking run status..." + Start-Sleep -Seconds 45 + az rest --method get ` + --uri "https://management.azure.com$rgScope/providers/Microsoft.Logic/workflows/$PlaybookName/runs?api-version=2019-05-01" ` + --query "value[0].{status:properties.status, start:properties.startTime}" -o table + Write-Info "Backups land in container '$BlobContainer' as backup-$FirewallName-.json" +} else { + Write-Info "Skipped. It will run automatically on its recurrence schedule." +} + +# ----------------------------- 6. restore as new policy + repoint ----------------------------- +Write-Step "6/6 Restore latest backup as a NEW policy and repoint an existing firewall" +Write-Warn2 "This is a WRITE operation against a live firewall. Proceed carefully." +if (Confirm-Yes " Do you want to restore a backup as a new policy and repoint a firewall?") { + + # 6a. source selection: a LOCAL file (already downloaded) OR list/download from storage + $usedLocalFile = $false + $localPath = Read-Host " Path to a LOCAL backup .json (leave blank to list from storage)" + if (-not [string]::IsNullOrWhiteSpace($localPath)) { + $localPath = $localPath.Trim().Trim('"').Trim("'").Trim() + if (-not (Test-Path $localPath)) { Write-Warn2 "File '$localPath' not found. Aborting restore."; return } + # copy to a temp working file so we don't mutate/delete the user's original + $localBackup = Join-Path $env:TEMP "restore-$([guid]::NewGuid().ToString('N').Substring(0,8)).json" + Copy-Item $localPath $localBackup -Force + $usedLocalFile = $true + Write-Ok "Using local backup file: $localPath" + } else { + + if ($net.defaultAction -eq "Deny" -and -not ($myIp -and (az storage account network-rule list --account-name $StorageAccountName -g $ResourceGroup --query "ipRules[?ipAddressOrRange=='$myIp']" -o tsv))) { + Write-Warn2 "Storage is IP-restricted and your IP isn't allowed - blob download will likely fail." + if (-not (Confirm-Yes " Continue anyway?")) { Write-Info "Restore aborted."; return } + } + + # find the newest backup blob + Write-Info "Listing backups in '$BlobContainer'..." + $blobsJson = az storage blob list --account-name $StorageAccountName --container-name $BlobContainer ` + --prefix "backup-$FirewallName-" --auth-mode login ` + --query "sort_by([].{name:name, modified:properties.lastModified}, &name)" -o json 2>$null + $blobs = if ($blobsJson) { $blobsJson | ConvertFrom-Json } else { @() } + if (-not $blobs -or $blobs.Count -eq 0) { Write-Warn2 "No backups found (or no storage access). Aborting restore."; return } + + $latest = $blobs[-1].name + Write-Info "Found $($blobs.Count) backup(s). Latest: $latest" + $chosen = (Read-Host " Blob to restore [default: $latest]").Trim() + if ([string]::IsNullOrWhiteSpace($chosen)) { $chosen = $latest } + + # download it + $localBackup = Join-Path $env:TEMP "restore-$([guid]::NewGuid().ToString('N').Substring(0,8)).json" + az storage blob download --account-name $StorageAccountName --container-name $BlobContainer ` + --name $chosen --file $localBackup --auth-mode login 1>$null + if (-not (Test-Path $localBackup)) { Write-Warn2 "Download failed. Aborting."; return } + Write-Ok "Downloaded $chosen" + } + + # 6c. locate the firewallPolicies name parameter inside the backup template + $tpl = Get-Content $localBackup -Raw | ConvertFrom-Json + $polRes = $tpl.resources | Where-Object { $_.type -eq 'Microsoft.Network/firewallPolicies' } | Select-Object -First 1 + if (-not $polRes) { Write-Warn2 "Backup has no firewallPolicies resource. Aborting."; return } + $polParam = $null + if ($polRes.name -match "parameters\('([^']+)'\)") { $polParam = $Matches[1] } + + $defaultNew = "$FirewallPolicyName-restored-$((Get-Date).ToString('yyyyMMdd-HHmmss'))" + $newPolicyName = (Read-Host " New policy name [default: $defaultNew]").Trim() + if ([string]::IsNullOrWhiteSpace($newPolicyName)) { $newPolicyName = $defaultNew } + + # 6d. deploy the backup as a NEW policy (override the policy-name parameter) + Write-Info "Deploying backup as new policy '$newPolicyName'..." + $restoreDeploy = "fwrestore-$((Get-Date).ToString('yyyyMMdd-HHmmss'))" + if ($polParam) { + az deployment group create --name $restoreDeploy --resource-group $PolicyResourceGroup ` + --template-file $localBackup --parameters "$polParam=$newPolicyName" -o none + } else { + Write-Warn2 "Could not auto-detect the policy-name parameter; deploying template as-is." + Write-Warn2 "This may recreate the ORIGINAL policy name instead of a new one." + if (-not (Confirm-Yes " Continue?")) { Write-Info "Restore aborted."; return } + az deployment group create --name $restoreDeploy --resource-group $PolicyResourceGroup ` + --template-file $localBackup -o none + } + if ($LASTEXITCODE -ne 0) { Write-Warn2 "Restore deployment failed. Aborting repoint."; return } + Write-Ok "New policy '$newPolicyName' deployed." + + # 6e. choose the target firewall to associate, then repoint it + Write-Info "Now choose which EXISTING firewall to associate with policy '$newPolicyName'." + $fwRg = (Read-Host " Target firewall's resource group [default: $FirewallResourceGroup]").Trim() + if ([string]::IsNullOrWhiteSpace($fwRg)) { $fwRg = $FirewallResourceGroup } + + # Discover firewalls in that RG to help the user pick + $fwListJson = az network firewall list -g $fwRg --query "[].name" -o json 2>$null + $fwList = if ($fwListJson) { $fwListJson | ConvertFrom-Json } else { @() } + if ($fwList.Count -gt 0) { + Write-Info "Firewalls found in '$fwRg': $($fwList -join ', ')" + } else { + Write-Warn2 "No firewalls discovered in '$fwRg' (list may be restricted). You can still type a name." + } + + $targetFw = (Read-Host " Firewall name to associate [default: $FirewallName]").Trim() + if ([string]::IsNullOrWhiteSpace($targetFw)) { $targetFw = $FirewallName } + + # Validate the firewall exists before attempting a repoint + $fwRgScope = "/subscriptions/$SubscriptionId/resourceGroups/$fwRg" + $fwId = "$fwRgScope/providers/Microsoft.Network/azureFirewalls/$targetFw" + $exists = az resource show --ids $fwId --api-version 2023-11-01 --query "name" -o tsv 2>$null + if (-not $exists) { + Write-Warn2 "Firewall '$targetFw' not found in RG '$fwRg'. Repoint aborted." + Write-Info "New policy '$newPolicyName' was created but NOT associated." + Remove-Item $localBackup -ErrorAction SilentlyContinue + return + } + + # Show current association so the user knows what they're changing + $currentPol = az resource show --ids $fwId --api-version 2023-11-01 --query "properties.firewallPolicy.id" -o tsv 2>$null + $newPolicyId = "/subscriptions/$SubscriptionId/resourceGroups/$PolicyResourceGroup/providers/Microsoft.Network/firewallPolicies/$newPolicyName" + Write-Warn2 "About to REPOINT firewall '$targetFw' (RG '$fwRg')." + Write-Info " Current policy : $(if($currentPol){$currentPol.Split('/')[-1]}else{''})" + Write-Info " New policy : $newPolicyName" + Write-Warn2 "This changes the live firewall's active policy." + if (Confirm-Yes " Repoint '$targetFw' to '$newPolicyName' now?") { + az resource update --ids $fwId --set "properties.firewallPolicy.id=$newPolicyId" ` + --api-version 2023-11-01 -o none + if ($LASTEXITCODE -eq 0) { + Write-Ok "Firewall '$targetFw' now uses policy '$newPolicyName'." + } else { + Write-Warn2 "Repoint failed. Firewall still on its previous policy. New policy '$newPolicyName' exists and is unused." + } + } else { + Write-Info "Repoint skipped. New policy '$newPolicyName' was created but NOT associated." + } + + Remove-Item $localBackup -ErrorAction SilentlyContinue +} else { + Write-Info "Restore skipped." +} + +Write-Step "Done" +Write-Ok "Firewall backup solution deployed and configured." From db6732eeda2fffdb23317ee2ca4aabf4326e3552 Mon Sep 17 00:00:00 2001 From: SaiPrasannaKishor Date: Thu, 9 Jul 2026 01:38:38 -0700 Subject: [PATCH 4/5] Rename azuredeploy.json to backup-azfw-template-v3.json Updated name for Json template --- .../{azuredeploy.json => backup-azfw-template-v3.json} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Azure Firewall/New Template - Logic App for Azure Firewall Backups with PS Script/{azuredeploy.json => backup-azfw-template-v3.json} (100%) diff --git a/Azure Firewall/New Template - Logic App for Azure Firewall Backups with PS Script/azuredeploy.json b/Azure Firewall/New Template - Logic App for Azure Firewall Backups with PS Script/backup-azfw-template-v3.json similarity index 100% rename from Azure Firewall/New Template - Logic App for Azure Firewall Backups with PS Script/azuredeploy.json rename to Azure Firewall/New Template - Logic App for Azure Firewall Backups with PS Script/backup-azfw-template-v3.json From b08903f17ca9bf8617dd12b18ff04447eefea8b2 Mon Sep 17 00:00:00 2001 From: SaiPrasannaKishor Date: Thu, 9 Jul 2026 01:47:46 -0700 Subject: [PATCH 5/5] Create README.md Readme --- .../README.md | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 Azure Firewall/New Template - Logic App for Azure Firewall Backups with PS Script/README.md diff --git a/Azure Firewall/New Template - Logic App for Azure Firewall Backups with PS Script/README.md b/Azure Firewall/New Template - Logic App for Azure Firewall Backups with PS Script/README.md new file mode 100644 index 00000000..17c5e746 --- /dev/null +++ b/Azure Firewall/New Template - Logic App for Azure Firewall Backups with PS Script/README.md @@ -0,0 +1,251 @@ +# Azure Firewall Backup Solution + +Automated, versioned backups of an **Azure Firewall Policy** (and all of its rule +collection groups) to a secure Storage account, with optional near-real-time +backups on every policy change, plus a guided restore + repoint workflow. + +This folder contains two files: + +| File | Purpose | +|------|---------| +| `backup-azfw-template-v3.json` | ARM template. Deploys all Azure resources (storage, Logic Apps, alert, action group). | +| `Deploy-FWBackup.ps1` | End-to-end deployer. Deploys the template, assigns RBAC, checks storage reachability, runs a baseline backup, and can restore. | + +--- + +## What it does + +- Exports the firewall **policy + rule collection groups** as an ARM template and + stores it as a timestamped JSON blob: `backup--.json`. +- Two backup triggers: + - **Scheduled** (``): runs on a recurrence (default every 3 days at 02:00 UTC). + - **Write-triggered** (`-OnWrite`, optional): fires on **any** change to + the firewall policy or its children via an Activity Log Alert. +- **Cooldown** on the write-trigger (default 5 min) prevents backup storms during bulk edits. +- **Retention**: a storage lifecycle rule auto-deletes backups older than `Retention_Days` (default 30). +- Uses **Managed Identity** end-to-end. No keys, no secrets. Storage is locked down + (`defaultAction=Deny`, no public blob access, shared-key disabled). + +--- + +## Architecture + +``` + Activity Log Alert (optional, write trigger) + scope = Policy Resource Group + | + v + Action Group (Logic App receiver) + | + Recurrence timer v + | -OnWrite (Logic App, SystemAssigned MI) + | | + v | + --------->-+--> exportTemplate on the POLICY's resource group + (Logic App, MI) (captures policy + all ruleCollectionGroups) + | + v + Rebuild RCGs with sequential dependsOn + | + v + PUT backup--.json --> Storage (firewall-backups) +``` + +### Resources deployed by the template + +- `Microsoft.Storage/storageAccounts` (`Standard_LRS`, StorageV2, public access disabled) + - blobService with 7-day soft-delete + - container `firewall-backups` + - lifecycle management policy `DeleteOldBackups` (retention) +- `Microsoft.Logic/workflows` `` (scheduled backup) +- `Microsoft.Logic/workflows` `-OnWrite` (write-triggered backup) *(only if `Enable_Write_Trigger=true`)* +- `Microsoft.Insights/actionGroups` `-AG` *(write trigger only)* +- `Microsoft.Insights/activityLogAlerts` `-WriteAlert` *(write trigger only)* + +Both Logic Apps share the same backup logic (template variable `backupWorkflowActions`); +the OnWrite app adds the cooldown check. + +--- + +## Cross-resource-group support (important) + +The firewall, its policy, and the backup solution itself can all live in **different +resource groups**. This is common in hub-and-spoke designs. + +The backup exports the policy by calling ARM `exportTemplate` **scoped to the policy's +own resource group** (`Policy_Resource_Group_Name`). This is required because an +RG-scoped `exportTemplate` only returns resources that live in that RG. Scoping the +export to the firewall's RG would silently drop the policy and all rule collection +groups when they live elsewhere, producing an empty/failed backup. + +The deploy script grants **Contributor** to both Logic App identities on **both** the +firewall RG and the policy RG (deduplicated) so this works regardless of layout. + +--- + +## Prerequisites + +- Azure CLI (`az`) installed and logged in: `az login`. +- Rights to deploy resources **and** create role assignments (Owner or User Access + Administrator on the relevant scopes). +- Windows PowerShell 5.1 or PowerShell 7+. + +--- + +## Quick start + +From this folder: + +```powershell +# Uses the defaults baked into the script params (edit them or pass overrides) +.\Deploy-FWBackup.ps1 + +# Or specify everything explicitly: +.\Deploy-FWBackup.ps1 ` + -SubscriptionId "" ` + -ResourceGroup "rg-hub-spoke-fw" ` # where the backup solution is deployed + -FirewallResourceGroup "rg-hub-spoke-fw" ` # where the Azure Firewall lives + -PolicyResourceGroup "NetSecDemoShabaz" ` # where the Firewall Policy lives + -StorageAccountName "fwbackupsnew101" ` + -FirewallName "azfw-hub" ` + -FirewallPolicyName "fwpol-premium-alpineSkiHouse" ` + -PlaybookName "BackUp-AzFW" ` + -EnableWriteTrigger $true +``` + +The script is **idempotent** - safe to re-run. Existing role assignments are skipped. + +--- + +## What the deploy script does (6 steps) + +1. **Deploy** the ARM template (incremental) with your parameters. +2. **Assign roles** to both Logic App managed identities: + - `Contributor` on the firewall RG and the policy RG. + - `Storage Blob Data Contributor` on the storage account. +3. **Grant you** `Storage Blob Data Reader` on the storage account so you can list/read + backups with your AAD identity. +4. **Check storage reachability**: if the storage account denies public access, it warns + you and prints the exact `az storage account network-rule add` command to allow your + IP. (The script does **not** modify the storage firewall for you.) +5. **Baseline backup**: optionally triggers the scheduled Logic App now and reports status. +6. **Restore (optional)**: restore a backup as a **new** policy and repoint a firewall. + +--- + +## Restore + +Run `Deploy-FWBackup.ps1` and answer **yes** at step 6, or run the restore flow on a +fresh invocation. You can restore from: + +- a **local** backup `.json` you already downloaded, or +- the **latest** (or a chosen) blob listed from the storage container. + +The restore: + +1. Reads the backup template and finds the `firewallPolicies` name parameter. +2. Deploys it into `Policy_Resource_Group_Name` as a **new** policy + (default name `-restored-`), so nothing is overwritten. +3. Optionally **repoints** an existing firewall to the new policy after explicit + confirmation (this is a live write to the firewall). + +> A backup captures the **policy and its rules** only; the firewall instance itself is +> intentionally excluded (it is infrastructure that references the policy). Restore +> recreates the policy and lets you attach it to a firewall. + +4. If backup needs to be run separately, use command- + az deployment group create --resource-group --template-file +--- + +## Parameters + +### Template (`backup-azfw-template-v3.json`) + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `Playbook_Name` | `BackUp-AzFW` | Base name; OnWrite app is `-OnWrite`. | +| `Storage_Account_Name` | `fwbackups` | Storage account for backups. | +| `Firewall_Name` | (required) | Azure Firewall name (used for blob naming). | +| `Firewall_Policy_Name` | (required) | Firewall Policy to back up. | +| `Subscription_Id` | current sub | Subscription hosting firewall and policy. | +| `Firewall_Resource_Group_Name` | (required) | RG hosting the firewall. | +| `Policy_Resource_Group_Name` | = firewall RG | RG hosting the policy. Leave empty if same as firewall RG. | +| `Backup_Frequency_Days` | `3` | Recurrence interval (days). | +| `Backup_Start_Time` | `2024-01-01T02:00:00Z` | Recurrence start time (UTC). | +| `Retention_Days` | `30` | Days before backups are auto-deleted. | +| `Enable_Write_Trigger` | `false` | Deploy the change-triggered backup (alert + action group + OnWrite app). | +| `Write_Trigger_Cooldown_Minutes` | `5` | Min minutes between write-triggered backups. | + +### Script (`Deploy-FWBackup.ps1`) + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `SubscriptionId` | (env-specific) | Target subscription. | +| `ResourceGroup` | `rg-hub-spoke-fw` | RG where the solution is deployed. | +| `FirewallResourceGroup` | `rg-hub-spoke-fw` | RG hosting the firewall. Defaults to `ResourceGroup`. | +| `PolicyResourceGroup` | `NetSecDemoShabaz` | RG hosting the policy. Defaults to firewall RG. | +| `StorageAccountName` | `fwbackupsnew101` | Storage account. | +| `FirewallName` | `azfw-hub` | Firewall name. | +| `FirewallPolicyName` | `fwpol-premium-alpineSkiHouse` | Policy name. | +| `PlaybookName` | `BackUp-AzFW` | Logic App base name. | +| `EnableWriteTrigger` | `$true` | Deploy the write-triggered backup. | +| `TemplateFile` | `backup-azfw-template-v3.json` (next to the script) | ARM template path. | +| `BlobContainer` | `firewall-backups` | Backup container name. | + +--- + +## Storage security notes + +The storage account is deployed **locked down**: + +- `publicNetworkAccess = Disabled`, `defaultAction = Deny` +- `allowBlobPublicAccess = false`, `allowSharedKeyAccess = false`, TLS 1.2 min +- `bypass = AzureServices` so the Logic App managed identities can still write. + +Backups **will still be written** over the trusted managed-identity path. However, you +will **not** be able to list/download blobs from your machine or the portal until your +IP or network is allowed. Add your IP: + +```powershell +az storage account network-rule add --account-name -g --ip-address +``` + +Listing/downloading uses AAD (`--auth-mode login`), not account keys. + +--- + +## Verify a backup + +```powershell +# List backups (requires your IP allowed + Storage Blob Data Reader) +az storage blob list --account-name --container-name firewall-backups ` + --auth-mode login --query "sort_by([].{name:name,modified:properties.lastModified},&name)[-5:]" -o table + +# Check the latest Logic App run +az rest --method get ` + --url "https://management.azure.com/subscriptions//resourceGroups//providers/Microsoft.Logic/workflows/BackUp-AzFW/runs?api-version=2019-05-01" ` + --query "value[0].{status:properties.status,start:properties.startTime}" -o table +``` + +A healthy backup blob contains 1 `Microsoft.Network/firewallPolicies` resource plus one +`.../ruleCollectionGroups` resource per RCG, chained with sequential `dependsOn`. + +--- + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---------|--------------|-----| +| Run failed, `Extract_Policy_Param` error `replace ... type Null` | Export returned no rule collection groups (policy was in a different RG than the export scope, or policy has 0 RCGs). | Ensure `Policy_Resource_Group_Name` is correct. Current template exports from the policy RG and guards the zero-RCG case. | +| Backup blob written but missing rules | Export scoped to the wrong RG. | Confirm the export URI uses `policyResourceGroup` (fixed in this template). | +| Can't see backups in portal/CLI | Storage firewall blocks your IP. | Add your IP (see Storage security notes). | +| Role grant failed | Signed-in user lacks Owner/UAA. | Have an admin assign the roles, or re-run as a privileged user. | +| OnWrite app didn't fire | `Enable_Write_Trigger` was false, or the change was outside the policy RG scope. | Redeploy with `Enable_Write_Trigger=$true`; alert scope is the policy RG. | + +--- + +## Notes + +- Editing the **live** Logic Apps in the portal will be **overwritten** on the next + `Deploy-FWBackup.ps1` run. Make durable changes in `backup-azfw-template-v3.json`. +- The template is UTF-8 (no BOM) with CRLF line endings; keep that encoding when editing.