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:
https://docs.microsoft.com/en-us/azure/governance/policy/concepts/definition-structure
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 formatyyyy-MM-ddTHH:mm:ss.fffffffZ
.
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.