Sentinel : Checking nested tagging values

Hi,
I am writing a policy to check whether a tag for a in Azure is within the allowed values.
Below is my policy:

import "tfplan/v2" as tfplan
import "strings"

allRGs = filter tfplan.resource_changes as _, rc {
	rc.type is "azurerm_resource_group" and
	rc.mode is "managed" and
	(rc.change.actions contains "create" or 
	 	rc.change.actions is ["update"])

}

allowed_appID = ["1234","567"]
violatingappIDs = {}

for allRGs as address, key {
    app_id_value = key.change.after.tags.app-id else null
    
    if app_id_value is null {
        violatingappIDs[address] = key
        print("App-id is null or undefined")
    } else if app_id_value not in allowed_appID {
        violatingappIDs[address] = key
        print("App-id isnt in the allowed list")
    }
}
violations = length(violatingappIDs)
main = rule {
    violations is 0
}

The resource_changes block for the mock is as below:

resource_changes = {
	"azurerm_resource_group.example": {
		"address": "azurerm_resource_group.example",
		"change": {
			"actions": [
				"create",
			],
			"after": {
				"location": "eastus2",
				"name":     "practice-rg",
				"tags": {
					"app-id":  "1234",
					"bill_id": "567",
					"env":     "test",
				},
				"timeouts": null,
			},
			"after_unknown": {
				"id":   true,
				"tags": {},
			},
			"before": null,
		},
		"deposed":        "",
		"index":          null,
		"mode":           "managed",
		"module_address": "",
		"name":           "example",
		"provider_name":  "registry.terraform.io/hashicorp/azurerm",
		"type":           "azurerm_resource_group",
	},
}

Issue is, the condition should pass as the values are as per allowed values, but its failing on the first if condition.
What is strange is, if I used the same code logic to test the bill_id or env tags, policy results are as expected.
Issue is only with app-id tag.
Is the hyphen causing any problems?

Thanks in advance!

@JiJo333 I have taken a look at your code and written the following example which uses the new features in Sentinel 0.17 to simplify the policy logic. You can read more about these features in the following blog post.

I am however conscious that you may not be running the latest version of Sentinel, so in order to fix your code you will need to update the for loop so that you are accessing the element in the map via the key value :

for allRGs as address, key {
    app_id_value = key.change.after.tags["app-id"] else null 
    
    if app_id_value is null {
        violatingappIDs[address] = key
        print("App-id is null or undefined")
    } else if app_id_value not in allowed_appID {
        violatingappIDs[address] = key
        print("App-id isnt in the allowed list")
    }
}

Hope this helps :slight_smile:

1 Like

Thank you @hcrhall , it worked!
Appreciate your help :slight_smile:

@JiJo333 I wrote a couple of custom functions in case you wanted an alternate option:

##### Imports #####
import "tfplan/v2" as tfplan
import "tfplan-functions" as plan
import "strings"
import "types"

### filter_map_attribute_key_contains_items_in_list ###
# Filter a list of resources to those with a specified map
# attribute (attr) key (key) that contains items in a given list of forbidden values (forbidden).
# Resources should be derived by applying filters to tfplan.resource_changes.
# Set prtmsg to `true` (without quotes) if you want to print violation messages.
# If you want to allow null, include "null" in the list (forbidden).
filter_map_attribute_key_contains_items_in_list = func(resources, attr, key, forbidden, prtmsg) {
	violators = {}
	messages = {}
	for resources as address, rc {
		# Evaluate the value (vals) of the attribute
		vals = plan.evaluate_attribute(rc, attr) else null

		# Check if the attribute value is a map or is null.
		if not (types.type_of(vals) is "map" or types.type_of(vals) is "null") {
			# Add the resource and a warning message to the violators list
			message = "Resource '" + plan.to_string(address) + " has attribute '" + plan.to_string(attr) +
				"' with a value of '" +
				plan.to_string(vals) +
				"' that is not a map or null"
			violators[address] = rc
			messages[address] = message
			if prtmsg {
				print(message)
			}
		} else if vals is null {
			if "null" not in forbidden {
				# Add the resource and a warning message to the violators list
				message = "Resource '" + plan.to_string(address) + "' has attribute '" + plan.to_string(attr) +
					"' with a value of 'null' that is not in the forbidden list: " +
					plan.to_string(forbidden)
				violators[address] = rc
				messages[address] = message
				if prtmsg {
					print(message)
				}
			}
		} else {
			# Check if vals contains the desired key
			key_value = vals[key]
			key_exists = key_value else null
			if types.type_of(key_exists) is "null" {
				message = "Resource '" + plan.to_string(address) + "' has attribute '" + plan.to_string(attr) +
					"' that does not contain the key '" +
					plan.to_string(key) +
					"'"
				violators[address] = rc
				messages[address] = message
				if prtmsg {
					print(message)
				}
			} else {
				forbidden_values = []
				if types.type_of(key_value) not in ["list", "map"] {
					if key_value in forbidden {
						append(forbidden_values, key_value)
					}
				} else {
					for key_value as v {
						if v in forbidden {
							append(forbidden_values, v)
						} // end if v not forbidden
					} // end for vals
				}

				if length(forbidden_values) > 0 {
					# Build warning message when vals is a map
					message = "Resource '" + plan.to_string(address) + "' has attribute '" + plan.to_string(attr) +
						"' with key '" +
						plan.to_string(key) +
						"' containing items " +
						plan.to_string(forbidden_values) +
						" that are in the forbidden list: " +
						plan.to_string(forbidden)
					# Add the resource and a warning message to the violators list
					violators[address] = rc
					messages[address] = message
					if prtmsg {
						print(message)
					}
				} // end length(forbidden_values)
			}

		} // end if null
	} // end for
	return {"resources": violators, "messages": messages}
}

### filter_map_attribute_key_contains_items_not_in_list ###
# Filter a list of resources to those with a specified map
# attribute (attr) key (key) that contains items not in a given list of allowed values (allowed).
# Resources should be derived by applying filters to tfplan.resource_changes.
# Set prtmsg to `true` (without quotes) if you want to print violation messages.
# If you want to allow null, include "null" in the list (allowed).
filter_map_attribute_key_contains_items_not_in_list = func(resources, attr, key, allowed, prtmsg) {
	violators = {}
	messages = {}
	for resources as address, rc {
		# Evaluate the value (vals) of the attribute
		vals = plan.evaluate_attribute(rc, attr) else null

		# Check if the attribute value is a map or is null.
		if not (types.type_of(vals) is "map" or types.type_of(vals) is "null") {
			# Add the resource and a warning message to the violators list
			message = "Resource '" + plan.to_string(address) + " has attribute '" + plan.to_string(attr) +
				"' with a value of '" +
				plan.to_string(vals) +
				"' that is not a map or null"
			violators[address] = rc
			messages[address] = message
			if prtmsg {
				print(message)
			}
		} else if vals is null {
			if "null" not in allowed {
				# Add the resource and a warning message to the violators list
				message = "Resource '" + plan.to_string(address) + "' has attribute '" + plan.to_string(attr) +
					"' with a value of 'null' that is not in the allowed list: " +
					plan.to_string(allowed)
				violators[address] = rc
				messages[address] = message
				if prtmsg {
					print(message)
				}
			}
		} else {
			# Check if vals contains the desired key
			key_value = vals[key]
			key_exists = key_value else null
			if types.type_of(key_exists) is "null" {
				message = "Resource '" + plan.to_string(address) + "' has attribute '" + plan.to_string(attr) +
					"' that does not contain the key '" +
					plan.to_string(key) +
					"'"
				violators[address] = rc
				messages[address] = message
				if prtmsg {
					print(message)
				}
			} else {
				forbidden_values = []
				if types.type_of(key_value) not in ["list", "map"] {
					if key_value not in allowed {
						append(forbidden_values, key_value)
					}
				} else {
					for key_value as v {
						if v not in allowed {
							append(forbidden_values, v)
						} // end if v not allowed
					} // end for vals
				}

				if length(forbidden_values) > 0 {
					# Build warning message when vals is a map
					message = "Resource '" + plan.to_string(address) + "' has attribute '" + plan.to_string(attr) +
						"' with key '" +
						plan.to_string(key) +
						"' containing items " +
						plan.to_string(forbidden_values) +
						" that are not in the allowed list: " +
						plan.to_string(allowed)
					# Add the resource and a warning message to the violators list
					violators[address] = rc
					messages[address] = message
					if prtmsg {
						print(message)
					}
				} // end length(forbidden_values)
			}

		} // end if null
	} // end for
	return {"resources": violators, "messages": messages}
}

Then to call the functions, you would do something like:

# This policy requires that certain tags with valid values are present on given object types

import "custom-functions" as custom
import "tfplan-functions" as plan

# Map of resources that are required to have the specified mandatory name/value tags.
param resource_type_mandatory_tags default {
	"azurerm_resource_group": {
		"app-id": [
			"1234",
			"567",
		],
	},
}

# Loop through each object type, finding any resources that do not have a valid value for each mandatory tag.
resourceMissingTagsCount = 0
for resource_type_mandatory_tags as resourceType, mandatoryTags {
	for mandatoryTags as key, allowedValues {
		allResources = plan.find_resources(resourceType)
		resourcesWithoutValidTags = custom.filter_map_attribute_key_contains_items_not_in_list(allResources,
			"tags", key, allowedValues, true)
		resourceMissingTagsCount += length(resourcesWithoutValidTags["resources"])
	}
}

# Main rule
main = rule {
	resourceMissingTagsCount is 0
}

You’ll need to import the functions in your policy set:

module "custom-functions" {
  source = "../../../custom-functions/tfplan-functions.sentinel"
}

Thanks for posting your functions, @pshamus. I ended up adding modified versions of them to the new https://github.com/hashicorp/terraform-sentinel-policies repository that is replacing https://github.com/hashicorp/terraform-guides/tree/master/governance/third-generation. I probably should have given you an acknowledgement there. I just did that.

@rberlind Thanks, that’s awesome!

I tried switching to use your version of my functions and they fail. It seems like the logic is slightly different for null values. Curious why you switched it. My test condition is where the attribute tags is null.