Managing Tags In Microsoft Azure

Overview

Tagging can be a powerful tool in Azure. You can use it to organize and categorize your resources making them easier to find. Tags can also help with other things like automating processes like locking virtual networks or destroying test resources which can protect and reduce the cost of your Azure environment.

Tagging can be an important asset for large companies with cloud platforms that service multiple internal business streams and external clients all around the world. I would recommend that you take some time and plan out your tagging strategy. Think through what information you want to capture for each resource and how that data will be used.

Here are some common use cases for tagging:

  • Logically organizing resources into a taxonomy
  • Automating operations
  • Reducing costs
  • Maintaining compliance
  • Managing cloud usage costs

In order to create a consistent metadata model using tag key value pairs, you need a way of ensuring your tagging is consistent. This article will provide details on how you can use Terraform to:

  • Create policy definitions
  • Create a policy initiative
  • Assign a policy to a scope with an identity
  • Create a runbook for maintaining compliance at the resource level based on the deployed policy

Here is a high-level overview of what we want to achieve. Terraform will be used to create resources that will:

  • Enforce tags be added to resource groups and resources
  • Validate tags that are added against a set of pre-defined values
  • Inherit resource tags from resource groups

And a runbook that will allow you to:

  • Trigger a remediation task on the initiative to keep your resource tagging evergreen

Here is the general flow:

Creating Policies

Azure policies help technology teams to enforce organizational standards and compliance requirements at scale. The policies we are creating will help us to enforce, validate, and inherit tags.

  • Tag enforcement ensures that any required tag keys are included as part of a resource or resource group.
  • Tag validation checks that a given value for a tag key is appropriate by checking against a pre-defined list of valid values.
  • Tag inheritance ensures that all resources are tagged. It also makes reduces burden on team members deploying new resources in an existing resource group.

Tag enforcement and validation helps to ensure we have a consistent metadata model while tag inheritance helps to ensure we have a complete metadata model.

In this example, we will be creating policies that enforce a single tag called “Environment” with valid values of “Development”, “QA”, “UAT”, and “Production”.

You can find the full code base on my GitHub at https://github.com/Jsoconno/terraform-azure-tagging-standards/tree/main/tagging-policy.

Prerequisites

The Policy deployment will require that you have the appropriate user permissions to manage policies and assignments at the tenant root of your Azure tenant or that you have an app registration with these permissions with a generated client secret. You will also need to have created the initial Tenant Root Group management group.

Tag Enforcement Policies

The first policies we want to make are policies that enforce a certain list of tags to be added to resource groups and resources.

Here is the Terraform code for resource group tag enforcement:

resource "azurerm_policy_definition" "enforce_resource_group_tags" {
    name         = "apd_enforce_rg_tags"  
    management_group_name = data.azurerm_management_group.tenant_root.name
    policy_type  = "Custom"
    mode         = "All"
    display_name = "Enforce Resource Group Tags"

    lifecycle {
        ignore_changes = [
            metadata
        ]
    }

    metadata = <<METADATA
    {
        "category": "Tags",
        "createdBy": "",
        "createdOn": "",
        "updatedBy": "",
        "updatedOn": ""    
    }
    METADATA

    parameters = <<PARAMETERS
    {
        "tagName": {
            "type": "String",
            "metadata": {
                "displayName": "Tag Name",
                "description": "Name of the tag, such as costCenter"
            }
        },
        "policy-effect": {
        "type": "String",
        "metadata": {
            "displayName": "Policy Effect",
            "description": "The available options for the Policy Effect"
        },
        "allowedValues": [
            "audit",
            "deny"
        ],
        "defaultValue": "audit"
        }
    }
    PARAMETERS

    policy_rule = <<POLICY_RULE
    {
        "if": {
            "allOf": [
                {
                    "field": "type",
                    "equals": "Microsoft.Resources/subscriptions/resourceGroups"
                },
                {
                    "field": "[concat('tags[', parameters('tagName'), ']')]",
                    "exists": "false"
                }
            ]
        },
        "then": {
            "effect": "[parameters('policy-effect')]"
        }
    }
    POLICY_RULE
}

Here is the Terraform code for resource tag enforcement:

resource "azurerm_policy_definition" "enforce_resource_tags" {
    name         = "apd_enforce_r_tags"
    management_group_name = data.azurerm_management_group.tenant_root.name
    policy_type  = "Custom"
    mode         = "Indexed"
    display_name = "Enforce Resource Tags"
    description  = "Policy to enforce that a specific tag exists for a resource.  Excludes metric alerts."

    lifecycle {
        ignore_changes = [
            metadata
        ]
    }

    metadata = <<METADATA
    {
        "category": "Tags",
        "createdBy": "",
        "createdOn": "",
        "updatedBy": "",
        "updatedOn": ""    
    }
    METADATA

    parameters = <<PARAMETERS
    {
        "tagName": {
            "type": "String",
            "metadata": {
                "displayName": "Tag Name",
                "description": "Name of the tag, such as 'environment'"
            }
        },
        "policy-effect": {
            "type": "String",
            "metadata": {
                "displayName": "Policy Effect",
                "description": "The available options for the Policy Effect"
            },
            "allowedValues": [
                "audit",
                "deny"
            ],
            "defaultValue": "audit"
        }
    }
    PARAMETERS

    policy_rule = <<POLICY_RULE
    {
        "if": {
            "allOf": [
                {
                    "field": "[concat('tags[', parameters('tagName'), ']')]",
                    "exists": "false"
                }
            ]
        },
        "then": {
            "effect": "[parameters('policy-effect')]"
        }
    }
    POLICY_RULE
}

Tag Validation Policies

Now that we can enforce tags, we want to be able to validate those tags. You will notice in the code that there are some exclusions. That is because, at the time of writing this, those resources cannot be tagged in Azure and result in inaccurate tagging results.

Here is the Terraform code for resource group tag validation:

resource "azurerm_policy_definition" "validate_resource_group_tags" {
    name         = "apd_validate_rg_tags"
    management_group_name = data.azurerm_management_group.tenant_root.name
    policy_type  = "Custom"
    mode         = "All"
    display_name = "Validate Resource Group Tags"
    description  = "Policy to validate the value supplied for a tag key based on a parameterized array for a resource group."

    lifecycle {
        ignore_changes = [
            metadata
        ]
    }

    metadata = <<METADATA
    {
        "category": "Tags",
        "createdBy": "",
        "createdOn": "",
        "updatedBy": "",
        "updatedOn": ""    
    }
    METADATA

    parameters = <<PARAMETERS
    {
        "tagName": {
        "type": "String",
        "metadata": {
            "displayName": "Tag Name",
            "description": "Name of the tag, such as 'environment'"
        }
        },
        "policy-effect": {
            "type": "String",
            "metadata": {
                "displayName": "Policy Effect",
                "description": "The available options for the Policy Effect"
        },
        "allowedValues": [
            "audit",
            "deny"
        ],
        "defaultValue": "audit"
        },
        "options": {
            "type": "Array",
            "metadata": {
                "displayName": "Options",
                "description": "List of available options for validation."
            }
        }
    }
    PARAMETERS

    policy_rule = <<POLICY_RULE
    {
        "if": {
            "allOf": [
                {
                    "not": {
                        "field": "[concat('tags[', parameters('tagName'), ']')]",
                        "in": "[parameters('options')]"
                    }
                },
                {
                    "field": "type",
                    "equals": "Microsoft.Resources/subscriptions/resourceGroups"
                }
            ]
            },
        "then": {
            "effect": "[parameters('policy-effect')]"
        }
    }
    POLICY_RULE
}

Here is the Terraform code for resource tag validation:

resource "azurerm_policy_definition" "validate_resource_tags" {
    name         = "apd_validate_r_tags"
    management_group_name = data.azurerm_management_group.tenant_root.name
    policy_type  = "Custom"
    mode         = "Indexed"
    display_name = "Validate Resource Tags"
    description  = "Policy to validate the value supplied for a tag key based on a parameterized array for a resource."

    lifecycle {
            ignore_changes = [
                metadata
        ]
    }

    metadata = <<METADATA
    {
        "category": "Tags",
        "createdBy": "",
        "createdOn": "",
        "updatedBy": "",
        "updatedOn": ""    
    }
    METADATA

    parameters = <<PARAMETERS
    {
        "tagName":{
            "type":"String",
            "metadata":{
                "displayName":"Tag Name",
                "description":"Name of the tag, such as 'environment'"
            }
        },
        "policy-effect":{
            "type":"String",
            "metadata":{
                "displayName":"Policy Effect",
                "description":"The available options for the Policy Effect"
            },
            "allowedValues":[
                "audit",
                "deny"
            ],
            "defaultValue":"audit"
        },
        "options":{
            "type":"Array",
            "metadata":{
                "displayName":"Options",
                "description":"List of available options for validation."
            }
        }
    }
    PARAMETERS

    policy_rule = <<POLICY_RULE
    {
        "if": {
            "allOf": [
                {
                    "not": {
                        "field": "[concat('tags[', parameters('tagName'), ']')]",
                        "in": "[parameters('options')]"
                    }
                }
            ]
        },
        "then": {
            "effect": "[parameters('policy-effect')]"
        }
    }
    POLICY_RULE
}

Tag Inheritance Policy

Now that we can enforce and validate tags at the resource group and resource levels, we want create a policy that will serve as a mechanism for appending tags from a resource group to a resource deployed in that resource group if it does not have the required tags.

Here is the Terraform code for resource tag inheritance from the parent resource group:

resource "azurerm_policy_definition" "append_resource_group_tags" {
    name         = "apd_append_rg_tags"  
    management_group_name = data.azurerm_management_group.tenant_root.name
    policy_type  = "Custom"
    mode         = "Indexed"
    display_name = "Append Resource Group Tags"

    lifecycle {
        ignore_changes = [
            metadata
        ]
    }

    metadata = <<METADATA
    {
        "category": "Tags",
        "createdBy": "",
        "createdOn": "",
        "updatedBy": "",
        "updatedOn": ""    
    }
    METADATA

    parameters = <<PARAMETERS
    {
        "tagName": {
            "type": "String",
            "metadata": {
                "displayName": "Tag Name",
                "description": "Name of the tag, such as costCenter"
            }
        }
    }
    PARAMETERS

    # By using the modify effect, we are able to create a remediation task for this policy
    # An Azure Automation Runbook can be used to accomplish this on a regular basis so tags are evergreen
    policy_rule = <<POLICY_RULE
    {
        "if": {
            "allOf": [
                {
                    "field": "[concat('tags[', parameters('tagName'), ']')]",
                    "exists": "false"
                },
                {
                    "value": "[resourceGroup().tags[parameters('tagName')]]",
                    "exists": "true"
                },
                {
                    "value": "[resourceGroup().tags[parameters('tagName')]]",
                    "notEquals": ""
                }
            ]
        },
        "then": {
            "effect": "modify",
            "details": {
                "roleDefinitionIds": [
                    "/providers/microsoft.authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c"
                ],
                "operations": [
                    {
                        "operation": "add",
                        "field": "[concat('tags[', parameters('tagName'), ']')]",
                        "value": "[resourceGroup().tags[parameters('tagName')]]"
                    }
                ]
            }
        }
    }
  POLICY_RULE
}

Creating a Policy Initiative

Azure Policy Initiatives, also known as Policy Sets, are a collection of policy definitions that help you to achieve an overall goal. In this case, our overall goal is to ensure our tagging standards are met. In particular, that we are prescribe that an “Environment” tag be added to all resources and that a particular set of values be required.

Here is the Terraform code to create a policy set that can be used to manage all of our policy definitions together in a single initiative:

locals {
    environment_tags = [
        "Development",
        "QA",
        "UAT",
        "Production"
    ]
}

resource "azurerm_policy_set_definition" "tagging_standards" {
    name                  = "api_tagging_standards"
    policy_type           = "Custom"
    display_name          = "Tagging Standards"
    management_group_name = data.azurerm_management_group.tenant_root.name
    description           = "Tagging Standards to be applied to the Azure environment."

    lifecycle {
        ignore_changes = [
            metadata
        ]
    }

    parameters = <<PARAMETERS
    {
    "policy-effect":
        {
            "allowedValues":["audit","deny"],
            "metadata":{
                "description":"The effect options for the initiative.",
                "displayName":"policy-effect"
            },
            "type":"String",
            "defaultValue": "audit"
        }
    }
    PARAMETERS

    # Enforces the Environment tag on any resource group that is created.
    policy_definition_reference {
        policy_definition_id = azurerm_policy_definition.enforce_resource_group_tags.id
        parameter_values = jsonencode({
            policy-effect = {value = "[parameters('policy-effect')]"},
            tagName = {value = "Environment"}
        })
    }

    # Validates the Environment tag on any resource group that is created.
    policy_definition_reference {
        policy_definition_id = azurerm_policy_definition.validate_resource_group_tags.id
        parameter_values = jsonencode({
        policy-effect = {value = "[parameters('policy-effect')]"},
            options = {value = local.environment_tags},
            tagName = {value = "Environment"}
        })
    }

    # Appends the Environment tag on a resource based on the parent resource group if one is not provided.
    policy_definition_reference {
        policy_definition_id = azurerm_policy_definition.append_resource_group_tags.id
        parameter_values = jsonencode({
            tagName = {value = "Environment"}
        })
    }

    # Enforces the Environment tag on any resource that is created.
    policy_definition_reference {
        policy_definition_id = azurerm_policy_definition.enforce_resource_tags.id
        parameter_values = jsonencode({
            policy-effect = {value = "[parameters('policy-effect')]"},
            tagName = {value = "Environment"}
        })
    }

    # Validates the Environment tag on any resource that is created.
    policy_definition_reference {
        policy_definition_id = azurerm_policy_definition.validate_resource_tags.id
        parameter_values = jsonencode({
            policy-effect = {value = "[parameters('policy-effect')]"},
            options = {value = local.environment_tags},
            tagName = {value = "Environment"}
        })
    }
}

Creating A Policy Assignment

In order for the policy initiative to take effect, we need to assign it to a scope. This can be a management group, subscription, resource group, or a specific resource. In this case, we will want to assign this to our root management group so all resources are covered.

Here is the Terraform code for the policy assignment:

resource "azurerm_policy_assignment" "apa_tagging_standards" {
    name                 = "apa_tagging_standards"
    scope                = data.azurerm_management_group.tenant_root.id
    policy_definition_id = azurerm_policy_set_definition.tagging_standards.id
    description          = ""
    display_name         = "Example Tagging Standards"
    location             = "eastus"

    parameters = <<PARAMETERS
        {
            "policy-effect": {
                "value": "deny"
            }
        }
    PARAMETERS

    identity {
        type          = "SystemAssigned"
    }
}

resource "azurerm_role_assignment" "apa_tagging_standards" {
    scope                 = "/providers/Microsoft.Management/managementGroups/${data.azurerm_management_group.tenant_root.name}"
    role_definition_name  = "Contributor"
    principal_id          = azurerm_policy_assignment.apa_tagging_standards.identity[0].principal_id
}

Creating Runbook

Azure Automation Runbooks provide a means for automating and scheduling activities in Azure. In this case, we are going to create a runbook that can trigger the remediation task in our append resource group tags policy and have it run every four hours. This will ensure that any resources created for any reason that are not tagged in Azure do have the tags of the parent resource group.

You can find the full code base on my GitHub at https://github.com/Jsoconno/terraform-azure-tagging-standards/tree/main/tagging-runbook.

Below is the PowerShell script we will use to remediate tags:

##
# tagging-remediation.ps1
#
# Description: Used to give child resource the tags required from the parent resouce group
##

# Login to Azure
$connectionName = "AzureRunAsConnection"
try
{
    # Get the connection "AzureRunAsConnection"
    $servicePrincipalConnection=Get-AutomationConnection -Name $connectionName
 
    "Logging in to Azure..."
    Add-AzAccount `
        -ServicePrincipal `
        -TenantId $servicePrincipalConnection.TenantId `
        -ApplicationId $servicePrincipalConnection.ApplicationId `
        -CertificateThumbprint $servicePrincipalConnection.CertificateThumbprint
        # Add the below if you are using Azure Government to ensure authentication works as expected
        # -Environment "AzureUSGovernment"
}
catch
{
    if (!$servicePrincipalConnection)
    {
        $ErrorMessage = "Connection $connectionName not found."
        throw $ErrorMessage
    }
    else
    {
        Write-Error -Message $_.Exception
        throw $_.Exception
    }
}

$policySetName = "api_tagging_standards"
$policyDefinitionName = "apd_append_rg_tags"
$policyAssignmentScope = Get-AzManagementGroup | Where {$_.DisplayName -eq "Tenant Root Group"}
$policyDefinitionId = "$($policyAssignmentScope.Id)/providers/Microsoft.Authorization/policyDefinitions/$($policyDefinitionName)"

# Fetch Policy and Assignment Information
try
{
    # Get the policy set definition based on name
    $policySet = Get-AzPolicySetDefinition | where { $_.Name -eq $policySetName }

    # Get the policy definition that is responsible for appending tags from the resource group level
    $policies = $policySet.Properties.PolicyDefinitions | where { $_.policyDefinitionId -eq $policyDefinitionId }

    # Get the assigments for the policy definition
    $assignment = Get-AzPolicyAssignment -Scope $policyAssignmentScope.Id | where { $_.Properties.PolicyDefinitionId -eq $($policySet.PolicySetDefinitionId)}

}
catch
{
    Write-Error -Message $_.Exception
    throw $_.Exception
}

# Create Remediation Task
try
{
    foreach ( $policy in $policies ) {
        $job = Start-AzPolicyRemediation -PolicyAssignmentId $($assignment.PolicyAssignmentId) -PolicyDefinitionReferenceId $($policy.policyDefinitionReferenceId) -Name "remediate-$($policy.parameters.tagName.value)-tag" -ManagementGroupName $policyAssignmentScope.Name -AsJob
        $job | Wait-Job
        $remediation = $job | Receive-Job
        Write-Output "$($remediation.Name):"
        Write-Output $($remediation.DeploymentSummary)
        Start-Sleep -Seconds 60
    }
}
catch
{
    Write-Error -Message $_.Exception
    throw $_.Exception
}

Now that we have the script, we need to create a runbook that uses it. Here is the Terraform code for the runbook:

resource "azurerm_automation_runbook" "tagging_remediation" {
  name                    = "TaggingRemediation"
  location                = data.azurerm_resource_group.main.location
  resource_group_name     = data.azurerm_resource_group.main.name
  automation_account_name = data.azurerm_automation_account.main.name
  log_verbose             = "true"
  log_progress            = "true"
  description             = "Used to give child resource the tags required from the parent resource group"
  runbook_type            = "PowerShell"

  # must have a content link, but will use custom content from local file
  publish_content_link {
    uri = "https://www.microsoft.com/en-us/"
  }

  content = data.local_file.tagging_remediation.content
}

And here is the code to put the runbook on a four-hour schedule:

resource "azurerm_automation_schedule" "every_four_hours" {
  name                    = "Every Four Hours"
  resource_group_name     = data.azurerm_resource_group.main.name
  automation_account_name = data.azurerm_automation_account.main.name
  frequency               = "Hour"
  interval                = 4
  timezone                = "America/New_York"
  description             = "Runs Every 4 Hours"
}

resource "azurerm_automation_job_schedule" "tagging_remediation" {
  resource_group_name     = data.azurerm_resource_group.main.name
  automation_account_name = data.azurerm_automation_account.main.name
  schedule_name           = "Every Four Hours"
  runbook_name            = azurerm_automation_runbook.tagging_remediation.name
}

In order for the Run As Account to have the permissions it needs, we also have to make it Contributor at the management group scope:

resource "azurerm_role_assignment" "automation_run_as_account" {
  scope                = data.azurerm_management_group.tenant_root.id
  role_definition_name = "Contributor"
  principal_id         = data.azuread_service_principal.automation_run_as_account.object_id
}

Conclusion

Be above code can serve as the baseline for building a flexible, reliable, complete, and consistent tagging strategy in Azure. Hopefully you find it helpful as you explore ways to build a robust tagging system in your own Azure environments.

For the full code including the variables files, data sources, and other items, check out the repository on GitHub:

https://github.com/Jsoconno/terraform-azure-tagging-standards

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: