How to deploy Azure Policy with Bicep?


Azure Azure Landing Zones Azure Policy Infrastructure-as-code Azure Bicep 💪


Table of contents:

Azure Policy is the way to enforce company’s standards and settle compliance properly at-scale. While it’s possible to do portal clickOps in small environments with little requirements, I found it’s error prone and cumbersome to deal with in anything that is bigger than just a personal Azure tenant and a demo subscription in it. :) In this article I want to walk through different aspects of policies, provide simple examples of deploying policies as code (Azure Bicep) and outline several resources that I used along the way in my journey. I highly recommend the following video (taken from here) to get started with policies:

To settle everything properly at-scale the best way to move forward (IMHO) is to put it in code and use git as a single source of truth, have the ability to trace back to any particul change (that might cause an issue or for audit purposes) and of course for collaboration purposes between members of the team. There are multiple options for SCM, but if you ask my preference I’d mention GitLab and Azure DevOps (Repo).

Let’s take a look what elements make policy work in Azure.

Policy definition #

Policy definition, as the name suggests, is the one that makes up a policy. There are builtin (or this repo) and custom policy definitions (the ones we craft ourselves). An example of a custom policy definition would be:

 1{
 2  "properties": {
 3    "displayName": "A tag",
 4    "policyType": "Custom",
 5    "mode": "All",
 6    "description": "A tag should be given to a resource!",
 7    "metadata": {
 8      "version": "1.0.0",
 9      "category": "Custom"
10    },
11    "parameters": {
12    },
13    "policyRule": {
14      "if": {
15        "field": "tags",
16        "exists": "false"
17      },
18      "then": {
19        "effect": "audit"
20      }
21    }
22  }
23}

Each policy goes to separate json file and stored in git. This is a policy with audit effect, and it evaluates existence of a tag (if resource’s tag is missing this policy is not compliant). The policy added to the portal ends up in the list of definitions (custom):

Azure Policy - Definition

To create such policy using Bicep (actually I can put multiple in that array) and loop:

 1@description('List of policies with attributes')
 2param policies array =  [
 3  {
 4    // 1
 5    name: 'a_tag_policy.json'
 6    policyDefinition: json(loadTextContent('./custom/a_tag_policy.json'))
 7    parameters: {}
 8    identity: false
 9    scopes: [
10    ]
11  }
12]
13
14resource policyDefinition 'Microsoft.Authorization/policyDefinitions@2021-06-01' = [for policy in policies: {
15  name: guid(policy.name)
16  properties: {
17    description: policy.policyDefinition.properties.description
18    displayName: policy.policyDefinition.properties.displayName
19    metadata: policy.policyDefinition.properties.metadata
20    mode: policy.policyDefinition.properties.mode
21    parameters: policy.policyDefinition.properties.parameters
22    policyType: policy.policyDefinition.properties.policyType
23    policyRule: policy.policyDefinition.properties.policyRule
24  }
25}]

For the builtin policy definition we just need it’s ID which is formed like this: /providers/Microsoft.Authorization/policyDefinitions/<GUID>. This is needed when a policy definition needs to be added to policy initiative or assigned directly.

Azure Policy - builtin

Policy assignment #

Definition does not have any influence on our resources. What makes a difference is a policy assignment.

 1
 2@description('Location of this deployment')
 3param location string
 4
 5resource policyAssignment 'Microsoft.Authorization/policyAssignments@2021-06-01' = {
 6  name: 'policyAssignment'
 7  location: location
 8  identity: {
 9    type: 'SystemAssigned'
10  }
11  properties: {
12    description: policy.policyDefinition.properties.description
13    displayName: policy.name
14    policyDefinitionId: policyDefinitionId
15    parameters: policy.parameters
16  }
17}

NB! policyAssignment’s name has to be >64 length and uniq. And policyDefinitionId has to be formed like: /providers/Microsoft.Authorization/policyDefinitions/<GUID> (by the way builtin policyDefinitionId can be specified as well for assignment). Parameters are being passed in exactly the same form as they are required by the policy:

 1{
 2  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
 3  "contentVersion": "1.0.0.0",
 4  "parameters": {
 5    "location": {
 6      "value": "westeurope"
 7    },
 8    "listOfAllowedLocations": {
 9      "value": [
10        "westeurope",
11        "swedencentral"
12      ]
13    },
14    "managementGroupIds": {
15      "value": {
16        "development": "development",
17        "production": "production"
18      }
19    }
20 }
21}

Policy initiative #

We can assign individual definitions or group multiple definitions to make policy initiative (also known as policy set) and assign initiatives to required scope. Resource format is outlined here.

Role assignment #

Some policies (with deployIfNotExists and modify effect) require managed identity in order to make a change. This is done with a role assignment of that managed identity to a particular scope. Let’s assume we have the following policy to enable Microsoft Cloud Defender on all subscriptions:

  1{
  2    "properties": {
  3      "displayName": "Enable Microsoft Defender for Cloud on your subscription",
  4      "policyType": "Custom",
  5      "mode": "All",
  6      "description": "Identifies existing subscriptions that aren't monitored by Microsoft Defender for Cloud and protects them with Defender for Cloud's standard features.\r\nSubscriptions already monitored will be considered compliant.\r\nTo register newly created subscriptions, open the compliance tab, select the relevant non-compliant assignment, and create a remediation task.",
  7      "metadata": {
  8        "version": "1.0.0",
  9        "category": "Custom"
 10      },
 11      "parameters": {},
 12      "policyRule": {
 13        "if": {
 14          "field": "type",
 15          "equals": "Microsoft.Resources/subscriptions"
 16        },
 17        "then": {
 18          "effect": "deployIfNotExists",
 19          "details": {
 20            "type": "Microsoft.Security/pricings",
 21            "name": "VirtualMachines",
 22            "deploymentScope": "subscription",
 23            "existenceScope": "subscription",
 24            "roleDefinitionIds": [
 25              "/providers/Microsoft.Authorization/roleDefinitions/fb1c8493-542b-48eb-b624-b4c8fea62acd"
 26            ],
 27            "existenceCondition": {
 28              "anyof": [
 29                {
 30                  "field": "microsoft.security/pricings/pricingTier",
 31                  "equals": "standard"
 32                }
 33              ]
 34            },
 35            "deployment": {
 36              "location": "westeurope",
 37              "properties": {
 38                "mode": "incremental",
 39                "template": {
 40                  "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#",
 41                  "contentVersion": "1.0.0.0",
 42                  "variables": {},
 43                  "resources": [
 44                    {
 45                      "type": "Microsoft.Security/pricings",
 46                      "apiVersion": "2018-06-01",
 47                      "name": "VirtualMachines",
 48                      "properties": {
 49                        "pricingTier": "standard"
 50                      }
 51                    },
 52                    {
 53                      "type": "Microsoft.Security/pricings",
 54                      "apiVersion": "2018-06-01",
 55                      "name": "AppServices",
 56                      "properties": {
 57                        "pricingTier": "standard"
 58                      }
 59                    },
 60                     {
 61                      "type": "Microsoft.Security/pricings",
 62                      "apiVersion": "2018-06-01",
 63                      "name": "SqlServers",
 64                      "properties": {
 65                        "pricingTier": "standard"
 66                      }
 67                    },
 68                    {
 69                      "type": "Microsoft.Security/pricings",
 70                      "apiVersion": "2018-06-01",
 71                      "name": "StorageAccounts",
 72                      "properties": {
 73                        "pricingTier": "standard"
 74                      }
 75                    },
 76                    {
 77                      "type": "Microsoft.Security/pricings",
 78                      "apiVersion": "2018-06-01",
 79                      "name": "KeyVaults",
 80                      "properties": {
 81                        "pricingTier": "standard"
 82                      }
 83                    },
 84                    {
 85                      "type": "Microsoft.Security/pricings",
 86                      "apiVersion": "2018-06-01",
 87                      "name": "Arm",
 88                      "properties": {
 89                        "pricingTier": "standard"
 90                      }
 91                    },
 92                    {
 93                      "type": "Microsoft.Security/pricings",
 94                      "apiVersion": "2018-06-01",
 95                      "name": "Dns",
 96                      "properties": {
 97                        "pricingTier": "standard"
 98                      }
 99                    },
100                    {
101                      "type": "Microsoft.Security/pricings",
102                      "apiVersion": "2018-06-01",
103                      "name": "Containers",
104                      "properties": {
105                        "pricingTier": "standard"
106                      }
107                    },
108                    {
109                      "type": "Microsoft.Security/pricings",
110                      "apiVersion": "2018-06-01",
111                      "name": "CosmosDbs",
112                      "properties": {
113                        "pricingTier": "standard"
114                      }
115                    },
116                    {
117                        "type": "Microsoft.Security/pricings",
118                        "apiVersion": "2018-06-01",
119                        "name": "OpenSourceRelationalDatabases",
120                        "properties": {
121                          "pricingTier": "standard"
122                        }
123                    },
124                    {
125                        "type": "Microsoft.Security/pricings",
126                        "apiVersion": "2018-06-01",
127                        "name": "SqlServerVirtualMachines",
128                        "properties": {
129                          "pricingTier": "standard"
130                        }
131                    }
132                  ],
133                  "outputs": {}
134                }
135              }
136            }
137          }
138        }
139      }
140    }
141  }

… nah, just a long one. It includes a roleDefinitionIds array with a single item pointing to resource definition with GUID: fb1c8493-542b-48eb-b624-b4c8fea62acd. This is Security Admin builtin role required for enabling MCD on subscription (check description from the doc: This permission allows to view and update permissions for Microsoft Defender for Cloud. Same permissions as the Security Reader role and can also update the security policy and dismiss alerts and recommendations). This role has to be given to the service principal that is being generated during policy assignment.

Last but not least there is a policy exemption resource (which is an extension resource, means that we can apply this to another resource) to exclude a defined scope from an effect of the policy (an example use case would be: with the policy definition above we can require a given tag from every resource within our tenant, but we want to exclude a certain resource group or subscription from this list, so this is where we use policy exemption).

Okay the list of all Microsoft.Authorization provider resources required for policies:

  1. policyDefinitions
  2. policySetDefinitions
  3. policyAssignments
  4. roleAssignments
  5. policyExemptions

Now the next step is to put this together. Review the following folder structure:

 1
 2tree policies 
 3
 4# this content is available here: https://github.com/erudinsky/azure-landing-zones
 5
 6├── README.md
 7├── assignment.bicep
 8├── custom
 9│   ├── a_tag_policy.json
10│   ├── allowed_location.json
11│   └── enable_mdc_on_subscription.json
12├── main.bicep
13├── main.parameters.dev.json
14└── wrapper.bicep

Policy as code #

Let’s take a look at the following main.bicep:

 1targetScope = 'managementGroup'
 2
 3@description('Location of deployment')
 4param location string
 5@description('List of allowed locations')
 6param listOfAllowedLocations array
 7@description('List of management group Ids')
 8param managementGroupIds object
 9@description('List of policies')
10param policies array =  [
11  {
12    // 1
13    name: 'a_tag_policy.json'
14    policyDefinition: json(loadTextContent('./custom/a_tag_policy.json'))
15    parameters: {}
16    identity: false
17    scopes: [
18      managementGroupIds.development
19    ]
20  }
21  {
22    // 2
23    name: 'allowed_location.json'
24    policyDefinition: json(loadTextContent('./custom/allowed_location.json'))
25    parameters: {
26      listOfAllowedLocations: {
27        value: listOfAllowedLocations
28      }
29    }
30    identity: false
31    scopes: [
32      managementGroupIds.development
33    ]
34  }
35  {
36    // 3
37    name: 'dine_enable_mdc.json'
38    policyDefinition: json(loadTextContent('./custom/enable_mdc_on_subscription.json'))
39    parameters: {}
40    identity: true
41    scopes: [
42      managementGroupIds.production
43    ]
44  }
45]
46
47resource policyDefinition 'Microsoft.Authorization/policyDefinitions@2021-06-01' = [for policy in policies: {
48  name: guid(policy.name)
49  properties: {
50    description: policy.policyDefinition.properties.description
51    displayName: policy.policyDefinition.properties.displayName
52    metadata: policy.policyDefinition.properties.metadata
53    mode: policy.policyDefinition.properties.mode
54    parameters: policy.policyDefinition.properties.parameters
55    policyType: policy.policyDefinition.properties.policyType
56    policyRule: policy.policyDefinition.properties.policyRule
57  }
58}]
59
60module policyAssignment './wrapper.bicep' = [for (policy, i) in policies: {
61  name: 'poAssign_${take(policy.name, 40)}'
62  params: {
63    policy: policy
64    location: location
65    policyDefinitionId: policyDefinition[i].id  
66  }
67  dependsOn: [
68    policyDefinition
69  ]
70}]

Each definition is in a json file and is loaded with Bicep’s loadTextContent function into an array as an object. Since each element of the array is being loaded as an json object we can work with it accordingly by calling every single attribute like this (for example we need to access description attribute of a policy we do it the following way: policy.policyDefinition.properties.description. Along with policy definition we define some other things that can be helpful for us during creation and assignment of the policy. I chose the following:

Policy deployment

While the loop through the policies for the policyDefinition is clear I might need to explain the reason for calling the module for policyAssignment. Since we want scopes (plural) for the policy assignment I need a nested loop. I’ve been looking into this for a while and based on this ended up using modules (sometimes submodules) if need another nested loop.

 1targetScope = 'managementGroup'
 2
 3param location string = 'westeurope'
 4param policy object
 5param policyDefinitionId string
 6
 7module policyAssignment './assignment.bicep' = [for scope in policy.scopes: {
 8  name: 'poAssign_${take(policy.name, 40)}'
 9  scope: managementGroup(scope)
10  params: {
11    policy: policy
12    location: location
13    policyDefinitionId: policyDefinitionId
14  }
15}]

and then from the wrapper.bicep we call the actual role and policy assignment:

 1targetScope = 'managementGroup'
 2
 3param location string = 'westeurope'
 4param policy object
 5param policyDefinitionId string
 6
 7resource policyAssignment 'Microsoft.Authorization/policyAssignments@2021-06-01' = [for scope in policy.scopes: {
 8  name: uniqueString('${policy.name}_${scope}')
 9  location: location
10  identity: {
11    type: 'SystemAssigned'
12  }
13  properties: {
14    description: policy.policyDefinition.properties.description
15    displayName: policy.name
16    policyDefinitionId: policyDefinitionId
17    parameters: policy.parameters
18  }
19}]
20
21resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for (scope, i) in policy.scopes: if (!policy.identity == false) {
22  name: guid('${policy.name}_${scope}_${i}')
23  properties: {
24    roleDefinitionId: policy.policyDefinition.properties.policyRule.then.details.roleDefinitionIds[0]
25    principalId: policyAssignment[i].identity.principalId
26    principalType: 'ServicePrincipal'
27  }
28}]
29
30output policyAssignments array = [for (scope, i) in policy.scopes: {
31  policyAssignmentId: policyAssignment[i].id
32  principalId: policyAssignment[i].identity.principalId
33}]

How to deploy #

I prefer to use the same code base, but separate environments via parameters. Parameters can be defined via parameters file or via environment variables (i.e. from CICD tool). Some of my policies accept parameters (default values will be overwritten in this case). Example of parameters file:

 1{
 2  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
 3  "contentVersion": "1.0.0.0",
 4  "parameters": {
 5    "location": {
 6      "value": "westeurope"
 7    },
 8    "listOfAllowedLocations": {
 9      "value": [
10        "westeurope",
11        "swedencentral"
12      ]
13    },
14    "managementGroupIds": {
15      "value": {
16        "development": "development",
17        "production": "production"
18      }
19    }
20 }
21}

Now it’s easy to deploy using azure CLI like this:

 1
 2# Make sure az login has been run and token has been stored, check permissions on user / service principal
 3
 4az deployment mg what-if \
 5  -n policyDeployment \
 6  -f main.bicep \
 7  -p main.parameters.dev.json \
 8  -m <tenantId>  
 9
10az deployment mg validate \
11  -n policyDeployment \
12  -f main.bicep \
13  -p main.parameters.dev.json \
14  -m <tenantId>  
15
16az deployment mg create \
17  -n policyDeployment \
18  -f main.bicep \
19  -p main.parameters.dev.json \
20  -m <tenantId>  

There is one important caveat. Using deployment with anything above group scope deployment does not allow the use --mode=Complete. This means I can’t destroy this and have to come up with some creative ideas … good news, I am not alone.

I have not worked with deploymentStack yet, but this seems to be exactly what I am missing (may be you as well :). You can watch this video to learn more about deployment stacks:

Now that we have policies and managed identity assigned it’s time to review …

Azure Policy - compliance Azure Policy - remediation

If you liked this article, go ahead fork this repo and enjoy crafting policies in Azure! 🧶

Resources #

comments powered by Disqus