Skip to main content

About the Terraform policy framework

HashiCorp has been shipping a new policy framework for Terraform, written in HCL and driven by a CLI called tfpolicy. It is in beta at the time of writing, and HashiCorp advises against running beta features in production. I have been testing it against Azure Storage Accounts to work out where it actually belongs, and this page is what I concluded.

The short version: it moves a guardrail from the cloud control plane into the repository that produces the change, and it does that in the language the configuration is already written in. That is worth something. It does not replace Azure Policy, and at 0.1.0 it does not yet do the one thing most people assume it does.

Why HCL matters more than it sounds like it should

Policy for Terraform has existed for years. Sentinel is HashiCorp's own, and Open Policy Agent with Rego is the common open-source route via Conftest. Both work. Both have the same practical problem: the policy is written in a language nobody on the team writes anything else in.

That sounds like a minor tax until you watch it play out. A platform team writes the Rego. The application teams cannot read it, so when a policy fires they do not debug it, they raise a ticket. The policy set drifts because the only people who can safely change it are the two people who learned Rego, and one of them has left. I have inherited that exact situation, and the policies had been switched to warn-only eighteen months earlier because nobody could work out whether a change would break a deploy.

tfpolicy policies are HCL. They use input blocks, locals, string interpolation, and a set of core:: functions that behave like the Terraform functions of the same name. Anyone who can write a module can read a policy, and more usefully, can write the test that proves it works.

Here is a whole working policy, which is the argument in condensed form:

resource_policy "azurerm_storage_account" "deny_weak_tls" {
enforcement_level = "mandatory"
operations = ["create", "update"]

enforce {
condition = core::contains(
input.allowed_min_tls_versions_azurerm,
core::try(attrs.min_tls_version, "")
)
error_message = "Storage account '${attrs.name}' sets min_tls_version to '${core::try(attrs.min_tls_version, "<unset>")}'. TLS 1.0 and TLS 1.1 are not permitted."
}
}

There is no JSON plan representation to reason about, no separate toolchain, no second language. A Terraform engineer can review that in a pull request without being taught anything first.

The problem it is actually for

The case I keep running into is shared modules across teams.

A platform team publishes a storage account module with hardened defaults. That is genuinely good work, and it is also not a control. It is a suggestion. A consumer can override the defaults, pin an old version from before the defaults were hardened, fork the module, or skip it entirely and write the resource by hand. None of those are hypothetical. All four have happened on estates I have worked on.

The usual answer is Azure Policy, and it is the right answer for the estate as a whole. But it enforces at the Azure control plane, which means the failure arrives during terraform apply, as a provider error, partway through a run that has already created a resource group and half a network. The engineer who hits it gets an ARM error code and a stack of state to clean up, at the point where they are least receptive to learning about the control.

Cloud-side policy is the backstop. It catches everything, including resources created in the portal by someone who has never opened Terraform, and that is exactly why it has to stay. What it is bad at is telling a developer, early, in terms they can act on, that the thing they just wrote will be rejected.

tfpolicy is the other half of that pair. The same rule, expressed a second time, living next to the module in source control, running before anything is built. When the two agree, an engineer finds out at review time instead of at apply time.

The cost is that the rule now exists in two places and can drift. I do not have a clean answer to that. What I do is treat the cloud policy as authoritative and the tfpolicy version as a fast mirror of it, and I put both in the same pull request when either changes. That is a discipline, not a mechanism, which means it will fail eventually.

Forked and in-house modules, and what actually travels with them

Most enterprises I work with do not consume Azure Verified Modules as published. They fork them, adjust inputs and outputs, sometimes swap or add resources to fit internal controls, and publish the result to a private registry as the thing internal consumers are required to use. That is the normal shape of a mature platform team, and it is exactly the situation where this framework earns its place.

It is worth being precise about why, because the obvious mental model is wrong in a way that changes how you build it.

The intuitive version goes: the policy is embedded in the module, so when a consumer calls the module they get the policy too, and the check runs on their call. That is not how tfpolicy distributes. Policies live in their own directory, .policy.hcl files under a policies/ folder, hosted in a version control repository connected to HCP Terraform and registered there as a policy set. Policy sets are then scoped globally, to projects, to workspaces, or to workspaces carrying particular tags, with the option to exclude specific ones. Evaluation happens on the run in the workspace, against the plan.

So the module and the policy set are two artifacts on two distribution channels. The consumer gets the module from the registry. They get the policy from the workspace they happen to be running in. Nothing about calling a module pulls a policy along with it.

That distinction has a practical consequence: a consumer who runs Terraform outside HCP Terraform gets none of your policy by default. The tfpolicy CLI can validate and test policies, but it cannot evaluate a plan, and terraform plan has no flag that hands it a policy directory.

It turns out you can build your way around that, and I would rather correct this than leave the earlier version standing. Converting a plan into mocks with terraform show -json and feeding those to tfpolicy test gives you a real gate in your own pipeline, with no HCP Terraform involved. Gate a Terraform plan with tfpolicy without HCP Terraform has the harness. It is a few hundred lines of Python, not a platform migration.

What remains true is narrower. The gate covers resource policies only, since it is built from resource_changes, so module and provider policies still need the platform. And anyone applying from a laptop, outside any pipeline, is outside every version of this. Azure Policy is the control that still reaches them.

What survives the correction is the reason forked modules are the strongest case for tfpolicy, and there are three separate mechanisms doing the work.

module_policy pins approved sources and versions. This is the one that maps directly onto forking. You can require that any storage module comes from your fork's registry address, and refuse the upstream module, an unapproved second fork, or a copy someone vendored into their repo. Source and version are available before the plan completes, so these evaluate at the pre-plan stage and can stop a module from being used at all.

resource_policy catches the resources whatever produced them. This is the backstop that makes forking survivable. When a fork drifts, or a team adjusts a resource inside their copy, or somebody skips modules entirely and writes the resource by hand, the resource policy still sees the storage account. The module was never the thing worth guarding. The resource was.

input blocks are overridable per policy set. One policy library, tuned per environment, without maintaining divergent copies of the conditions. This is what lets a control be strict in production and looser in a sandbox without forking the policy the way you forked the module.

There is a gap that lands directly on this plan, and it is the one to know about before you commit. module_policy cannot read module input variables. HashiCorp's own authoring reference lists attrs.* for module policies as not accessible yet, marked work in progress for the beta. So the check you most want to write, "if a consumer calls my fork with public_network_access_enabled = true, stop them", cannot be expressed at the module layer. You have to catch it on the resource the fork produces.

This trips people up because mock module blocks in test files happily accept an attrs object, so you can write a test that appears to inspect module inputs and watch it pass.

The closest thing to the embedded model is to treat the module and its policy set as one release: same review, same version bump, and workspace provisioning that attaches the policy set at creation time so no consumer has to opt in. If you already do subscription or workspace vending, that is where the attachment belongs. It is a weaker guarantee than policy travelling inside the module package, and it is the strongest one available in the beta.

Where the resource policy sits, and why

My first instinct was to write a module_policy against the Azure Verified Module and inspect its input variables. That does not work, and the way it does not work is worth knowing about.

A module_policy only exposes meta, which is the module source, version, and address. Module input variables are not readable from inside a policy. The reason this catches people is that mock module blocks in test files happily accept an attrs object, so you can write a test that looks like it inspects module inputs and watch it pass.

So module_policy does the narrower job it suits: pinning the module source and refusing versions below a floor. The real checks live in resource_policy blocks, against the resources the module produces. That turns out to be the better placement anyway, because a resource policy catches the storage account somebody wrote by hand without the module at all. The module was never the thing worth guarding. The resource was.

The trap that made me write the tests first

The Azure Verified Module for storage accounts changed the resource it builds underneath. Version 0.8.0 creates the account with azapi_resource against Microsoft.Storage/storageAccounts@2025-06-01, so every setting lives under body.properties in ARM casing: minimumTlsVersion, publicNetworkAccess. Version 0.6.x and earlier used azurerm_storage_account, where the same settings are top-level attributes in Terraform casing: min_tls_version, public_network_access_enabled.

A policy written for one shape does not fail against the other. It never matches, and the run goes green.

That is a worse failure than a loud error. A policy that blocks a valid deploy gets fixed within the hour, because someone is standing over it. A policy that silently matches nothing can sit there for a year while everyone believes the control is on. If you take one thing from this framework, take the habit of writing a mock that is supposed to fail, and confirming it does.

What 0.1.0 does not do

The tfpolicy CLI ships three subcommands: validate, test, and version. HashiCorp's own CLI documentation describes it as a tool for validating and testing policies locally, and nothing more. There is no command that takes a terraform plan and judges it, and terraform plan itself has no policy flag to hand one a policy directory.

This matters because of what people assume when they see a policy job go green in CI. That job is not a deployment gate. It proves the policies parse and that their tests still describe the behaviour they claim to. Enforcement against a real plan happens in HCP Terraform, once the repository is attached to a workspace as a policy set.

If you read the CI policy job as a gate, you will believe you are protected when you are not. I would rather say that plainly than ship a green badge that means less than it looks like it means.

Enforcement levels, and one surprise

Policies carry an enforcement_level, and there are three of them rather than the two I assumed when I started.

LevelEffect on the run
advisoryNever interrupts the run. The failure is reported in the UI.
mandatory_overridableHalts the run, but a user with the right permission can override and continue.
mandatoryHalts the run. It is the default.

mandatory_overridable is the one I had missed, and it is more useful than it sounds. The gap between "warn and be ignored" and "block and generate a ticket" is where most real controls live. A break-glass path that records who overrode what is a better answer than an advisory policy nobody reads, and it means you can set a control hard on day one without booking an outage for the first legitimate exception.

The surprise, which the test run settled for me, is 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. That is worth internalising before you write your tests, because it means a mock tripping an advisory policy still asserts expect_failure = true, which reads wrong the first few times you see it.

I use advisory the same way I use audit in Azure Policy: as the rollout stage before a control becomes hard, and as the permanent setting for things that are preferences rather than requirements. An internal wrapper module around a verified module is a legitimate pattern, so the policy that would rather you used the verified module directly is advisory forever.

Would I run it

Not in production, because it is beta and HashiCorp says not to, and I have no appetite for arguing with that after an incident.

What I would do, and have done, is build the policy set now against a real module, keep it in CI as a correctness check on the policies themselves, and have it ready for when the framework goes GA. The work of deciding what the controls are and proving they fire is the slow part. The wiring is the fast part.

I also find I write better Azure Policy after writing the tfpolicy version, because the test harness forces me to enumerate the cases where a resource can be non-compliant. Three separate routes make a storage account publicly reachable, and I had been closing one of them for years while thinking about it as one control.