Policy patterns for real modules
The getting started tutorial covers a single attribute on a single resource type. Real policy sets are harder for reasons that have nothing to do with policy language and everything to do with how modules and providers actually behave.
These are the patterns I ended up needing when guarding Azure Storage Accounts deployed through the Azure Verified Module Azure/avm-res-storage-storageaccount/azurerm. Each one exists because a simpler version of the policy was wrong.
Cover both resource shapes when a module changes its implementation
This is the one that will bite you, and it is silent when it does.
The AVM storage account module changed the resource it builds underneath. Version 0.8.0 creates the account with azapi_resource against Microsoft.Storage/storageAccounts@2025-06-01, which puts every setting under body.properties in ARM casing. Version 0.6.x and earlier used azurerm_storage_account, where the same settings are top-level attributes in Terraform casing.
| Control | AzAPI property | azurerm attribute |
|---|---|---|
| Minimum TLS | body.properties.minimumTlsVersion | min_tls_version |
| Public endpoint | body.properties.publicNetworkAccess | public_network_access_enabled |
| Data plane firewall | body.properties.networkAcls.defaultAction | network_rules[0].default_action |
| Anonymous blob access | body.properties.allowBlobPublicAccess | allow_nested_items_to_be_public |
A policy written for one shape does not error against the other. It simply never matches, and the run goes green.
Write both, in separate files, and keep them in sync:
policies/
storage_account_azapi.policy.hcl AVM 0.7.0 and later
storage_account_azurerm.policy.hcl AVM 0.6.x and earlier
storage_account_module.policy.hcl source and version pinning
Both files stay useful after you have upgraded every caller. The azurerm policy still catches storage accounts written by hand against the provider without the module, which is the case the module policy can never see.
If you are guarding a module you did not write, check which resource it builds before you write a line of policy. terraform plan against a minimal call is the fastest way to find out.
Filter to exactly the resource you mean
azapi_resource is a single Terraform resource type covering every ARM type, so a policy on it needs a filter. The obvious filter is wrong in a way that is easy to miss:
locals {
# The trailing "@" is load-bearing. It matches the storage account itself and
# excludes child types such as
# "Microsoft.Storage/storageAccounts/blobServices@2025-06-01".
storage_account_type_prefix = "Microsoft.Storage/storageAccounts@"
}
resource_policy "azapi_resource" "storage_account_deny_weak_tls" {
enforcement_level = "mandatory"
operations = ["create", "update"]
filter = core::startswith(attrs.type, local.storage_account_type_prefix)
enforce {
condition = core::contains(
input.allowed_min_tls_versions,
core::try(attrs.body.properties.minimumTlsVersion, "")
)
error_message = "Storage account '${attrs.name}' sets minimumTlsVersion to '${core::try(attrs.body.properties.minimumTlsVersion, "<unset>")}'. TLS 1.0 and TLS 1.1 are not permitted."
}
}
Without the @, the prefix matches Microsoft.Storage/storageAccounts/blobServices@2025-06-01 too. A blob service has no minimumTlsVersion, so core::try falls back to the empty string, the condition fails, and the policy blocks a resource it was never meant to look at.
Pin that behaviour with a mock that must pass:
resource "azapi_resource" "pass_child_resource_is_filtered_out" {
attrs = {
type = "Microsoft.Storage/storageAccounts/blobServices@2025-06-01"
name = "default"
parent_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-storage/providers/Microsoft.Storage/storageAccounts/stavmpass0001"
body = {
properties = {
deleteRetentionPolicy = {
enabled = true
days = 7
}
}
}
}
}
If somebody later loosens the filter, that mock starts failing and tells them why.
Fail closed when an attribute is absent
Decide explicitly what an unset attribute means, because the default behaviour of a naive condition is usually to let it through.
The fallback in core::try is the mechanism. Make it the insecure value so that absence fails:
enforce {
condition = core::try(attrs.public_network_access_enabled, true) == false
error_message = "Storage account '${attrs.name}' must set public_network_access_enabled = false. Use a private endpoint for access."
}
core::try(..., true) means an absent attribute is treated as public access enabled, which fails. Had the fallback been false, an omitted attribute would have passed and the control would only cover people who had already thought about it.
Two reasons this matters more than it looks:
Service-side defaults do not appear in the plan and can change between API versions. A policy that trusts an invisible default is trusting something it cannot pin.
Second, and specific to this module, setting network_rules = null removes networkAcls from the request entirely, which disables the firewall. A policy that treated an absent firewall as acceptable would wave that straight through. Test it directly:
resource "azapi_resource" "fail_network_acl_absent" {
expect_failure = true
attrs = {
type = "Microsoft.Storage/storageAccounts@2025-06-01"
name = "stavmaclnone01"
location = "eastus"
parent_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-storage"
body = {
properties = {
minimumTlsVersion = "TLS1_2"
publicNetworkAccess = "Disabled"
allowBlobPublicAccess = false
}
}
}
}
The cost of failing closed is that callers have to be explicit. For this module that costs nothing, because its defaults already match what the policies want.
Split enforce blocks so the error message is specific
One resource_policy can hold several enforce blocks. Use that instead of joining conditions with &&.
A storage account is reachable from the internet by three separate routes, and closing only the obvious one leaves the other two open:
resource_policy "azapi_resource" "storage_account_deny_public_network_access" {
enforcement_level = "mandatory"
operations = ["create", "update"]
filter = core::startswith(attrs.type, local.storage_account_type_prefix)
enforce {
condition = core::try(attrs.body.properties.publicNetworkAccess, null) == "Disabled"
error_message = "Storage account '${attrs.name}' must set publicNetworkAccess to 'Disabled' (AVM input: public_network_access_enabled = false). Use a private endpoint for access."
}
enforce {
condition = core::try(attrs.body.properties.networkAcls.defaultAction, null) == "Deny"
error_message = "Storage account '${attrs.name}' must set networkAcls.defaultAction to 'Deny' (AVM input: network_rules.default_action = \"Deny\"). Note that setting network_rules = null removes the firewall entirely and fails this policy."
}
enforce {
condition = core::try(attrs.body.properties.allowBlobPublicAccess, null) == false
error_message = "Storage account '${attrs.name}' must set allowBlobPublicAccess to false (AVM input: allow_nested_items_to_be_public = false) so containers cannot opt into anonymous public read."
}
}
Three blocks means three specific messages, and a caller who violates two of them sees both. Collapsed into one condition, they would get a single message that does not say which of the three is wrong.
Note what the messages do: they name the ARM property the policy inspects and the module input the caller actually sets. The person reading the error is editing a module block, not an ARM template. Telling them publicNetworkAccess must be Disabled sends them looking for a field that does not exist in their code.
Turning off the public endpoint while leaving the firewall on Allow is not a hardened account, and neither is one where any container can quietly opt into anonymous public read.
Read nested blocks as lists
On azurerm_storage_account, network_rules is a nested block rather than an attribute, so it arrives as a list:
enforce {
condition = core::try(attrs.network_rules[0].default_action, null) == "Deny"
error_message = "Storage account '${attrs.name}' must declare a network_rules block with default_action = \"Deny\"."
}
This mirrors the documented attrs.versioning[0].enabled pattern for nested blocks.
Be honest about what your tests prove here. Mocks are hand written, so a test confirming this expression works against a list-shaped input does not show that a real azurerm_storage_account plan emits network_rules that way. If you depend on this, check it against a real plan rather than trusting the mock.
Cover a setting that can be attached two ways
The storage firewall can be an inline block or a standalone resource. Guard both, or the arrangement you did not think of slips through:
resource_policy "azurerm_storage_account_network_rules" "deny_by_default" {
enforcement_level = "mandatory"
operations = ["create", "update"]
enforce {
condition = core::try(attrs.default_action, null) == "Deny"
error_message = "Storage account network rules must set default_action = \"Deny\". Grant access explicitly with ip_rules, virtual_network_subnet_ids, or private link."
}
}
This pattern generalises well beyond storage. Any time a provider offers both an inline block and a separate association resource, assume both are in use somewhere in the estate.
Pin the module source and a version floor
module_policy only exposes meta, which is the source, version, and address. Module input variables are not readable from a policy. So use it for what it can see:
module_policy "Azure/avm-res-storage-storageaccount/azurerm" "pinned_version" {
enforcement_level = "mandatory"
# 0.6.0 is the floor at which the module defaults to
# public_network_access_enabled = false, min_tls_version = "TLS1_2", and
# network_rules.default_action = "Deny".
enforce {
condition = core::try(core::semverconstraint(meta.version, ">= 0.6.0"), false)
error_message = "The AVM storage account module must be version 0.6.0 or higher. Version '${core::try(meta.version, "<unpinned>")}' predates the secure-by-default settings this policy set assumes."
}
enforce {
condition = core::try(meta.version, "") != "" && core::try(meta.version, null) != null
error_message = "The AVM storage account module at ${meta.address} must pin an explicit version. Pre-1.0 modules make breaking changes between minor versions."
}
}
Do not add an info_message to that block. HashiCorp's authoring reference is explicit that info_message belongs only in stub blocks where condition = true and no real enforcement is possible, and never in a block that can actually fail. The reason shows up the moment you run the tests: the info message fires on every evaluation, including the ones that are failing, so the log prints a neutral status line about a module the policy is in the middle of blocking. A pinning block that can fail should carry error_message alone.
The core::try wrapping core::semverconstraint is not decoration. An unpinned or malformed version makes semverconstraint raise an evaluation error, which surfaces as a broken policy rather than a clean policy failure. The try converts it into false, so the caller gets the error message you wrote.
An unpinned call is worth failing on its own. These modules are pre-1.0, where anything may change at any time, so terraform init -upgrade can pick up a breaking change with no diff in your repository.
Force consumers onto your fork
If you have forked a verified module and published it privately, the control you want is that consumers use your fork and not the upstream module, a second fork, or a vendored copy. module_policy targets exactly this, and it evaluates from meta.source and meta.version, which are available before the plan completes.
Targeting has one rule that will waste an afternoon if you miss it. The first label matches the full module source or is *. Substring matching does not work, so module_policy "storage" matches nothing at all, including modules with storage in the source.
That leaves two shapes. Target a specific source by writing it out in full:
module_policy "app.terraform.io/contoso/storage-account/azurerm" "approved_fork_version" {
enforcement_level = "mandatory"
enforce {
condition = core::try(core::semverconstraint(meta.version, ">= 2.1.0"), false)
error_message = "The internal storage module must be version 2.1.0 or higher. Version '${core::try(meta.version, "<unpinned>")}' predates the current control baseline."
}
}
Or match everything with * and narrow with a filter, which is how you catch the modules you did not expect:
module_policy "*" "storage_modules_must_come_from_the_internal_registry" {
enforcement_level = "mandatory_overridable"
filter = core::length(core::regexall("storage", core::lower(meta.source))) > 0
enforce {
condition = core::startswith(meta.source, "app.terraform.io/contoso/")
error_message = "Module ${meta.address} (source '${meta.source}') is storage-related but does not come from the internal registry at app.terraform.io/contoso/. Use the approved fork, or request an exception."
}
}
mandatory_overridable fits this better than mandatory. A team with a genuine reason to call something else gets a recorded override instead of a blocked pipeline and an argument, and you keep the audit trail.
The limit to understand before you build on this: module_policy cannot read module input variables. HashiCorp's authoring reference lists attrs.* on module policies as not accessible yet, marked work in progress during the beta. So you cannot write "fail if a consumer calls my fork with public_network_access_enabled = true" at the module layer. Catch it on the resource the fork produces, which is what every resource_policy on this page is doing.
Watch for this in tests specifically. Mock module blocks accept an attrs object, so a test that appears to inspect module inputs will pass while the real policy has nothing to read.
Never interpolate meta.address in a resource policy
meta.address is available in module_policy, where it gives the module address such as module.storage. Every module_policy example on this page uses it in an error message, which is fine.
It is undefined in resource_policy. Interpolating it there throws Error: Unsupported attribute at runtime, for every resource the policy evaluates.
The reason this deserves its own heading is the failure mode. tfpolicy test does not catch it. Your mocks pass, the policy set validates, and the error only appears when the policy meets a real plan. Keep resource policy error messages to static strings and attrs.* interpolation:
# Safe in a resource_policy.
error_message = "Storage account '${attrs.name}' must set public_network_access_enabled = false."
Warn on lookalike modules
Anything that looks like a storage module but is not the verified one is probably a fork or a copy, and its defaults are unknown to your policy set. Use a wildcard source with a filter, at advisory:
module_policy "*" "prefer_verified_storage_module" {
enforcement_level = "advisory"
filter = core::length(core::regexall("storage", core::lower(meta.source))) > 0
enforce {
condition = core::startswith(meta.source, "Azure/avm-res-storage-storageaccount/azurerm")
error_message = "Module ${meta.address} (source '${meta.source}') looks storage-related but is not the Azure Verified Module 'Azure/avm-res-storage-storageaccount/azurerm'. Prefer the verified module, or confirm this wrapper sets public_network_access_enabled = false and min_tls_version = \"TLS1_2\"."
}
}
Advisory rather than mandatory, because internal wrapper modules around AVM are a legitimate pattern and blocking them would be wrong. The wrapper's inner resources are still caught by the resource policies, which is the reason this can safely stay a warning.
If you want the wrapper to be a deliberate decision rather than an accident, mandatory_overridable is the middle setting: the run halts, and someone with the permission to override signs their name against it.
Remember that an advisory policy whose condition fails is still recorded as a failure. The enforcement level governs whether the run is blocked, not whether the policy is marked as failing. A mock that trips this policy still asserts expect_failure = true.
Keep input names unique across files
tfpolicy loads every file under --policies together and reads overrides from a flat TFPOLICY_INPUT_<name> namespace. Two files declaring the same input name are ambiguous.
So the azapi policy declares allowed_min_tls_versions and the azurerm policy declares allowed_min_tls_versions_azurerm. The duplication is ugly and it is the safe choice. One input per resource shape, named for the shape.
That gives you a per-shape override at run time, which is how you add TLS 1.3 once it is available in your regions without touching a condition:
export TFPOLICY_INPUT_allowed_min_tls_versions='["TLS1_2","TLS1_3"]'
Keep non-compliant examples out of reach of CI
If you keep a deliberately non-compliant configuration as reference material, put it in a directory no pipeline job points at:
examples/
compliant/ passes every policy, the only thing CI ever plans or applies
non-compliant/ reference only, shows what a violation looks like
The non-compliant configuration in my repository would build a storage account open to the internet, accepting TLS 1.1, with anonymous blob reads allowed. Keeping it out of the applyable root module means no job can reach it by accident. Put a comment at the top of the file saying so, because the next person to touch it will not have read this page.