Write and test your first tfpolicy policy
By the end of this you will have the tfpolicy CLI installed, one policy that rejects a storage account accepting TLS 1.1, a test suite that proves it fires, and a demonstration that the test harness is doing real work rather than passing by default.
Everything here runs locally. No Azure subscription, no credentials, and nothing gets deployed.
Before you start
You need Terraform 1.16 or later, which is where the policy framework integration landed. Check what you have:
terraform version
If that reports anything below 1.16, install a newer version before continuing. At the time of writing 1.16 is itself in beta, so you will be installing something like 1.16.0-beta2.
The commands below use bash and download the Linux build. On Windows, run them in Git Bash and substitute windows_amd64 in the archive name. On an Apple Silicon Mac, substitute darwin_arm64. Nothing else changes.
Step 1: install tfpolicy
The binary is published at releases.hashicorp.com/tfpolicy. Download the archive, verify it against the published checksums, and put it on your PATH:
curl -fsSL -o tfpolicy_0.1.0_linux_amd64.zip https://releases.hashicorp.com/tfpolicy/0.1.0/tfpolicy_0.1.0_linux_amd64.zip
curl -fsSL -o SHA256SUMS https://releases.hashicorp.com/tfpolicy/0.1.0/tfpolicy_0.1.0_SHA256SUMS
sha256sum -c --ignore-missing SHA256SUMS
That last command prints an OK line for the archive you downloaded. If it prints FAILED, stop and download again. Do not skip this step because the tool is beta and you are only testing. Getting into the habit on a throwaway install is the point.
Unzip it and put it somewhere on your PATH:
unzip -q tfpolicy_0.1.0_linux_amd64.zip
sudo install -m 0755 tfpolicy /usr/local/bin/tfpolicy
Confirm it runs:
tfpolicy version
You should see 0.1.0. This version ships exactly three subcommands: validate, test, and version.
Step 2: create the layout
tfpolicy takes a directory of policies and a directory of tests as separate arguments, so keep them apart from the start:
mkdir -p tfpolicy-tutorial/policies tfpolicy-tutorial/tests && cd tfpolicy-tutorial
Policy files end in .policy.hcl. Test files end in .policytest.hcl.
Step 3: write the policy
Create policies/storage.policy.hcl with this content:
input "allowed_min_tls_versions" {
type = list(string)
description = "Minimum TLS versions accepted on a storage account."
default = ["TLS1_2"]
}
resource_policy "azurerm_storage_account" "deny_weak_tls" {
enforcement_level = "mandatory"
operations = ["create", "update"]
enforce {
condition = core::contains(
input.allowed_min_tls_versions,
core::try(attrs.min_tls_version, "")
)
error_message = "Storage account '${attrs.name}' sets min_tls_version to '${core::try(attrs.min_tls_version, "<unset>")}'. Use one of: ${core::join(", ", input.allowed_min_tls_versions)}."
}
}
Four things in there are worth naming before you run it.
resource_policy "azurerm_storage_account" binds the policy to a Terraform resource type. Every resource of that type in a configuration is evaluated against it.
enforcement_level = "mandatory" means a violation stops the run, and it is the default. The other two levels are mandatory_overridable, which stops the run but lets an authorised user override and continue, and advisory, which reports the failure without interrupting anything.
core::try(attrs.min_tls_version, "") falls back to an empty string when the attribute is not set. An empty string is not in the allow-list, so an unset value fails. That is deliberate. A service-side default does not appear in the plan and can change between API versions, so a policy that trusts one is trusting something it cannot see.
attrs is the resource's attributes. The input value is read as input.<name>.
Step 4: check that it parses
tfpolicy validate --policies=policies/
This reports the policy set as valid. It checks syntax and structure, not behaviour, so a policy that matches nothing at all still passes here. That is the gap the next step closes.
Step 5: write the tests
Create tests/storage.policytest.hcl:
policytest {
targets = ["../policies/storage.policy.hcl"]
}
# A compliant account. No expect_failure, so this mock must pass every policy.
resource "azurerm_storage_account" "pass_tls_1_2" {
attrs = {
name = "sttutorialpass01"
location = "eastus"
resource_group_name = "rg-tutorial"
min_tls_version = "TLS1_2"
}
}
# The named requirement: TLS 1.1 must be rejected.
resource "azurerm_storage_account" "fail_tls_1_1" {
expect_failure = true
attrs = {
name = "sttutorialtls11"
location = "eastus"
resource_group_name = "rg-tutorial"
min_tls_version = "TLS1_1"
}
}
# An omitted min_tls_version fails closed rather than trusting the service default.
resource "azurerm_storage_account" "fail_tls_unset" {
expect_failure = true
attrs = {
name = "sttutorialunset"
location = "eastus"
resource_group_name = "rg-tutorial"
}
}
The policytest block points at the policy file under test, with a path relative to the test file. Each resource block is a mock: a hand-written set of attributes standing in for what a real plan would produce.
expect_failure = true inverts the assertion. That mock passes when the policy fails against it.
Step 6: run the tests
tfpolicy test --policies=policies/ --tests=tests/
All three mocks report pass, and the command exits zero.
Two of those three passed because the policy failed against them, which reads oddly the first time. pass here means "the policy did what the test said it would", not "the resource is compliant".
Step 7: prove the harness is doing work
Three green checks are worth nothing if the harness is quietly evaluating nothing. Satisfy yourself that it is not.
Open tests/storage.policytest.hcl and delete this line from the fail_tls_1_1 mock:
expect_failure = true
Run the tests again:
tfpolicy test --policies=policies/ --tests=tests/
That mock now flips to fail, the command exits non-zero, and the output includes the condition trace showing the value it compared. That trace is the thing you will read most often when debugging a policy that is not firing.
Put the line back and re-run to get to green again.
Do this on every policy set you write. The failure mode that costs you is not a policy that blocks a good deploy. It is a policy whose filter never matches anything, sitting green for a year while everyone believes the control is on.
Step 8: override the input without editing the policy
The allowed versions are an input rather than a hardcoded string, so they can be changed at run time. Overrides are read from environment variables named TFPOLICY_INPUT_<name>:
export TFPOLICY_INPUT_allowed_min_tls_versions='["TLS1_2","TLS1_3"]'
tfpolicy test --policies=policies/ --tests=tests/
Still green: TLS1_2 remains in the allow-list, so the compliant mock passes, and TLS1_1 is still absent from it, so the failing mocks still fail.
Now narrow it to something none of the mocks use:
export TFPOLICY_INPUT_allowed_min_tls_versions='["TLS1_3"]'
tfpolicy test --policies=policies/ --tests=tests/
The pass_tls_1_2 mock now fails, because TLS1_2 is no longer permitted. That is the input reaching the policy.
Clear it before you move on:
unset TFPOLICY_INPUT_allowed_min_tls_versions
One consequence of that flat TFPOLICY_INPUT_ namespace: tfpolicy loads every file under --policies together, so two policy files declaring the same input name are ambiguous. Give each one a distinct name.
What you have now
A policy set that parses, a test suite that proves the policy fires on the cases you care about, and a check that the suite is not passing vacuously.
What you do not have yet is a deployment gate. tfpolicy 0.1.0 has no command that takes a terraform plan and judges it, so nothing you have built here blocks an apply on its own. Two routes from here: attach the policy set to a workspace in HCP Terraform, or build the gate yourself from terraform show -json as in Gate a Terraform plan with tfpolicy without HCP Terraform. For where each belongs in a pipeline, see Fit tfpolicy into a multi-stage Terraform pipeline.