Skip to main content

Taming Azure Firewall Policies with Bicep: A Battle Against Nested Loops

· 12 min read
Craig Dempsey
Cloud Devops Engineer @ Digital Reflections

Deploying Azure Firewalls with IP Groups and Firewall Policies using Azure Verified Modules (AVM) sounded straightforward—until I hit a wall with Bicep’s nested loop limitations. What followed was a deep dive into dependency chains, AVM quirks, and creative workarounds. Here’s how I tamed the beast, and how you can too.


The Setup: What I Was Trying to Achieve​

I wanted to deploy an Azure Firewall with a Firewall Policy that references multiple IP Groups. The goal was simple:

  1. IP Groups: Define a set of IP ranges (e.g., subnets for management, bastion hosts, etc.).
  2. Firewall Policy: Create rules that reference these IP Groups as sources or destinations.
  3. Declarative Configuration: Use Bicep parameter files to define everything in a clean, reusable way.

Sounds easy, right? Well, here’s where things got messy.


The Problems: Dependency Chains and Bicep’s Limitations​

Problem 1: The Dependency Chain​

The Firewall Policy depends on the IP Groups being created first. Why? Because the rules in the Firewall Policy need to reference the resource IDs of the IP Groups. If the IP Groups don’t exist, the Firewall Policy deployment fails.

Problem 2: Bicep’s Nested Loop Limitation​

Bicep doesn’t support nested loops within modules. This became a problem because my Firewall Policy configuration had four levels of nested arrays:

Here’s what that looked like in the parameter file:

param firewallPolicyConfig = {
name: 'testFirewallPolicy01'
tier: 'Basic'
ruleCollectionGroups: [
{
name: 'networkRuleCollectionGroup'
priority: 500
ruleCollections: [
{
action: {
type: 'Allow'
}
name: 'networkRuleCollection'
priority: 1000
ruleCollectionType: 'FirewallPolicyFilterRuleCollection'
rules: [
{
name: 'bastionInbound'
ruleType: 'NetworkRule'
ipProtocols: [
'Any'
]
sourceAddresses: []
sourceIpGroups: [
'hubBastionSubnetAe'
]
destinationAddresses: [
'*'
]
destinationFqdns: []
destinationIpGroups: []
destinationPorts: [
'22'
'3389'
]
}
]
}
]
}
]
}

Problem 3: AVM's Child Resource Limitation​

Azure Verified Modules (AVM) are fantastic for standardizing deployments, but they don’t publish child resources (like Firewall Rules) as standalone modules. This meant I couldn’t break the Firewall Policy into smaller, more manageable pieces.


The Turning Point: Discovering the map Function​

After hours of frustration, I stumbled upon Bicep’s map function. This function allows you to iterate over arrays and transform their elements – without the need for nested loops.

Why map is a Game-Changer​

Bicep’s map function is designed to simplify array transformations. Instead of writing nested for loops (which Bicep doesn’t support), you can use map to process each element in an array and return a new array. This is especially useful when dealing with complex, multi-level configurations like Firewall Policies.

Here’s a quick breakdown of how map works:

  • Input: An array (e.g., ruleCollectionGroups).
  • Transformation: A function that processes each element of the array.
  • Output: A new array with the transformed elements.

Before: Nested Loops (Hypothetical)​

If Bicep supported nested loops, the code might look something like this (pseudo-code):

for each ruleCollectionGroup in ruleCollectionGroups:
for each ruleCollection in ruleCollectionGroup:
for each rule in ruleCollection:
// Process rule

But since Bicep doesn’t support nested loops, this approach is a no-go.

After: Using map to Flatten the Structure​

Instead of nested loops, I used map to flatten the structure. Here’s how it works:

ruleCollections: map(range(0, length(firewallPolicyConfig.ruleCollectionGroups)), i => ({
name: firewallPolicyConfig.ruleCollectionGroups[i].name
priority: firewallPolicyConfig.ruleCollectionGroups[i].priority
// ...
}))

Breaking It Down​

  • range(0, length(...)): Generates a sequence of indices for the array.
  • map: Iterates over the indices and applies the transformation function.
  • Transformation Function: Processes each element of the array and returns a new object.

Why This Works​

The map function eliminates the need for nested loops, which Bicep doesn’t support, making it a powerful tool for handling complex array transformations. By using map, the code becomes much easier to read and understand compared to nested for loops. Additionally, its flexibility allows you to chain multiple map calls, enabling you to handle multi-level arrays with ease.

Example: Transforming a Rule Collection​

Here’s how I used map to transform rule collections:

ruleCollections: map(range(0, length(firewallPolicyConfig.ruleCollectionGroups)), i => ({
name: firewallPolicyConfig.ruleCollectionGroups[i].name
priority: firewallPolicyConfig.ruleCollectionGroups[i].priority
ruleCollections: map(range(0, length(firewallPolicyConfig.ruleCollectionGroups[i].ruleCollections)), j => ({
name: firewallPolicyConfig.ruleCollectionGroups[i].ruleCollections[j].name
priority: firewallPolicyConfig.ruleCollectionGroups[i].ruleCollections[j].priority
// ...
}))
}))

This approach allowed me to handle multi-level arrays without running into Bicep’s limitations.


Cleaning Up the Mess with User-Defined Functions​

With the Firewall Policy deploying successfully, the next challenge was readability. The code was functional but messy, with multiple levels of map functions and confusing indices like i, j, and k. Enter user-defined functions—a Bicep feature that lets you encapsulate complex logic into reusable, readable components.

By breaking down the nested map calls into smaller, self-contained functions, I transformed the code from a tangled mess into a clean, maintainable solution.

This is what the original code looked like.

module firewallPolicy 'br/public:avm/res/network/firewall-policy:0.2.0' = {
name: '${uniqueString(deployment().name, location)}-firewallPolicy'
scope: az.resourceGroup(resourceGroupName)
params: {
name: firewallPolicyConfig.name
location: location
tags: tags
tier: firewallPolicyConfig.tier
ruleCollectionGroups: map(range(0, length(firewallPolicyConfig.ruleCollectionGroups)), i => {
name: firewallPolicyConfig.ruleCollectionGroups[i].name
priority: firewallPolicyConfig.ruleCollectionGroups[i].priority
ruleCollections: map(range(0, length(firewallPolicyConfig.ruleCollectionGroups[i].ruleCollections)), j => {
name: firewallPolicyConfig.ruleCollectionGroups[i].ruleCollections[j].name
priority: firewallPolicyConfig.ruleCollectionGroups[i].ruleCollections[j].priority
ruleCollectionType: firewallPolicyConfig.ruleCollectionGroups[i].ruleCollections[j].ruleCollectionType
action: firewallPolicyConfig.ruleCollectionGroups[i].ruleCollections[j].action
rules: map(range(0, length(firewallPolicyConfig.ruleCollectionGroups[i].ruleCollections[j].rules)), k => {
name: firewallPolicyConfig.ruleCollectionGroups[i].ruleCollections[j].rules[k].name
ruleType: firewallPolicyConfig.ruleCollectionGroups[i].ruleCollections[j].rules[k].ruleType
ipProtocols: firewallPolicyConfig.ruleCollectionGroups[i].ruleCollections[j].rules[k].ipProtocols
sourceAddresses: firewallPolicyConfig.ruleCollectionGroups[i].ruleCollections[j].rules[k].sourceAddresses
sourceIpGroups: map(
range(0, length(firewallPolicyConfig.ruleCollectionGroups[i].ruleCollections[j].rules[k].sourceIpGroups)),
l =>
az.resourceId(
subscription().subscriptionId,
resourceGroupName,
'Microsoft.Network/ipGroups',
firewallPolicyConfig.ruleCollectionGroups[i].ruleCollections[j].rules[k].sourceIpGroups[l]
)
)
destinationAddresses: firewallPolicyConfig.ruleCollectionGroups[i].ruleCollections[j].rules[k].destinationAddresses
destinationFqdns: firewallPolicyConfig.ruleCollectionGroups[i].ruleCollections[j].rules[k].destinationFqdns
destinationIpGroups: map(
range(
0,
length(firewallPolicyConfig.ruleCollectionGroups[i].ruleCollections[j].rules[k].destinationIpGroups)
),
n =>
az.resourceId(
subscription().subscriptionId,
resourceGroupName,
'Microsoft.Network/ipGroups',
firewallPolicyConfig.ruleCollectionGroups[i].ruleCollections[j].rules[k].destinationIpGroups[n]
)
)
destinationPorts: firewallPolicyConfig.ruleCollectionGroups[i].ruleCollections[j].rules[k].destinationPorts
})
})
})
}
dependsOn: [
ipGroups
]
}

The Problem: Nested Maps and Indices​

The code above is functional but hard to read and error-prone. The nested map functions and indices (i, j, k, l, n) make it difficult to follow the logic. Worse, if you need to make changes, you risk introducing bugs due to the complexity.

The Solution: User-Defined Functions​

Enter user-defined functions—a Bicep feature that lets you encapsulate complex logic into reusable, readable components. By breaking down the nested map calls into smaller, self-contained functions, I transformed the code into something clean and maintainable.

Here’s how I did it:

Step 1: Create Helper Functions​

First, I created helper functions to handle repetitive tasks, like generating resource IDs for IP Groups:

@description('Helper function to generate resource IDs for IP Groups.')
func getIpGroupResourceId(ipGroupName string, resourceGroupName string) string =>
az.resourceId(subscription().subscriptionId, resourceGroupName, 'Microsoft.Network/ipGroups', ipGroupName)

@description('Maps an array of IP Group names to their resource IDs.')
func mapIpGroups(ipGroupNames array, resourceGroupName string) array =>
map(range(0, length(ipGroupNames)), idx => getIpGroupResourceId(ipGroupNames[idx], resourceGroupName))

Step 2: Transform Rules and Collections​

Next, I created functions to transform rules and rule collections:

@description('Transforms a rule configuration object.')
func transformRule(ruleConfig object, resourceGroupName string) object =>
({
name: ruleConfig.name
ruleType: ruleConfig.ruleType
ipProtocols: ruleConfig.ipProtocols
sourceAddresses: ruleConfig.sourceAddresses
sourceIpGroups: mapIpGroups(ruleConfig.sourceIpGroups, resourceGroupName)
destinationAddresses: ruleConfig.destinationAddresses
destinationFqdns: ruleConfig.destinationFqdns
destinationIpGroups: mapIpGroups(ruleConfig.destinationIpGroups, resourceGroupName)
destinationPorts: ruleConfig.destinationPorts
})

@description('Transforms a rule collection configuration object.')
func transformRuleCollection(ruleCollectionConfig object, resourceGroupName string) object =>
({
name: ruleCollectionConfig.name
priority: ruleCollectionConfig.priority
ruleCollectionType: ruleCollectionConfig.ruleCollectionType
action: ruleCollectionConfig.action
rules: map(
range(0, length(ruleCollectionConfig.rules)),
k => transformRule(ruleCollectionConfig.rules[k], resourceGroupName)
)
})

@description('Transforms a rule collection group configuration object.')
func transformRuleCollectionGroup(groupConfig object, resourceGroupName string) object =>
({
name: groupConfig.name
priority: groupConfig.priority
ruleCollections: map(
range(0, length(groupConfig.ruleCollections)),
j => transformRuleCollection(groupConfig.ruleCollections[j], resourceGroupName)
)
})

Step 3: Simplify the Firewall Policy Module​

Finally, I replaced the nested map calls with these helper functions:

module firewallPolicy 'br/public:avm/res/network/firewall-policy:0.2.0' = {
name: '${uniqueString(deployment().name, location)}-firewallPolicy'
scope: az.resourceGroup(resourceGroupName)
params: {
name: firewallPolicyConfig.name
location: location
tags: tags
tier: firewallPolicyConfig.tier
ruleCollectionGroups: map(
range(0, length(firewallPolicyConfig.ruleCollectionGroups)),
i => transformRuleCollectionGroup(firewallPolicyConfig.ruleCollectionGroups[i], resourceGroupName)
)
}
dependsOn: [ipGroups]
}

The Result: Clean, Readable Code​

After implementing these changes, the code became cleaner, easier to read, and maintainable. The Firewall Policy now waits for the IP Groups to be created, and the rules correctly reference the IP Group resource IDs. Plus, if I need to make changes, I can do so confidently without worrying about breaking the nested logic.

Before and After: A Visual Comparison​

Before​

ruleCollectionGroups: map(range(0, length(firewallPolicyConfig.ruleCollectionGroups)), i => {
ruleCollections: map(range(0, length(firewallPolicyConfig.ruleCollectionGroups[i].ruleCollections)), j => {
rules: map(range(0, length(firewallPolicyConfig.ruleCollectionGroups[i].ruleCollections[j].rules)), k => {
sourceIpGroups: map(
range(0, length(firewallPolicyConfig.ruleCollectionGroups[i].ruleCollections[j].rules[k].sourceIpGroups)),
l => getIpGroupResourceId(firewallPolicyConfig.ruleCollectionGroups[i].ruleCollections[j].rules[k].sourceIpGroups[l])
)
})
})
})

After​

ruleCollectionGroups: map(
range(0, length(firewallPolicyConfig.ruleCollectionGroups)),
i => transformRuleCollectionGroup(firewallPolicyConfig.ruleCollectionGroups[i], resourceGroupName)
)

After implementing these changes, the deployment worked flawlessly. The Firewall Policy waited for the IP Groups to be created, and the rules correctly referenced the IP Group resource IDs.


Lessons Learned​

Bicep Isn’t a Programming Language​

Bicep is a declarative DSL, not a full-fledged programming language. If you find yourself writing complex logic, it’s a sign that you need to rethink your approach. Instead of nested loops, lean on functions like map and range to simplify your code. For example:

// Instead of nested loops, use map to flatten the structure
ruleCollections: map(range(0, length(ruleCollections)), i => ({
name: ruleCollections[i].name
priority: ruleCollections[i].priority
}))

AVM Requires Creativity​

While AVM modules are great for standardization, they don’t cover every use case. Be prepared to write helper functions or workarounds for advanced scenarios.

Test Incrementally​

Don’t try to deploy everything at once. Start with a single IP Group and rule, then scale up. Use az deployment group validate to catch issues early.


Code You Can Steal​

Here’s the complete Bicep code for the Firewall Policy module, including the parameter file and main Bicep file. Feel free to adapt it to your own deployments!

.biceparam file​

using '../../../../deployments/resource-group/hubAzureFirewall/deploy.bicep'

var location = {
long: 'australiaeast'
short: 'ae'
}

// -- Parameters -----------------------------------------------------------------------------------

param resourceGroupName = 'rg-hub-networks-01'
param tags = {
env: 'dev'
ownedby: 'theChief'
region: 'australiaeast'
supportteam: 'TheFootClan'
}

param azureFirewallConfig = {
name: 'azfw-hub-01'
virtualNetworkName: 'vnet-hub-01'
lawName: 'law-sec-01'
lawResourceGroupName: 'rg-sec-law-01'
azureSkuTier: 'Basic'
networkRuleCollections: []
threatIntelMode: 'Deny'
}

param ipGroupConfig = {
groups: [
{
name: 'hubEntireRangeAe'
ipAddresses: [
'10.200.0.0/22'
]
}
{
name: 'hubBastionSubnetAe'
ipAddresses: [
'10.200.4.192/26'
]
}

]
}
param firewallPolicyConfig = {
name: 'testFirewallPolicy01'
tier: 'Basic'
ruleCollectionGroups: [
{
name: 'networkRuleCollectionGroup'
priority: 500
ruleCollections: [
{
action: {
type: 'Allow'
}
name: 'networkRuleCollection'
priority: 1000
ruleCollectionType: 'FirewallPolicyFilterRuleCollection'
rules: [
{
name: 'bastionInbound'
ruleType: 'NetworkRule'
ipProtocols: [
'Any'
]
sourceAddresses: []
sourceIpGroups: [
'hubBastionSubnetAe'
]
destinationAddresses: [
'*'
]
destinationFqdns: []
destinationIpGroups: []
destinationPorts: [
'22'
'3389'
]
}
]
}
]
}
]
}

main.bicep​

targetScope = 'subscription'

// ==============================================
// Parameters
// ==============================================

@description('Required. Configuration for the virtual network.')
param azureFirewallConfig object

@description('Optional. Location for all resources.')
param location string = deployment().location

@description('Required. Name of the resource group to create.')
param resourceGroupName string

@description('Required. Tags for the resources.')
param tags object

@description('Required. Ip Group configuration information.')
param ipGroupConfig object

@description('Required. Firewall Policy information.')
param firewallPolicyConfig object

// ==============================================
// User-Defined Functions
// ==============================================

@description('Helper function to generate resource IDs for IP Groups.')
func getIpGroupResourceId(ipGroupName string, resourceGroupName string) string =>
az.resourceId(subscription().subscriptionId, resourceGroupName, 'Microsoft.Network/ipGroups', ipGroupName)

@description('Maps an array of IP Group names to their resource IDs')
func mapIpGroups(ipGroupNames array, resourceGroupName string) array =>
map(range(0, length(ipGroupNames)), idx => getIpGroupResourceId(ipGroupNames[idx], resourceGroupName))

@description('Transforms a rule configuration object')
func transformRule(ruleConfig object, resourceGroupName string) object =>
({
name: ruleConfig.name
ruleType: ruleConfig.ruleType
ipProtocols: ruleConfig.ipProtocols
sourceAddresses: ruleConfig.sourceAddresses
sourceIpGroups: mapIpGroups(ruleConfig.sourceIpGroups, resourceGroupName)
destinationAddresses: ruleConfig.destinationAddresses
destinationFqdns: ruleConfig.destinationFqdns
destinationIpGroups: mapIpGroups(ruleConfig.destinationIpGroups, resourceGroupName)
destinationPorts: ruleConfig.destinationPorts
})

@description('Transforms a rule collection configuration object')
func transformRuleCollection(ruleCollectionConfig object, resourceGroupName string) object =>
({
name: ruleCollectionConfig.name
priority: ruleCollectionConfig.priority
ruleCollectionType: ruleCollectionConfig.ruleCollectionType
action: ruleCollectionConfig.action
rules: map(
range(0, length(ruleCollectionConfig.rules)),
k => transformRule(ruleCollectionConfig.rules[k], resourceGroupName)
)
})

@description('Transforms a rule collection group configuration object')
func transformRuleCollectionGroup(groupConfig object, resourceGroupName string) object =>
({
name: groupConfig.name
priority: groupConfig.priority
ruleCollections: map(
range(0, length(groupConfig.ruleCollections)),
j => transformRuleCollection(groupConfig.ruleCollections[j], resourceGroupName)
)
})


// ==============================================
// Existing Resources
// ==============================================

resource resourceGroup 'Microsoft.Resources/resourceGroups@2024-07-01' existing = {
name: resourceGroupName
}

resource vNet 'Microsoft.Network/virtualNetworks@2024-05-01' existing = {
name: azureFirewallConfig.virtualNetworkName
scope: az.resourceGroup(resourceGroupName)
}

resource law 'Microsoft.OperationalInsights/workspaces@2023-09-01' existing = {
name: azureFirewallConfig.lawName
scope: az.resourceGroup(azureFirewallConfig.lawResourceGroupName)
}

// ==============================================
// Modules
// ==============================================
module ipGroups 'br/public:avm/res/network/ip-group:0.2.0' = [
for (ipGroup, index) in ipGroupConfig.groups: {
name: 'ipGroup-${ipGroup.name}'
scope: az.resourceGroup(resourceGroupName)
params: {
name: ipGroup.name
ipAddresses: ipGroup.ipAddresses
location: location
tags: tags
}
}
]

module firewallPolicy 'br/public:avm/res/network/firewall-policy:0.2.0' = {
name: '${uniqueString(deployment().name, location)}-firewallPolicy'
scope: az.resourceGroup(resourceGroupName)
params: {
name: firewallPolicyConfig.name
location: location
tags: tags
tier: firewallPolicyConfig.tier
ruleCollectionGroups: map(
range(0, length(firewallPolicyConfig.ruleCollectionGroups)),
i => transformRuleCollectionGroup(firewallPolicyConfig.ruleCollectionGroups[i], resourceGroupName)
)
}
dependsOn: [ipGroups]
}

module azureFirewall 'br/public:avm/res/network/azure-firewall:0.5.2' = {
name: '${uniqueString(deployment().name, location)}-azureFirewall'
scope: az.resourceGroup(resourceGroupName)
params: {
name: azureFirewallConfig.name
location: location
tags: tags
azureSkuTier: azureFirewallConfig.azureSkuTier
networkRuleCollections: azureFirewallConfig.networkRuleCollections
threatIntelMode: azureFirewallConfig.threatIntelMode
virtualNetworkResourceId: az.resourceId(
subscription().subscriptionId,
resourceGroupName,
'Microsoft.Network/virtualNetworks',
azureFirewallConfig.virtualNetworkName
)
diagnosticSettings: [
{
workspaceResourceId: az.resourceId(
subscription().subscriptionId,
azureFirewallConfig.lawResourceGroupName,
'Microsoft.OperationalInsights/workspaces',
azureFirewallConfig.lawName
)
name: 'logs'
logCategoriesAndGroups: azureFirewallConfig.logCategoriesAndGroups
}
]
firewallPolicyId: az.resourceId(
subscription().subscriptionId,
resourceGroupName,
'Microsoft.Network/firewallPolicies',
firewallPolicyConfig.name
)
}
dependsOn: [
resourceGroup
vNet
law
firewallPolicy
]
}

// ==============================================
// Outputs
// ==============================================

// Add outputs here if needed


Final Thoughts​

This journey taught me that even the most straightforward deployments can become complex when dealing with dependencies and nested configurations. By leveraging Bicep’s map function and AVM’s modular approach, I was able to create a scalable and maintainable solution.

If you’re facing similar challenges, don’t give up! Experiment with Bicep’s features, test incrementally, and don’t be afraid to think outside the box. And remember, sometimes the best solutions come from stepping back, re-evaluating the problem, and approaching it from a new angle.

Happy coding!


Further Reading: