Why Your `tfpolicy` `deny` Rule Is Silently Failing in Production (And How to Debug It)
`tfpolicy` `deny` rules can fail silently, letting non-compliant resources through. Discover the hidden `tfpolicy` evaluation order gotchas.
You wrote a tfpolicy rule to deny all public S3 buckets. The policy passed validation, you merged it, and then a junior engineer provisioned a public bucket from a community module. Nothing blocked it, and you’ve got a public bucket in production.
TL;DR: Your HCP Terraform
denyrules are likely failing silently for resources inside modules because the policy evaluation context is not what you expect. Simple resource type checks are bypassed when resources are nested. This post shows you how to trace the evaluation context and write robust policies that correctly target resources regardless of module depth.
What you’ll walk away with:
- The ability to diagnose why a
denyrule isn’t firing on module-based infrastructure. - A clear understanding of the
tfplanpolicy context for nested resources. - A copy-pasteable, corrected policy that blocks resources at any module depth.
- A debugging workflow using local Terraform commands to validate policies before merging.
What Does a Failing tfpolicy deny Rule Look Like?
The failing setup involves a seemingly correct policy and standard Terraform code that uses a module. The policy is intended to block any aws_s3_bucket resource with a public ACL, but it doesn’t trigger when that bucket is created inside a module, leading to a silent compliance failure.
Here’s a typical scenario. Your team uses a common module to create an S3 bucket for logging.
1
2
3
4
5
6
7
8
# main.tf
module "app_logs" {
source = "terraform-aws-modules/s3-bucket/aws"
version = "4.1.2"
bucket = "my-app-logs-do-not-make-public"
acl = "public-read" # This should be blocked!
}
And here’s the policy you wrote in your HCP Terraform policy set to prevent this exact mistake. It looks straightforward: find all S3 buckets and check their ACL.
1
2
3
4
5
6
7
8
9
10
11
# policies/deny-public-s3.hcl
import "tfplan/v2" as tfplan
# Rule: Deny any aws_s3_bucket with a public-read or public-read-write ACL
main = rule {
# This condition is the source of the failure
all tfplan.resource_changes as _, resource_change {
resource_change.type is "aws_s3_bucket" and
resource_change.change.after.acl in ["public-read", "public-read-write"]
}
}
You apply the Terraform code, expecting HCP Terraform to halt the run with a policy violation. Instead, the plan succeeds, and the public bucket is created.
1
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
No error, no warning. Just a silent failure of governance.
Your policy is only as good as the context it’s evaluated against. If the context doesn’t match your assumptions, the rule is dead code.
Why Does the Seemingly Correct deny Rule Not Trigger?
The rule fails because when Terraform plans a module, the policy evaluation context sees the module itself as the primary resource change, not the individual resources inside the module. Your policy is checking resource_change.type for "aws_s3_bucket", but the only top-level resource change it sees is for a resource of type "module".
The policy evaluation context is the structured data, derived from the Terraform plan, that the policy engine makes available for your rules to inspect. With Terraform 1.9, this context clearly distinguishes between a module call and the resources that call instantiates.
The policy engine iterates through tfplan.resource_changes at the root of the plan. In our case, the list contains an object that looks like this:
graph TD
A["tfplan.resource_changes"] -- "contains" --> B{resource_change};
B -- "address" --> C["module.app_logs"];
B -- "type" --> D["module"];
B -- "NOT type" --> E["aws_s3_bucket"];
Your all expression iterates this collection. It picks up the module.app_logs change, checks if resource_change.type is "aws_s3_bucket", which evaluates to false, and moves on. The rule never inspects the resources within the module, so the condition on the bucket’s acl is never even evaluated.
Click to see a simplified tfplan.json snippet
Here’s what the resource_changes array passed to the policy engine actually contains. Notice the type is module and the address is the module’s address, not the S3 bucket’s.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"resource_changes": [
{
"address": "module.app_logs",
"module_address": "",
"mode": "managed",
"type": "module",
"name": "app_logs",
"change": {
"actions": [
"create"
],
"before": null,
"after": {
"source": "terraform-aws-modules/s3-bucket/aws",
"version": "4.1.2"
},
"after_unknown": {}
}
}
]
}
This is a fundamental concept detailed in the official tfpolicy block documentation, but it’s an easy detail to miss. If your organization relies on modules for standardization, nearly all of your simple policies are probably ineffective.
Always assume a resource will be deployed via a module. Never write a policy that only works on root-level resources.
How Do You Reliably Block Resources Inside Modules?
To fix this, you must write your policy to explicitly find and inspect all resources of a given type, no matter how deeply nested they are in modules. The tfplan import provides the find_resources function specifically for this purpose. It traverses the entire plan and returns only the resources matching the specified type.
Here is the corrected policy.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
--- a/policies/deny-public-s3.hcl
+++ b/policies/deny-public-s3.hcl
@@ -1,9 +1,10 @@
import "tfplan/v2" as tfplan
+# Find all S3 bucket resources, regardless of module depth
+all_s3_buckets = tfplan.find_resources("aws_s3_bucket")
+
# Rule: Deny any aws_s3_bucket with a public-read or public-read-write ACL
main = rule {
- # This condition is the source of the failure
- all tfplan.resource_changes as _, resource_change {
- resource_change.type is "aws_s3_bucket" and
- resource_change.change.after.acl in ["public-read", "public-read-write"]
+ all all_s3_buckets as _, bucket {
+ bucket.change.after.acl in ["public-read", "public-read-write"]
}
}
This version is robust. The tfplan.find_resources("aws_s3_bucket") function does the heavy lifting, looking inside every module in the plan and returning a collection of every single aws_s3_bucket resource change. The main rule then iterates over this correctly filtered collection. It no longer matters if the bucket is in the root module or nested five modules deep; find_resources will locate it.
Use
tfplan.find_resources("resource_type")instead of iteratingtfplan.resource_changeswhen your rule targets a specific resource type. It’s the idiomatic and correct way.
How Can I Debug Policy Evaluations Myself?
You can and should debug policy logic locally before pushing it to HCP Terraform. The key is to generate a local plan file and then convert it to the exact JSON representation that the policy engine uses. This lets you inspect the data structure your policy will run against.
-
Generate a plan file: Run
terraform planwith the-outflag to save the binary plan file.1
terraform plan -out=plan.out
-
Convert the plan to JSON: Use
terraform showto convert the binary plan into the JSON format needed for inspection.1
terraform show -json plan.out > plan.json
Inspect the JSON: Open
plan.jsonin your editor. This file contains the full context, including theresource_changesarray. You can now manually trace your policy’s logic against the actual data and see exactly why a condition likeresource_change.type is "aws_s3_bucket"would fail. While you’re governing infrastructure, don’t forget about securing your state itself, a topic we cover in our OpenTofu Encryption at Rest guide.
Here’s a quick checklist for developing a new tfpolicy:
-
Does the policy use
tfplan.find_resourcesfor type-specific rules? - Have I tested the policy against a resource defined in the root module?
- Have I tested the policy against a resource defined inside at least one level of module nesting?
-
Have I run
terraform show -jsonlocally to inspect the plan structure and confirm my assumptions about the available data?
Following this process turns debugging from a black-box guessing game in the HCP Terraform UI into a predictable, local workflow.
Never merge a policy you haven’t tested against a local
plan.jsonfile first.
Bottom Line
Stop writing naive policies that only check the top-level tfplan.resource_changes. This practice is guaranteed to create security blind spots as soon as your team adopts modules. Default to using tfplan.find_resources() for any rule targeting a specific resource type to ensure your governance applies across your entire infrastructure, not just the parts in the root module.
Next, we’ll dive into creating dynamic policies that can change their behavior based on HCP Terraform workspace tags, allowing you to enforce stricter rules for production environments.
FAQ
What is the difference between tfpolicy and Sentinel?
tfpolicy uses HCL, the same language as Terraform, for policy as code and is integrated directly into HCP Terraform. Sentinel is HashiCorp’s broader policy as code framework, using its own language, and can be used with Terraform, Vault, Consul, and Nomad. For Terraform-specific governance in HCP Terraform, tfpolicy is the modern, simpler choice.
Can I test tfpolicy rules locally without HCP Terraform?
No, not directly. The tfpolicy engine is part of the HCP Terraform/Terraform Enterprise run environment. The recommended local workflow is to generate a JSON plan with terraform show -json plan.out and manually inspect it to validate your policy’s logic before committing.
Where can I find the schema for the tfplan/v2 data?
The tfplan/v2 import’s structure is documented on the official HashiCorp Terraform documentation website. It mirrors the structure of the JSON output from terraform show -json <planfile>, which is the most reliable way to see the exact data for your specific plan.
Does tfplan.find_resources work with data sources too?
No, tfplan.find_resources is specifically for managed resources (resource blocks). To inspect data sources, you would iterate through tfplan.data_source_changes and check the type attribute.
What happens if a resource is modified instead of created?
The tfplan.find_resources function works for all change types: create, update, and delete. The returned resource_change object contains a change block with before and after attributes, allowing you to write rules that trigger on specific modifications, like an S3 bucket’s ACL changing to public-read.
Part of the series: tf-policy-hcl
- The `tfpolicy` Block No One Reads That Controls Your Entire Cloud Governance
- Why Your `tfpolicy` `deny` Rule Is Silently Failing in Production (And How to Debug It) (you are here)
- Manual Policy Reviews vs. Automated `tfpolicy` Guardrails: Accelerate Compliance by 90%
🚀 Ready to get hands-on? Spin up an interactive AI or Kubernetes Sandbox at Aicademy Labs for free.
