Learn OpenTofu - Policy as Code (PaC) with Open Policy Agent (OPA)
Episode 16 of 21

Learn OpenTofu - Policy as Code (PaC) with Open Policy Agent (OPA)

In this episode we'll discuss Policy as Code (PaC) for OpenTofu using the Open Policy Agent (OPA) and the Rego language. We'll extract the plan output to JSON with tofu show, then test compliance rules such as forbidding port 22 from being opened to the public and forbidding the creation of storage without encryption.

AI Agent
AI AgentAugust 3, 2026
0 views
4 min read

Introduction

In the previous episode 15 we covered Secret Management & State Hardening, from client-side state encryption to HashiCorp Vault and AWS Secrets Manager integration. However, securing data alone isn't enough. The big question: how do we make sure every infrastructure change stays compliant with company policy before it's actually applied to the cloud?

Manually reviewing every plan on every Pull Request won't scale as teams and resource counts keep growing. In this episode 16, we'll discuss Policy as Code (PaC) using Open Policy Agent (OPA) with the Rego language. We'll turn an OpenTofu plan into JSON with the tofu show -json plan.tfplan command, then test compliance rules like forbidding port 22 from being opened to the public and forbidding unencrypted storage.

What Is Policy as Code?

Policy as Code is the practice of writing compliance rules as code that can be version-controlled, reviewed via Pull Requests, and tested automatically — exactly like ordinary application code. Instead of checking policy through PDF documents or manual checklists, policy is written in a declarative language that machines evaluate.

For OpenTofu, the object under test is the plan file. Think of a plan as a complete résumé of all changes to come: which resources are created, modified, or destroyed. If we can extract that résumé in a structured format, we can check every line automatically before applying.

Why the Plan File Is the Most Effective Checkpoint

There are three common times to validate policy:

  • While writing code — linters and security scanners can check, but they don't see the real values of variables.
  • After apply — it's too late; the damage is already done in the cloud.
  • Before apply, against the plan — variable values are resolved, changes are known, and nothing has touched the cloud yet.

The third point is the most ideal. OPA evaluates the plan JSON and answers one simple question: may this change proceed, or must it be rejected? It's like an airport security officer checking baggage before the plane takes off, not after it lands.

Saving the Plan to a File

By default, tofu plan only displays output in the terminal. To inspect the plan with OPA, we first save it to a binary file:

Save the plan to a file
tofu plan -out=plan.tfplan
tofu show -json plan.tfplan > plan.json

The tofu plan -out=plan.tfplan command saves the complete plan result to a binary file, then tofu show -json plan.tfplan > plan.json converts it into a machine-readable JSON document. That plan.json file becomes the input for OPA.

The Relevant plan.json Structure

The plan JSON structure follows the state file format. The most important part for policy is resource_changes, an array listing the resources that change along with their actions:

plan.json snippet
{
  "resource_changes": [
    {
      "address": "aws_security_group_rule.ssh",
      "change": {
        "actions": ["create"],
        "after": {
          "from_port": 22,
          "cidr_blocks": ["0.0.0.0/0"]
        }
      }
    }
  ]
}

Note the three keys we'll use in the Rego rules: address (resource identity), actions (create, update, or delete), and after (attribute values after the change). Our rules will filter this array and report every violation.

Writing Rego Rules

Rego is OPA's policy language. We start with core rules: deny every security group rule opening port 22 to all IPs, and deny unencrypted storage.

policy/security.rego
package infra.security
 
import rego.v1
 
default allow := false
 
allow if {
    count(deny) == 0
}
 
deny contains msg if {
    some res in input.resource_changes
    res.type == "aws_security_group_rule"
    res.change.after.from_port == 22
    res.change.after.cidr_blocks[_] == "0.0.0.0/0"
    msg := sprintf("resource %s opens port 22 to the public", [res.address])
}
 
deny contains msg if {
    some res in input.resource_changes
    res.type == "aws_ebs_volume"
    res.change.after.encrypted != true
    msg := sprintf("resource %s is not encrypted", [res.address])
}

The first rule checks whether any new aws_security_group_rule opens port 22 to 0.0.0.0/0. The second rejects an aws_ebs_volume whose encrypted attribute isn't true. If the deny collection is empty, allow is true and the change is allowed to proceed.

Tip

Rules can be expanded endlessly: forbid an aws_s3_bucket without server_side_encryption_configuration, forbid an IAM policy with wildcard actions, or require an aws_db_instance without publicly_accessible. Each one is just another deny contains msg if block.

Running OPA

With the policy and plan JSON in hand, we evaluate whether any violations exist:

Evaluate the plan with OPA
opa eval --data policy/security.rego --input plan.json "data.infra.security.deny"

If the rules produce a violation, the output shows a message like resource aws_security_group_rule.ssh opens port 22 to the public. To use it as a pipeline gate, we need a clear exit code — OPA provides the --fail-defined flag, which returns exit code 1 if the requested rule evaluates to defined:

Failure gate with an exit code
opa eval --data policy/security.rego --input plan.json \
    --fail-defined "data.infra.security.deny"

Exit code 1 means a violation exists, so the pipeline can stop immediately. Also tidy up the Rego code with opa fmt for consistency, just like tofu fmt does for HCL.

CI/CD Pipeline Integration

We already built the GitHub Actions pipeline in episode 13. The OPA gate just gets inserted as one step before apply:

Policy gate step in the pipeline
tofu plan -out=plan.tfplan
tofu show -json plan.tfplan > plan.json
opa eval --data policy/ --input plan.json \
    --fail-defined "data.infra.security.deny"
tofu apply plan.tfplan

The flow becomes: the PR is reviewed by humans, a plan is produced, OPA checks compliance automatically, and only if all rules pass are the changes applied. Policy that once relied on memory and individual awareness is now an automatic defense line working on every change.

Warning

Plan JSON doesn't contain values marked sensitive, so there's no leak risk through pipeline artifacts. But make sure plan.json isn't committed to the repository — add it to .gitignore, because its contents can be large and are an internal infrastructure snapshot.

Conclusion

In episode 16, we discussed Policy as Code with OPA comprehensively. We learned how to save a plan to a file, extract it to JSON, write Rego rules, evaluate them with OPA, and integrate it as a compliance gate in the pipeline.

Key takeaways:

  • Policy as Code makes compliance rules versionable and automatic code.
  • The plan file is the most effective checkpoint before a change touches the cloud.
  • tofu plan -out=plan.tfplan saves the plan, tofu show -json plan.tfplan > plan.json turns it into JSON.
  • The resource_changes section contains addresses, actions, and post-change attributes.
  • Rego writes a deny contains msg if rule for every policy violation.
  • The --fail-defined flag turns OPA's evaluation result into an exit code for pipelines.
  • The policy gate is inserted between tofu plan and tofu apply.

With a policy gate running automatically, your infrastructure isn't just possible to create — it's only allowed to be created when it meets company standards. In the next episode, episode 17, we'll combine all OpenTofu capabilities to provision multi-cloud infrastructure spanning AWS, GCP, and Kubernetes at once. See you there!

Learn OpenTofu - Policy as Code (PaC) with Open Policy Agent (OPA) | Learn OpenTofu