Policy Enforcement Cheat Sheet: Terraform & OpenTofu

This cheat sheet provides a quick start guide to implementing policy enforcement for your Terraform and OpenTofu projects using Open Policy Agent (OPA) and Conftest.

1. Why Policy as Code (PaC)?

  • Consistency: Ensure uniform deployments.
  • Security: Prevent misconfigurations and vulnerabilities.
  • Compliance: Adhere to internal and external standards.
  • Cost Control: Avoid budget overruns by restricting expensive resources.

2. Core Tools & Setup

  • Terraform/OpenTofu: Your IaC tool.
  • Open Policy Agent (OPA): The policy engine.
    • Installation: Refer to OPA official documentation.
  • Conftest: Tool to test Terraform plans against OPA policies.
    • Installation: Refer to Conftest official documentation.
  • (Optional) Infracost: For cost estimation policies.
    • Installation: Refer to Infracost official documentation.

3. Writing Your First Rego Policy

Rego policies define what is allowed or denied. Store them in a dedicated directory (e.g., ./policies).

Basic Structure (.rego file):

package <your_policy_namespace> # e.g., terraform.aws.s3

import input.resource_changes # Accesses the list of resource changes from the plan JSON

# 'deny' rules generate messages if conditions are met (violations)
deny[msg] {
    # Condition 1
    # Condition 2
    # ...
    msg := sprintf("Violation message for resource %s: %s", [resource.address, "reason"])
}

# 'warn' rules (if supported by your test runner setup) generate warnings
warn[msg] {
    # Conditions for warning
    msg := sprintf("Warning for resource %s: %s", [resource.address, "advice"])
}

Example: Ensure AWS S3 Buckets are Private (./policies/aws/s3_acl.rego)

package terraform.aws.s3_acl

import input.resource_changes

# Deny if an S3 bucket is being created or updated with a public ACL
deny[msg] {
    resource := resource_changes[_] # Iterate over each resource change
    resource.type == "aws_s3_bucket"
    resource.mode == "managed" # Ensure it's a resource Terraform manages

    # Apply to 'create' or 'update' actions
    action := resource.change.actions[_]
    actions_to_check := {"create", "update"}
    actions_to_check[action]
    
    acl := object.get(resource.change.after, "acl", "") # Safely get ACL attribute
    
    public_acls := {"public-read", "public-read-write", "authenticated-read"}
    public_acls[acl] # True if the acl is one of the defined public ACLs
    
    msg := sprintf("S3 bucket '%s' must not have a public ACL. Found: '%s'.", [resource.address, acl])
}

4. Generating Terraform Plan as JSON

OPA and Conftest evaluate the JSON representation of your Terraform plan.

  1. terraform plan -out=tfplan.binary
  2. terraform show -json tfplan.binary > plan.json

5. Testing with Conftest

Run Conftest from your project root to test plan.json against policies in your ./policies directory.

conftest test plan.json --policy ./policies --all-namespaces

  • --policy ./policies: Path to your Rego policy files.
  • --all-namespaces: Tells Conftest to find policies regardless of their package declaration if your query is generic (e.g., just looking for deny or warn). Alternatively, specify a namespace with -n <namespace>.

Interpreting Output: Conftest will list failing policies and their messages. Modify Terraform code or policies until checks pass.

6. Common Resource Policy Patterns (Rego Snippets)

Access resource attributes via resource.change.after.<attribute>.

Mandatory Tags:

mandatory_tag_keys := {"Owner", "Environment"}
current_tags := object.get(resource.change.after, "tags", {})
missing_key := mandatory_tag_keys[_]
not current_tags[missing_key]
msg := sprintf("Resource '%s' is missing mandatory tag: '%s'.", [resource.address, missing_key])

Region/Location Restrictions (e.g., Azure):

azure_allowed_locations := {"uksouth", "westeurope"}
location := resource.change.after.location
not azure_allowed_locations[location]
msg := sprintf("Disallowed Azure location '%s' for resource '%s'.", [location, resource.address])

Resource Type/Size Restrictions (e.g., AWS EC2):

allowed_instance_types := {"t3.micro", "t3.small"}
instance_type := resource.change.after.instance_type
not allowed_instance_types[instance_type]
msg := sprintf("Disallowed EC2 instance type '%s' for resource '%s'.", [instance_type, resource.address])

Naming Conventions:

# Deny if resource name doesn't start with "prod-"
name := resource.change.after.name
not startswith(name, "prod-")
msg := sprintf("Resource '%s' name ('%s') must start with 'prod-'.", [resource.address, name])

7. Cost Policies (Infracost + OPA)

  1. Generate Infracost JSON: infracost breakdown --path . --format json --out-file infracost.json (For PRs, use infracost diff --path . --compare-to infracost-base.json --format json --out-file infracost-diff.json)
  2. Test with Conftest: conftest test infracost.json --policy ./policies -N infracost (Or conftest test infracost-diff.json ...)

Write Rego Policy (e.g., ./policies/common/cost.rego):

package infracost # Policies for Infracost data must be in this package

# Deny if total monthly cost difference exceeds $100
deny[out] {
    max_total_monthly_cost_diff := 100.00
    actual_diff := to_number(input.diffTotalMonthlyCost) # Input is Infracost JSON
    actual_diff > max_total_monthly_cost_diff
    msg := sprintf("Total monthly cost difference ($%.2f) exceeds limit of $%.2f.", [actual_diff, max_total_monthly_cost_diff])
    out := {"msg": msg, "failed": true} # Infracost expects this output structure
}

8. CI/CD Integration (Conceptual Workflow)

Integrate these checks into your CI/CD pipeline (e.g., GitHub Actions, GitLab CI):

  1. Checkout code.
  2. Setup Terraform, Conftest, (Infracost if used).
  3. Run terraform init.
  4. Run terraform validate.
  5. Run terraform plan -out=tfplan.binary.
  6. Run terraform show -json tfplan.binary > plan.json.
  7. (If using Infracost) Run infracost breakdown ... > infracost.json.
  8. Run conftest test plan.json ... (and conftest test infracost.json ... if applicable).
  9. Fail pipeline if Conftest reports violations.

9. When to Consider Scalr

If your needs evolve to require:

  • Centralized policy management and versioning.
  • Automated pre-plan and post-plan checks integrated into a platform.
  • Role-Based Access Control (RBAC) for IaC operations.
  • Comprehensive audit trails.
  • Integrated cost estimation and policy enforcement. ...then explore a platform like Scalr.

10. Key Commands & Resources

  • OPA CLI:
    • opa eval --data policy_file.rego --input input.json "data.your_policy_namespace"
    • opa test ./policies/
    • opa run (REPL)
  • Conftest:
    • conftest test <input_file.json> --policy <policy_dir> [--all-namespaces | -n <namespace>]
  • Documentation:
    • Open Policy Agent: openpolicyagent.org/docs
    • Conftest: conftest.dev
    • Infracost: infracost.io/docs
    • Scalr: docs.scalr.com
    • Terraform: developer.hashicorp.com/terraform/docs
    • OpenTofu: opentofu.org/docs