OPA policy for OpenTofu and Terraform plans, with Rego examples
Policy as code catches risky infrastructure changes before anyone approves them. Here are working Rego policies for OpenTofu and Terraform plans, how to test them, and how they compare with Sentinel.
Why check plans with policy
Code review catches a lot, but reviewers miss things in a 400-line plan. A policy runs the same checks on every plan, every time: no missing cost tags, no public buckets, no accidental database deletes. Running it before approval means the reviewer sees the result next to the diff.
Sentinel vs OPA
Terraform Cloud's original policy language is Sentinel, a HashiCorp language that runs only inside HashiCorp products. Open Policy Agent (OPA) is a graduated Cloud Native Computing Foundation project. Its language, Rego, is also used for Kubernetes admission, API authorization, and CI checks.
- Portability. Rego policies run anywhere OPA runs, including your laptop and CI.
- Testing.
opa testruns unit tests written in Rego itself. - Hiring. Rego skills transfer across tools; Sentinel skills don't.
Moving off Terraform Cloud means rewriting Sentinel policies in Rego. Most are short, and the examples below cover the common ones.
What a policy sees
A plan policy receives the planned resource changes as JSON, in the shape tofu show -json produces. Each change has an address, a provider_name, and a change with its actions and the before and after values:
{
"address": "aws_s3_bucket.assets",
"provider_name": "registry.opentofu.org/hashicorp/aws",
"change": {
"actions": ["create"],
"before": null,
"after": { "bucket": "acme-assets", "tags": { "owner": "web" } }
}
}On StackShip, sensitive values are replaced with null, and policies never see state, variables, or credentials.
Example: require tags on new resources
StackShip policies define decision in the package stackship.v1. Each finding needs a unique id, so include the resource address in it.
package stackship.v1
import rego.v1
decision := {"findings": findings}
required_tags := {"owner", "cost-center"}
tag_keys(after) := {key | some key, _ in after.tags} if is_object(after.tags)
tag_keys(after) := set() if not is_object(after.tags)
findings contains finding if {
some change in input.resource_changes
some action in change.change.actions
action in {"create", "update"}
"tags" in object.keys(change.change.after)
missing := required_tags - tag_keys(change.change.after)
count(missing) > 0
finding := {
"id": sprintf("required-tags:%s", [change.address]),
"title": "Missing required tags",
"message": sprintf("Add tags: %s.", [concat(", ", sort(missing))]),
"resource_address": change.address,
}
}The "tags" in object.keys(...) line limits the rule to resources that support tags at all.
Example: stop accidental database deletes
A replace shows up as both delete and create, so this also catches changes that would recreate a database. Put it in a second file in the same package:
package stackship.v1
import rego.v1
protected := `(^|\.)(aws_db_instance|aws_rds_cluster|google_sql_database_instance|azurerm_mssql_database)\.`
findings contains finding if {
some change in input.resource_changes
"delete" in change.change.actions
regex.match(protected, change.address)
finding := {
"id": sprintf("protected-delete:%s", [change.address]),
"title": "Database would be deleted",
"message": sprintf("%s would be destroyed or replaced.", [change.address]),
"resource_address": change.address,
}
}Attach this one as soft mandatory. A planned delete then blocks approval until an owner or admin overrides it with a written justification, which lands in the audit log.
Test policies with opa test
Put tests in *_test.rego files next to the policies:
package stackship.v1_test
import rego.v1
import data.stackship.v1 as policy
test_database_delete_is_flagged if {
result := policy.decision with input as {"resource_changes": [{
"address": "module.db.aws_db_instance.main",
"provider_name": "registry.opentofu.org/hashicorp/aws",
"change": {"actions": ["delete"], "before": {}, "after": null},
}]}
count(result.findings) == 1
}
test_tagged_bucket_passes if {
result := policy.decision with input as {"resource_changes": [{
"address": "aws_s3_bucket.assets",
"provider_name": "registry.opentofu.org/hashicorp/aws",
"change": {"actions": ["create"], "before": null, "after": {
"tags": {"owner": "web", "cost-center": "42"},
}},
}]}
count(result.findings) == 0
}opa check --strict .
opa test .StackShip runs both commands when you publish a policy version and rejects the version if either fails.
Choose an enforcement mode
| Mode | When the policy returns findings | Good for |
|---|---|---|
| Advisory | Shown on the run; the run continues. | Rolling out a new rule. |
| Soft mandatory | Approval is blocked until someone overrides it. | Rules with rare, legitimate exceptions. |
| Hard mandatory | The run fails and can't be overridden. | Rules that must never be broken. |
Start new rules as advisory, watch the findings for a week, then tighten them.
Next steps
Read how policy as code works in StackShip, or go straight to the policy set reference for input fields, limits, and the publishing flow.