Create Azure Policy exemptions with Terraform

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:

Write your own custom Azure Policies with Terraform
Previously on this blog we’ve talked about dealing with Azure Policy using Infrastructure as Code, sometimes referred to as Policy as Code. You can read about that more if you are interested here: Use Terraform to manage Azure PolicyThere are several ways you can manage Policy as Code with Microsoft

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:

GitHub - carlzxc71/azure-policy-custompolicy: This repository contains some demo code for configuring custom Azure Policies with Terraform
This repository contains some demo code for configuring custom Azure Policies with Terraform - carlzxc71/azure-policy-custompolicy
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:

Terraform Registry

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

I ran 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:

If you are following along, you can use any resource group. Just be sure to update the 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

Key here is account_replication_type = "LRS"

When I try and deploy this, it fails:

This is what we want!

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

About me
If you have landed on my page you will have already understood my passion for tech, but obviously there is more to life than that. Here I will try and outline a few of my other hobbies. Strength training I am a person who loves to move around and challenge