Creating environment-specific NSG rules with optional ARM Template parameters

In the context of Azure Network Security Groups, it’s often useful to be able to specify security rules that only apply in certain environments. For example, we might have some kind of load testing tool that should only be permitted to connect to our testing environment, or we might want to restrict our public facing load balancer so that it is only able to connect to our production environment.

I’ve long been of the opinion that when faced with complicated code of uncertain semantics - and ARM Templates for networking certainly tick both of these boxes - that a good way to understand the behaviour of the code is to write tests.

Accordingly, this example serves two purposes - the first being to describe the mechanism for creating optional NSG rules, and the second being to illustrate techniques for infrastructure testing with Pester. I think testing of infrastructure code is a fairly important topic, and it’s one I intend to return to in the future.

I have included Pester tests for each example in a separate file. All the files for this example are stored in a GitHub Gist. To run the examples, you will have to clone the Gist to a local repo with a command such as

git clone https://gist.github.com/gavincampbell/58ec298a2127a60e8ce4d787070904fe conditional-nsg-rules

You should then be able to change to the conditional-nsg-rules directory and run the tests with Invoke-Pester1.

The test suite will create a resource group with a randomized name - if this clashes with one of your existing resource groups you are very unlucky - and clean it up at the end of the test run.

The Configuration File

The configuration for our tests is stored in the file config.psd1. This config file could be re-used for a “real” deployment from your release management tool, but such a deployment isn’t included here.

The ARM Template

The ARM Template, like most ARM Templates, is too long to reproduce here, so I will call out some highlights.

In the parameters section

    "parameters": {
        "vnetName": {
            "type": "string"
        },
        "vnetAddressPrefix": {
            "type": "string"
        },

        "webServerSubnetName": {
            "type": "string"
        },
        "webServerSubnetAddressPrefix": {
            "type": "string"
        },
        "webServerNsgName": {
            "type": "string"
        },
        "testRunnerIpRange": {
            "type": "string",
            "defaultValue": ""

        }
}

we pass a few values, mostly the kind of things you would expect to vary per environment. All the parameters are mandatory except for testRunnerIpRange, which has a default value of "".

The ARM template will take these parameters and create a Virtual Network, Subnet, and Network Security Group using the supplied names, and bind the Network Security Group to the Subnet.

The Network Security Group has two Security Rules, one defined inline,

                  {
                        "name": "DefaultDeny",
                        "properties": {
                            "description": "Denies all inbound traffic not matched by a previous rule",
                            "protocol": "*",
                            "sourcePortRange": "*",
                            "destinationPortRange": "*",
                            "sourceAddressPrefix": "*",
                            "destinationAddressPrefix": "*",
                            "access": "Deny",
                            "priority": 4096,
                            "direction": "Inbound"
                        }
}

which adds a default deny, and one which is defined as a top-level resource.

{
            "name": "[concat(parameters('webServerNsgName'),'/allow8081fromTestSubnet')]",
            "type": "Microsoft.Network/networkSecurityGroups/securityRules",
            "location": "[resourceGroup().location]",
            "dependsOn": [
                "[parameters('webServerNsgName')]"
            ],
            "condition": "[greater(length(parameters('testRunnerIpRange')),0)]",

            "apiVersion": "2019-11-01",
            "properties": {
                "description": "nsgRuleDescription",
                "protocol": "tcp",
                "sourcePortRange": "*",
                "destinationPortRange": "8081",
                "sourceAddressPrefix": "[parameters('testRunnerIpRange')]",
                "destinationAddressPrefix": "[parameters('webServerSubnetAddressPrefix')]",
                "access": "Allow",
                "priority": 100,
                "direction": "Inbound"
            }
}

This second rule allows traffic from the IP range specified in the testRunnerIPRange parameter, but only if the length of this parameter is greater than zero

"condition": "[greater(length(parameters('testRunnerIpRange')),0)]"

What this allows us to do is supply a value for the parameter if we want the rule to be created, or simply omit the parameter in those environments where we don’t want this rule.

The Tests

The tests should make all of this a bit clearer.

In the first test context we just pass the following values read from the config file,

     $defaultTemplateParams = @{
           vnetName                     = $Config.vNetConfig.vNetName
           vnetAddressPrefix            = $Config.vNetConfig.addressPrefix
           webServerSubnetName          = $Config.webSubnetConfig.subnetName
           webServerSubnetAddressPrefix = $Config.webSubnetConfig.addressPrefix
           webServerNsgName             = $Config.nsgName
} 

with no value passed for the testRunnerIpRange parameter. Accordingly, we don’t expect the rule to be created.

In the second context, we add the testRunnerIpRange to the template parameters

 $TemplateParams = $defaultTemplateParams + @{testRunnerIpRange = $Config.testSubnetConfig.addressPrefix }

and we expect that the rule is created.

We also have some “base” assertions which we call in both contexts to make sure that our deployment hasn’t broken anything else.

    $baseCaseAssertions =  {
       
       $vNet = Get-AzVirtualNetwork -Name $Config.vNetConfig.vNetName -ResourceGroupName $resourceGroupName -ExpandResource 'Subnets/NetworkSecurityGroup'
       $webServerSubnet = Get-AzVirtualNetworkSubnetConfig -Name $Config.webSubnetConfig.subnetName -VirtualNetwork $vNet
       $nsg = $webServerSubnet.NetworkSecurityGroup
      
       It "Creates a vnet Called $($Config.vNetConfig.vNetName)" {
           $vNet | Should -Not -BeNullOrEmpty
       }
       It "Creates a subnet called $($Config.webSubnetConfig.subnetName)" {
           $webServerSubnet | Should -Not -BeNullOrEmpty
       }
       It "Attaches an NSG called $($Config.nsgName) to the subnet $($Config.webSubnetConfig.subnetName)" { 
           $webServerSubnet.NetworkSecurityGroup.Id.split("/") | select -last 1| Should -Be $Config.nsgName
       }
       It "Creates a Default Deny rule with priority 4096" {
           $nsg.SecurityRules | ?{ $_.Priority -eq 4096 -and $_.Access -eq 'Deny' -and
                                   $_.Direction -eq 'Inbound' -and $_.Protocol -eq '*' -and
                                   $_.SourceAddressPrefix -eq '*' -and $_.DestinationAddressPrefix -eq '*'} |
           Should -Not -BeNullOrEmpty

       }


}

This may appear to be overly cautious, but in my experience caution is a desirable thing when making network changes with ARM Templates.

There are some additional tests included in the file that examine the effects of deploying and redeploying the template in combinations of Complete and Incremental modes, these serve to demonstrate that the optional parameter can be used as a switch to delete and recreate the rule as desired. The full output of the test run follows. This takes around five minutes to execute, three minutes of which is the time taken to delete the resource group at the end of the run.


PS /home/gavin/code/conditional-nsg-rules> Invoke-Pester
Pester v4.10.1
Executing all tests in '.'

Executing script /home/gavin/code/conditional-nsg-rules/nsgTemplate.Tests.ps1

  Describing NSG Tests

    Context Create the NSG without passing a value to the testRunnerIpRange parameter
      [+] Doesn't create an ingress rule for the testRunnerIpRange 92ms
      [+] Creates a vnet Called conditionalNsgRulesDemovNet 76ms
      [+] Creates a subnet called webServerSubnet 9ms
      [+] Attaches an NSG called webServerNsg to the subnet webServerSubnet 71ms
      [+] Creates a Default Deny rule with priority 4096 19ms

    Context Create the NSG passing 192.168.0.128/25 to the testRunnerIpRange parameter
      [+] Creates an ingress rule for the 192.168.0.128/25 CIDR 55ms
      [+] Creates a vnet Called conditionalNsgRulesDemovNet 37ms
      [+] Creates a subnet called webServerSubnet 11ms
      [+] Attaches an NSG called webServerNsg to the subnet webServerSubnet 9ms
      [+] Creates a Default Deny rule with priority 4096 5ms

    Context Create the nsg in complete mode with the testRunnerIpRange parameter set then redeploy in Incremental Mode with the testRunnerIPRange parameter removed
      [+] Creates the nsg rule on the initial deployment 44ms
      [+] Deletes the Security Rule on the subsequent incremental deployment 31ms
      [+] Creates a vnet Called conditionalNsgRulesDemovNet 34ms
      [+] Creates a subnet called webServerSubnet 14ms
      [+] Attaches an NSG called webServerNsg to the subnet webServerSubnet 7ms
      [+] Creates a Default Deny rule with priority 4096 11ms

    Context Create the nsg in complete mode with the testRunnerIpRange parameter set then redeploy in Complete  Mode with the testRunnerIPRange parameter removed
      [+] Creates the nsg rule on the initial deployment 19ms
      [+] Deletes the Security Rule on the subsequent Complete Deployment 8ms
      [+] Creates a vnet Called conditionalNsgRulesDemovNet 21ms
      [+] Creates a subnet called webServerSubnet 7ms
      [+] Attaches an NSG called webServerNsg to the subnet webServerSubnet 11ms
      [+] Creates a Default Deny rule with priority 4096 10ms

    Context Create the nsg in complete mode with the testRunnerIpRange parameter unset then redeploy in Incremental Mode with the testRunnerIPRange parameter added
      [+] Doesn't create the nsg rule on the initial deployment 8ms
      [+] Creates the Security Rule on the subsequent incremental deployment 34ms
      [+] Creates a vnet Called conditionalNsgRulesDemovNet 39ms
      [+] Creates a subnet called webServerSubnet 12ms
      [+] Attaches an NSG called webServerNsg to the subnet webServerSubnet 12ms
      [+] Creates a Default Deny rule with priority 4096 8ms

    Context Create the nsg in complete mode with the testRunnerIpRange parameter unset then redeploy in Complete Mode with the testRunnerIPRange parameter added
      [+] Doesn't create the nsg rule on the initial deployment 7ms
      [+] Creates the Security Rule on the subsequent Complete Deployment 33ms
      [+] Creates a vnet Called conditionalNsgRulesDemovNet 6ms
      [+] Creates a subnet called webServerSubnet 10ms
      [+] Attaches an NSG called webServerNsg to the subnet webServerSubnet 32ms
      [+] Creates a Default Deny rule with priority 4096 19ms
VERBOSE: Performing the operation "Removing resource group ..." on target "conditionalNsgRulesDemoRg--GjxnKqLrvEsdFVWPpcCT".
Tests completed in 266.93s
Tests Passed: 34, Failed: 0, Skipped: 0, Pending: 0, Inconclusive: 0 


  1. There are a few unstated prerequisites here, such as having an Azure account, being signed into it from PowerShell, having appropriate versions of Azure Powershell and Pester installed, etc. ↩︎