Create Azure Policy exemptions with Terraform

In a previous blogpost I wrote about creating your own custom Azure Policies using Terraform, you can find that post here:

And that got me thinking. I have done several posts on here now about Azure Policy but never any post about creating exemptions for policies. Let's change that.
Why use exemptions?
We use Azure Policy to enforce and govern our Azure Landing zones. Sometimes though, you have a use-case where the policies you have set just cannot be met. Now, I would not make exemptions lightly I would do my utmost to make sure the resource actually becomes compliant instead. But sometimes this is not possible, here are a few scenarios where I could make an exemption:
- The application architecture just cannot handle it, and will not support it in the near-future (Perhaps you could argue here, should this application really be allowed then?)
- There is a plan to remediate the application settings in the near future (Create an exemption with an expiration date)
- The efforts required to make the resource compliant is way too complex and the content being protected is maybe not even all that sensitive. Weigh the pros and cons yourselves.
How do I setup an exemption now then?
Alright, let's get into it. If you want to checkout the code and follow along we will work on a previous repository I have which you can find here:
Important! I don't want to break the code for any readers of the previous post so for the exemption code be sure to git checkout exemption-code
if you want that specific code.
We will make several changes. The last post deployed three Azure storage accounts with different replication types: LRS, ZRS & GRS. We made a policy that essentially disallowed storage accounts with replication type of LRS. We will do the same but assign the policy on a Subscription scope so it effects all resource groups and then create a policy exemption to allow it in one of the resource groups.
First we are updating the terraform
block to include the azidentity
provider so we can skip having to deal with our subscription ID variables:
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "4.14.0"
}
azidentity = {
source = "co-native-ab/azidentity"
}
}
}
provider "azidentity" {}
ephemeral "azidentity_azure_cli_account" "this" {}
provider "azurerm" {
features {}
subscription_id = ephemeral.azidentity_azure_cli_account.this.subscription_id
}
data "azurerm_subscription" "current" {}
Note: If you want all of the new code in its entirety, keep reading - it will be provided in this post
Read more about the provider here:
We are keeping most things the same except for the Policy Assignment resource. We will change this to be at the scope of the Azure Subscription instead:
resource "azurerm_subscription_policy_assignment" "this" {
name = "allowed_storage_sku"
display_name = azurerm_policy_definition.this.display_name
subscription_id = data.azurerm_subscription.current.id // Only use this in demo environment, in PROD use a better variable or resource
policy_definition_id = azurerm_policy_definition.this.id
description = azurerm_policy_definition.this.description
parameters = jsonencode({
"effect" = {
"value" = "Deny"
}
})
}
I am also updating the locals
block:
locals {
storage_account_replication_type = [
"GRS",
"ZRS"
]
}
resource "azurerm_storage_account" "this" {
for_each = toset(local.storage_account_replication_type)
name = lower("st${var.environment}${var.location_short}${each.key}")
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
account_tier = "Standard"
account_replication_type = each.key
}
Locals used to include the LRS version as well
Now, the entire new main.tf
should look like this:
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "4.14.0"
}
azidentity = {
source = "co-native-ab/azidentity"
}
}
}
provider "azidentity" {}
ephemeral "azidentity_azure_cli_account" "this" {}
provider "azurerm" {
features {}
subscription_id = ephemeral.azidentity_azure_cli_account.this.subscription_id
}
data "azurerm_subscription" "current" {}
resource "azurerm_resource_group" "this" {
name = "rg-${var.environment}-${var.location_short}-custompolicy"
location = var.location
}
resource "azurerm_policy_definition" "this" {
name = "allowed_storage_replication_type"
policy_type = "Custom"
display_name = "Allow only storage accounts of zone or geo redundant replication"
description = "This is a custom policy which will combine and allow both zone and geo redundant storage accounts"
mode = "Indexed"
parameters = jsonencode({
"effect" = {
"type" = "String",
"metadata" = {
"displayName" = "Effect",
"description" = "This parameter lets you choose the effect of the policy. If you choose Audit (default), the policy will only audit resources for compliance. If you choose Deny, the policy will deny the creation of non-compliant resources. If you choose Disabled, the policy will not enforce compliance (useful, for example, as a second assignment to ignore a subset of non-compliant resources in a single resource group)."
},
"allowedValues" = [
"Audit",
"Deny",
"Disabled"
],
"defaultValue" = "Audit"
}
})
policy_rule = jsonencode({
"if" = {
"allOf" = [
{
"field" = "type",
"equals" = "Microsoft.Storage/storageAccounts"
},
{
"not" = {
"field" = "Microsoft.Storage/storageAccounts/sku.name",
"in" = [
"Standard_GRS",
"Standard_RAGRS",
"Standard_GZRS",
"Standard_RAGZRS",
"Standard_ZRS",
"Premium_ZRS"
]
}
}
]
},
"then" = {
"effect" = "[parameters('effect')]"
}
})
}
resource "azurerm_subscription_policy_assignment" "this" {
name = "allowed_storage_sku"
display_name = azurerm_policy_definition.this.display_name
subscription_id = data.azurerm_subscription.current.id // Only use this in demo environment, in PROD use a better variable or resource
policy_definition_id = azurerm_policy_definition.this.id
description = azurerm_policy_definition.this.description
parameters = jsonencode({
"effect" = {
"value" = "Deny"
}
})
}
locals {
storage_account_replication_type = [
"GRS",
"ZRS"
]
}
resource "azurerm_storage_account" "this" {
for_each = toset(local.storage_account_replication_type)
name = lower("st${var.environment}${var.location_short}${each.key}")
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
account_tier = "Standard"
account_replication_type = each.key
}
Our variables.tf
should look like this:
variable "environment" {
type = string
description = "The environment for the deployed resources"
}
variable "location" {
type = string
description = "The Azure region where resources will be deployed"
default = "swedencentral"
}
variable "location_short" {
type = string
description = "The location short of the deployed resources resources will be deployed"
default = "sc"
}
We've removed the previous variable subscription_id
And our variables/prod.tfvars
file only contains one variable: environment = prod
Creating the exemption
If you have the same code as me and you have deployed the code you should at this stage have the following resources deployed if you run terraform state list

terraform apply -var-file variables/prod.tfvars -auto-approve
Note: From here on you can follow-along by writing the code I am supplying or if you want it immediately you can git checkout updated-code-exemption
to get all of the exemption code at once
First off we can verify that the policy assignment exists on the subscription scope:

Now, let's say we actually need a storage account of LRS in this resource group:

name
property in the data
block.We need to create an exemption for it using the azurerm_resource_group_policy_exemption
resource in Terraform.
BUT, to make sure our policy works let's try and create a storage account using LRS replication without having an exemption. I have written the following code:
data "azurerm_resource_group" "azqr" {
name = "rg-${var.environment}-${var.location_short}-azqr"
}
resource "azurerm_storage_account" "lrs_account" {
name = "st${var.environment}${var.location_short}lrs"
resource_group_name = data.azurerm_resource_group.azqr.name
location = data.azurerm_resource_group.azqr.location
account_tier = "Standard"
account_replication_type = "LRS"
}
I need the data block for azurerm_resource_group
since it's not in my current state

account_replication_type = "LRS"
When I try and deploy this, it fails:

So, in order to fix this let us create the exemption:
resource "azurerm_resource_group_policy_exemption" "this" {
name = "allowed_storage_sku"
policy_assignment_id = azurerm_subscription_policy_assignment.this.id
resource_group_id = data.azurerm_resource_group.azqr.id
description = "This is a policy exemption for the allowed storage sku policy"
exemption_category = "Waiver"
}
This means I have this current config now:
data "azurerm_resource_group" "azqr" {
name = "rg-${var.environment}-${var.location_short}-azqr"
}
resource "azurerm_storage_account" "lrs_account" {
name = "st${var.environment}${var.location_short}lrs"
resource_group_name = data.azurerm_resource_group.azqr.name
location = data.azurerm_resource_group.azqr.location
account_tier = "Standard"
account_replication_type = "LRS"
depends_on = [ azurerm_resource_group_policy_exemption.this ]
}
resource "azurerm_resource_group_policy_exemption" "this" {
name = "allowed_storage_sku"
policy_assignment_id = azurerm_subscription_policy_assignment.this.id
resource_group_id = data.azurerm_resource_group.azqr.id
description = "This is a policy exemption for the allowed storage sku policy"
exemption_category = "Waiver"
}

I have added depends_on = [ azurerm_resource_group_policy_exemption.this ]
to the storage account resource just to be sure the exemption is created before the storage account. This is only for demo purposes, in a real environment these types of code logics would most likely be in seperate modules / projects.
Now if I run terraform apply -var-file variables/prod.tfvars -auto-approve
it's successful!

About me
