Azure, Professional, Terraform

Terraform, Azure Policy, and Dates OH MY!

The Requirement

I had requirement recently on how to evaluate a tag value which was a date and determine if it was within a specific time window. This part alone isn’t easy but then the ask was to do this via Terraform which provided its own interesting quirks.

Background

If just starting feel free to visit how to “Dynamically Assign Azure Policy via Terraform” or “Creating and Deploying Azure Policy via Terraform” to get a better understanding of how to leverage Terraform to deploy Azure Policy.

This post will specifically focus walking through on how to use Terraform to create an Azure Policy to evaluate if a date value falls within a desired range.. The given scenario is we’d like to enforce a “delete-by” tag whose date has to be at least three days from the given creation day and can’t be greater than thirty days out. This is to prevent someone from circumnavigating our process. This idea stems from the Auto Provision and Manage Azure Resource Group Lifecycle I contributed for the 2021 Azure Spring Cleaning Community Event.

The Plan

Thinking about the requirements for this there should be a policy that takes (1) a tag name (2) a minimum days from, (3) a maximum days from, and (4) a policy effect.

This would allow for maximum flexibility as any tag name and max/min combination can be passed into the policy.

One requirement is the value in the date tag must be in YYYY-MM-DD format. We will go into why later.

The Terraform Variables

Keeping flexibility is key to any design. As such this will adhere to a varaibles.tf file for each environment. This will allow us to assign different policy parameters for different subscriptions. As such our variables file would look something like:

variable "datediff_tags" {
  type = map(object({
    tagName         = string
    maxDaysFrom        = number
    minDaysFrom       = number
    tagPolicyEffect = string
  }))
  default = {
    expire-by = {
      tagName         = "delete-by"
      maxDaysFrom        = 30
      minDaysFrom         =3
      tagPolicyEffect = "Deny"
    }
  }
}

In this code block the variables.tf file has defined one tag, delete-by and essentially saying the date provided should fall between 3 days and 30 days from the current date. If it is not in that window then the policy will Deny the resource creation.

The Policy Definition

Terraform isn’t the greatest at implementing policy as essentially, we are injecting an ARM json into the policy definition and parameters.

resource "azurerm_policy_definition" "resource_group_datediff_tags_definition" {
  name         = "resourceGroupTagDateDifValidation"
  policy_type  = "Custom"
  mode         = "All"
  display_name = "Resource Group - Date Difference Validation"

  metadata = <<METADATA
    {
      "version": "1.0.0",
      "category": "Resource Group"
    }

METADATA


  policy_rule = <<POLICY_RULE
  {
      "if": {
                "anyOf": [
         {
            "field": "[concat('tags[', parameters('tagName'), ']')]",
            "greaterOrEquals": "[addDays(utcNow(), int(parameters('maxDaysFrom')))]"
          },
          {
            "field": "[concat('tags[', parameters('tagName'), ']')]",
            "less": "[addDays(utcNow(), int(parameters('minDaysFrom')))]"
          }]
          },
      
      "then": {
        "effect": "[parameters('tagPolicyEffect')]"
      }
    }
  
POLICY_RULE


  parameters = <<PARAMETERS
    {
        "tagName": {
        "type": "String",
        "metadata": {
          "displayName": "Tag Required",
          "description": "Name of the tag, such as 'owner'"
        }
      },
              "tagPolicyEffect": {
 "type": "String",
                 "metadata": {
                 "displayName": "Effect",
                 "description": "Enable or disable the execution of the policy"
                 },
                 "allowedValues": [
                 "AuditIfNotExists",
                 "Disabled",
                 "Deny"
                 ],
                 "defaultValue": "AuditIfNotExists"
      },
        "maxDaysFrom": {
        "type": "String",
        "metadata": {
          "displayName": "Days Allowed in Future",
          "description": "Maximum number of Days allowed from the provided tag"
        },
        "defaultValue": "30"
        },
        "minDaysFrom": {
        "type": "String",
        "metadata": {
          "displayName": "Minimum Days Allowed From today",
          "description": "Minimum number of Days allowed from today for the provided tag"
        },
        "defaultValue": "0"
      }
  }
PARAMETERS

}

The Policy Gotchas…..

This is a big enough hurdle it required it’s own section. When attempting to pass the number types from the variables.tf into the azurerm_policy_definition Terraform attempts to try and help us by converting the value into a string.

Terraform automatically converts number and bool values to strings when needed. It also converts strings to numbers or bools, as long as the string contains a valid representation of a number or bool value.

https://www.terraform.io/docs/language/expressions/types.html#type-conversion

So, because of this we need to get a little crafty on a few fronts with the policy definition.

First the all of the parameters in the azurerm_policy_definition need to be strings and so does the default. Notice that the minDaysFrom and maxDaysFrom take in type string and that their default value is also string.

Now for the next hurdle this causes. We can’t use the addDays functionality in the policy with passing a string for the number of days to add. This is a good time to stop and point out there is a subset of ARM functions and features only available to azure policy types. addDays is one of those and how we will use utcNow() is as well.

So back to the dilemma on passing a string to the addDays() function. To get around this we can convert the parameter to an int via ARM type casting functions. So now the addDays() day argument will look like int(parameters('minDaysFrom')) and int(parameters('maxDaysFrom')). Now the next step is to take the current date. Normally if using utcNow it can only be used as a default value; however, as noted in the above subsection of ARM functions for Azure Policy:

utcNow() – Unlike an ARM template, this property can be used outside defaultValue.Returns a string that is set to the current date and time in Universal ISO 8601 DateTime format yyyy-MM-ddTHH:mm:ss.fffffffZ.

https://docs.microsoft.com/en-us/azure/governance/policy/concepts/definition-structure

This allows us to leverage utcNow in the addDays() function. However, an important prereq is the format of the date value in the tag we are evaluating must be in YYYY-MM-DD format.

Translating the Policy

It always helps when dealing with Azure Policy to write the logic out in short hand. In this case:

IF tagValue >= today + maxDaysFrom OR tagValue < today + minDaysFrom THEN tagPolicyEffect

Output Policy ID

If using modules, which I am then need to create an output.tf file like the following so the assignment can reference it.

output "policy_id" {
  value = azurerm_policy_definition.resource_group_datediff_tags_definition.id
}

Policy Assignment

Having a definition is one thing; however, now the policy needs to be assigned and the variables passed into the assignment:

resource "azurerm_policy_assignment" "assign_rg_datediff_tags" {
  for_each=  var.datediff_tags
  name                 = "${var.env} - Assign Required Date Diff Validation ${each.value.tagName}"
  scope                = data.azurerm_subscription.subscription_info.id
  not_scopes           = [
]
  policy_definition_id = module.datediff_tag.policy_id
  description          = "${var.env} - Policy Assignment created for Date Diff Validation ${each.value.tagName}"
  display_name         = "${var.env} - Required Date Diff Validation For ${each.value.tagName} Tag"


  parameters = <<PARAMETERS
    {
      "tagName": {"value": "${each.value.tagName}" },
      "maxDaysFrom": {"value": "${each.value.maxDaysFrom}" },
      "minDaysFrom": {"value": "${each.value.minDaysFrom}" },
      "tagPolicyEffect": {"value": "${each.value.tagPolicyEffect}" }
    }
PARAMETERS
} 

This assignment will loop through all values in the variable datediff_tags object, pull the policy definition, and pass the values of each datediff_tags object as parameters for the policy assignment.

I’ve left the not_scopes block to show resources can be added here. Further documentation on the azurerm_policy_assignment is here. Some other things here to note is the use of ${each.value.tagName} in conjunction with {$var.env} to prevent the same assignment and policy name from appearing more then once. For clarity ${var.env} is just an abbreviation like sbx, dev, uat, or prd.

The End Result

Azure Policy Definition

Here is the policy definition taken from the Azure Portal after deployment

{
  "properties": {
    "displayName": "Resource Group - Date Difference Validation",
    "policyType": "Custom",
    "mode": "All",
    "description": "",
    "metadata": {
      "category": "Resource Group",
      "version": "1.0.0",
      "createdBy": "1000200-ea00-0000-0000-2597833a000d",
      "createdOn": "2021-05-13T16:45:17.439661Z",
      "updatedBy": null,
      "updatedOn": null
    },
    "parameters": {
      "maxDaysFrom": {
        "type": "String",
        "metadata": {
          "description": "Maximum number of Days allowed from the provided tag",
          "displayName": "Days Allowed in Future"
        },
        "defaultValue": "30"
      },
      "minDaysFrom": {
        "type": "String",
        "metadata": {
          "description": "Minimum number of Days allowed from today for the provided tag",
          "displayName": "Minimum Days Allowed From today"
        },
        "defaultValue": "0"
      },
      "tagName": {
        "type": "String",
        "metadata": {
          "description": "Name of the tag, such as 'delete-by'",
          "displayName": "Tag Required"
        }
      },
      "tagPolicyEffect": {
        "type": "String",
        "metadata": {
          "description": "Enable or disable the execution of the policy",
          "displayName": "Effect"
        },
        "allowedValues": [
          "AuditIfNotExists",
          "Disabled",
          "Deny"
        ],
        "defaultValue": "AuditIfNotExists"
      }
    },
    "policyRule": {
      "if": {
        "anyOf": [
          {
            "field": "[concat('tags[', parameters('tagName'), ']')]",
            "greaterOrEquals": "[addDays(utcNow(), int(parameters('maxDaysFrom')))]"
          },
          {
            "field": "[concat('tags[', parameters('tagName'), ']')]",
            "less": "[addDays(utcNow(), int(parameters('minDaysFrom')))]"
          }
        ]
      },
      "then": {
        "effect": "[parameters('tagPolicyEffect')]"
      }
    }
  },
  "id": "/subscriptions/0000000-0000-0000-0000-0000000000/providers/Microsoft.Authorization/policyDefinitions/resourceGroupTagDateDifValidation",
  "type": "Microsoft.Authorization/policyDefinitions",
  "name": "resourceGroupTagDateDifValidation"
}

Policy Assignment

Conclusion

Using Azure Policy to compare two dates is difficult enough. However; throwing Terraform on top of it adds extra complexity. Hopefully this walkthrough helps if you ever are in a situation when need to compare date values either on Azure Tags or on other resource properties.