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." 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. diff --git a/Azure Firewall/New Template - Logic App for Azure Firewall Backups with PS Script/backup-azfw-template-v3.json b/Azure Firewall/New Template - Logic App for Azure Firewall Backups with PS Script/backup-azfw-template-v3.json new file mode 100644 index 00000000..e90df811 --- /dev/null +++ b/Azure Firewall/New Template - Logic App for Azure Firewall Backups with PS Script/backup-azfw-template-v3.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')), ''))]" + } + } +} 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 +}