IaCTerraformPolicy

Infrastructure as Code Security: Terraform & OPA

Sven Nellemann
Oct 20237 min read

Introduction

Infrastructure as Code (IaC) revolutionized how we provision and manage cloud resources—but it also introduced new security risks. A single misconfigured Terraform file can expose databases to the internet, grant excessive IAM permissions, or create unencrypted storage buckets. Traditional code review can't catch every security issue, especially at scale.

Open Policy Agent (OPA) provides a powerful solution: policy as code. By defining security policies in a declarative language (Rego), you can automatically validate Terraform configurations before they're applied, preventing security misconfigurations from reaching production.

In this article, I'll show you how to implement automated security policy enforcement for Terraform using OPA.

Why Policy as Code Matters

Manual security reviews don't scale. Consider these real-world scenarios:

Scenario 1: The S3 Bucket

resource "aws_s3_bucket" "data" {
  bucket = "company-data-backup"
  # Missing: encryption, versioning, public access block
}

Scenario 2: The Overprivileged IAM Role

resource "aws_iam_role_policy" "app" {
  policy = jsonencode({
    Statement = [{
      Effect = "Allow"
      Action = "*"  # Full AWS access!
      Resource = "*"
    }]
  })
}

Scenario 3: The Public Database

resource "aws_db_instance" "main" {
  publicly_accessible = true  # Exposed to internet
  # Missing: encryption at rest
}

These issues are easy to miss in code review but trivial to catch with automated policy checks.

Understanding Open Policy Agent (OPA)

OPA is a general-purpose policy engine that evaluates policies written in Rego against structured data (JSON/YAML).

Core Concepts

1. Policies: Rules written in Rego that define what's allowed

2. Data: Input to evaluate (e.g., Terraform plan JSON)

3. Decisions: Boolean results (allow/deny) or detailed violations

How OPA Works with Terraform

Terraform Plan → JSON → OPA → Policy Evaluation → Allow/Deny

The workflow:

1. Generate a Terraform plan

2. Convert plan to JSON format

3. Feed JSON to OPA with security policies

4. OPA returns policy violations

5. Block deployment if violations found

Setting Up OPA for Terraform

Installation

# Install OPA
curl -L -o opa https://openpolicyagent.org/downloads/latest/opa_linux_amd64
chmod +x opa
sudo mv opa /usr/local/bin/

# Verify installation
opa version

Project Structure

terraform-project/
├── main.tf
├── variables.tf
├── policy/
│   ├── terraform.rego       # Main policy file
│   ├── aws_s3.rego         # S3-specific policies
│   ├── aws_iam.rego        # IAM policies
│   └── aws_ec2.rego        # EC2 policies
└── scripts/
    └── validate.sh         # Validation script

Writing Your First Security Policy

Let's start with a simple policy to ensure S3 buckets have encryption enabled.

Policy: Require S3 Encryption

# policy/aws_s3.rego
package terraform.aws.s3

import future.keywords.in

# Deny S3 buckets without encryption
deny[msg] {
    resource := input.resource_changes[_]
    resource.type == "aws_s3_bucket"
    
    # Check if encryption is configured
    not has_encryption(resource)
    
    msg := sprintf(
        "S3 bucket '%s' must have server-side encryption enabled",
        [resource.name]
    )
}

# Helper function to check encryption
has_encryption(resource) {
    resource.change.after.server_side_encryption_configuration
}

# Also check for aws_s3_bucket_server_side_encryption_configuration
has_encryption(resource) {
    resource.type == "aws_s3_bucket_server_side_encryption_configuration"
    # Encryption is being configured separately
}

Testing the Policy

Create a test Terraform file:

# test.tf
resource "aws_s3_bucket" "bad_bucket" {
  bucket = "test-bucket-no-encryption"
  # No encryption configured - should fail policy
}

resource "aws_s3_bucket" "good_bucket" {
  bucket = "test-bucket-encrypted"
}

resource "aws_s3_bucket_server_side_encryption_configuration" "good_bucket" {
  bucket = aws_s3_bucket.good_bucket.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

Run the Policy Check

# Generate Terraform plan as JSON
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json

# Evaluate policies
opa eval --data policy/ --input tfplan.json   --format pretty   "data.terraform.aws.s3.deny"

# Output shows violations:
# [
#   "S3 bucket 'bad_bucket' must have server-side encryption enabled"
# ]

Comprehensive Security Policies

Let's build a complete policy suite covering common security issues.

IAM Policies: Prevent Overprivileged Roles

# policy/aws_iam.rego
package terraform.aws.iam

import future.keywords.in

# Deny wildcard permissions
deny[msg] {
    resource := input.resource_changes[_]
    resource.type == "aws_iam_role_policy"
    
    policy := json.unmarshal(resource.change.after.policy)
    statement := policy.Statement[_]
    
    # Check for wildcard actions
    statement.Action == "*"
    
    msg := sprintf(
        "IAM policy '%s' grants wildcard (*) permissions - use specific actions",
        [resource.name]
    )
}

# Deny wildcard resources with sensitive actions
deny[msg] {
    resource := input.resource_changes[_]
    resource.type == "aws_iam_role_policy"
    
    policy := json.unmarshal(resource.change.after.policy)
    statement := policy.Statement[_]
    
    # Check for dangerous actions with wildcard resources
    statement.Resource == "*"
    has_dangerous_action(statement.Action)
    
    msg := sprintf(
        "IAM policy '%s' grants dangerous permissions on all resources (*)",
        [resource.name]
    )
}

# Sensitive actions that shouldn't apply to all resources
has_dangerous_action(actions) {
    dangerous := {
        "iam:*",
        "iam:CreateUser",
        "iam:DeleteUser",
        "s3:DeleteBucket",
        "ec2:TerminateInstances",
        "rds:DeleteDBInstance"
    }
    
    action := actions[_]
    action in dangerous
}

EC2: Enforce Security Best Practices

# policy/aws_ec2.rego
package terraform.aws.ec2

import future.keywords.in

# Deny instances without IMDSv2
deny[msg] {
    resource := input.resource_changes[_]
    resource.type == "aws_instance"
    
    # Check if IMDSv2 is required
    metadata := resource.change.after.metadata_options[_]
    metadata.http_tokens != "required"
    
    msg := sprintf(
        "EC2 instance '%s' must require IMDSv2 (set http_tokens = 'required')",
        [resource.name]
    )
}

# Deny security groups with unrestricted SSH
deny[msg] {
    resource := input.resource_changes[_]
    resource.type == "aws_security_group"
    
    ingress := resource.change.after.ingress[_]
    
    # Check for SSH (port 22) from anywhere
    ingress.from_port <= 22
    ingress.to_port >= 22
    "0.0.0.0/0" in ingress.cidr_blocks
    
    msg := sprintf(
        "Security group '%s' allows SSH from 0.0.0.0/0 - restrict to specific IPs",
        [resource.name]
    )
}

# Deny public RDS instances
deny[msg] {
    resource := input.resource_changes[_]
    resource.type == "aws_db_instance"
    
    resource.change.after.publicly_accessible == true
    
    msg := sprintf(
        "RDS instance '%s' is publicly accessible - set publicly_accessible = false",
        [resource.name]
    )
}

Tags: Enforce Compliance Requirements

# policy/tagging.rego
package terraform.tagging

import future.keywords.in

required_tags := ["Environment", "Owner", "Project", "CostCenter"]

# Deny resources without required tags
deny[msg] {
    resource := input.resource_changes[_]
    
    # Resources that must have tags
    taggable_resources := {
        "aws_instance",
        "aws_s3_bucket",
        "aws_db_instance",
        "aws_ecs_cluster"
    }
    
    resource.type in taggable_resources
    
    # Check for missing required tags
    missing_tags := get_missing_tags(resource)
    count(missing_tags) > 0
    
    msg := sprintf(
        "Resource '%s' (%s) missing required tags: %s",
        [resource.name, resource.type, concat(", ", missing_tags)]
    )
}

get_missing_tags(resource) = missing {
    resource_tags := object.keys(resource.change.after.tags)
    missing := {tag | 
        tag := required_tags[_]
        not tag in resource_tags
    }
}

Integrating OPA into CI/CD

GitHub Actions Workflow

# .github/workflows/terraform-security.yml
name: Terraform Security Validation

on:
  pull_request:
    paths:
      - '**.tf'
      - 'policy/**'

jobs:
  validate:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.6.0
      
      - name: Setup OPA
        run: |
          curl -L -o opa https://openpolicyagent.org/downloads/latest/opa_linux_amd64
          chmod +x opa
          sudo mv opa /usr/local/bin/
      
      - name: Terraform Init
        run: terraform init
      
      - name: Terraform Plan
        run: |
          terraform plan -out=tfplan.binary
          terraform show -json tfplan.binary > tfplan.json
      
      - name: Run OPA Policy Checks
        run: |
          opa eval             --data policy/             --input tfplan.json             --format pretty             --fail-defined             "data.terraform.deny[_]"
      
      - name: Generate Policy Report
        if: failure()
        run: |
          opa eval             --data policy/             --input tfplan.json             --format pretty             "data.terraform.deny" > policy-violations.txt
          cat policy-violations.txt
      
      - name: Comment on PR
        if: failure()
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const violations = fs.readFileSync('policy-violations.txt', 'utf8');
            
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `## ⚠️ Policy Violations Detected

\`\`\`
${violations}
\`\`\`

Please fix these issues before merging.
`
            });

GitLab CI Pipeline

# .gitlab-ci.yml
stages:
  - validate

terraform-security:
  stage: validate
  image: hashicorp/terraform:1.6
  before_script:
    - apk add --no-cache curl
    - curl -L -o /usr/local/bin/opa https://openpolicyagent.org/downloads/latest/opa_linux_amd64
    - chmod +x /usr/local/bin/opa
  script:
    - terraform init
    - terraform plan -out=tfplan.binary
    - terraform show -json tfplan.binary > tfplan.json
    - |
      if ! opa eval --data policy/ --input tfplan.json --fail-defined "data.terraform.deny[_]"; then
        echo "Policy violations found!"
        opa eval --data policy/ --input tfplan.json --format pretty "data.terraform.deny"
        exit 1
      fi
  only:
    changes:
      - "**.tf"
      - "policy/**"

Advanced Policy Patterns

Policy Testing with OPA

# policy/aws_s3_test.rego
package terraform.aws.s3

test_s3_without_encryption_fails {
    input := {
        "resource_changes": [{
            "type": "aws_s3_bucket",
            "name": "test_bucket",
            "change": {
                "after": {
                    "bucket": "test"
                    # No encryption
                }
            }
        }]
    }
    
    count(deny) == 1
}

test_s3_with_encryption_passes {
    input := {
        "resource_changes": [
            {
                "type": "aws_s3_bucket",
                "name": "test_bucket",
                "change": {"after": {"bucket": "test"}}
            },
            {
                "type": "aws_s3_bucket_server_side_encryption_configuration",
                "name": "test_bucket",
                "change": {"after": {"bucket": "test"}}
            }
        ]
    }
    
    count(deny) == 0
}

Run tests:

opa test policy/ -v

Parameterized Policies

# policy/config.rego
package config

# Allow overrides from data.json
allowed_ips := data.allowed_ssh_ips

default allowed_ips = ["10.0.0.0/8"]
// data.json
{
  "allowed_ssh_ips": [
    "10.0.0.0/8",
    "192.168.1.100/32"
  ]
}

Warnings vs Errors

package terraform.warnings

# Warnings don't block deployment but notify team
warn[msg] {
    resource := input.resource_changes[_]
    resource.type == "aws_s3_bucket"
    
    not has_versioning(resource)
    
    msg := sprintf(
        "Warning: S3 bucket '%s' should enable versioning for data protection",
        [resource.name]
    )
}

# Separate deny rules for blocking issues

Pre-commit Hooks for Local Validation

#!/bin/bash
# .git/hooks/pre-commit

set -e

echo "Running Terraform policy validation..."

# Check if Terraform files changed
if git diff --cached --name-only | grep -q ".tf$"; then
    # Run validation
    terraform fmt -check || {
        echo "❌ Terraform formatting issues found. Run 'terraform fmt'"
        exit 1
    }
    
    # Generate plan and validate policies
    terraform plan -out=tfplan.binary >/dev/null 2>&1
    terraform show -json tfplan.binary > tfplan.json
    
    if ! opa eval --data policy/ --input tfplan.json --fail-defined "data.terraform.deny[_]" >/dev/null 2>&1; then
        echo "❌ Policy violations found:"
        opa eval --data policy/ --input tfplan.json --format pretty "data.terraform.deny"
        rm tfplan.binary tfplan.json
        exit 1
    fi
    
    rm tfplan.binary tfplan.json
    echo "✅ All policies passed!"
fi

Make it executable:

chmod +x .git/hooks/pre-commit

Monitoring and Metrics

Track policy effectiveness with metrics:

# Generate policy report
opa eval   --data policy/   --input tfplan.json   --format json   "data.terraform" > policy-report.json

# Parse violations
violations=$(jq '.result[0].expressions[0].value.deny | length' policy-report.json)

# Send to monitoring
curl -X POST https://metrics.company.com/api/v1/metrics   -d "terraform.policy.violations=$violations"

Policy Coverage Metrics

| Metric | Target | Why It Matters |

|--------|--------|----------------|

| Policy pass rate | > 95% | Shows compliance level |

| Time to fix violations | < 24 hours | Measures responsiveness |

| Violations by category | Trend down | Identifies problem areas |

| False positive rate | < 5% | Indicates policy quality |

Best Practices

1. Start Small, Iterate

Week 1: S3 encryption policies
Week 2: IAM permission policies
Week 3: Network security policies
Week 4: Add to CI/CD

Begin with a few high-impact policies and expand gradually.

2. Document Your Policies

# Good: Clear documentation
# Policy: S3 Bucket Encryption
# Rationale: Protects data at rest per SOC2 requirement
# Severity: HIGH
# Remediation: Add server_side_encryption_configuration block
deny[msg] { ... }

3. Provide Actionable Error Messages

# Bad
msg := "Security violation"

# Good
msg := sprintf(
    "S3 bucket '%s' lacks encryption. Add:\n" +
    "resource \"aws_s3_bucket_server_side_encryption_configuration\" \"%s\" {\n" +
    "  bucket = aws_s3_bucket.%s.id\n" +
    "  rule { ... }\n" +
    "}",
    [resource.name, resource.name, resource.name]
)

4. Test Your Policies

Every policy should have tests:

  • Test that bad config fails
  • Test that good config passes
  • Test edge cases
  • 5. Version Control Your Policies

    # Tag policy versions
    git tag -a policies-v1.0.0 -m "Initial policy release"
    
    # Reference specific versions in CI/CD
    opa eval --data policy@v1.0.0 ...

    Common Pitfalls

    1. Overly Strict Policies

    Policies that are too restrictive get disabled. Balance security with developer productivity.

    2. Ignoring Terraform State

    OPA sees the plan, not current state. Some checks need state awareness:

    # Check if resource is being created (not updated)
    is_create(resource) {
        resource.change.actions[_] == "create"
    }

    3. Not Handling Modules

    Terraform modules need special handling:

    # Handle both root and module resources
    resource := input.resource_changes[_]
    # or
    resource := input.configuration.root_module.resources[_]

    4. Performance Issues

    Large Terraform plans can be slow. Optimise policies:

  • Use early returns
  • Cache computations
  • Focus on changed resources
  • Conclusion

    Policy as code with OPA transforms infrastructure security from reactive to proactive. By catching misconfigurations before deployment, you prevent security incidents rather than responding to them.

    The combination of Terraform and OPA provides:

  • **Automated enforcement**: No manual reviews needed
  • **Consistent standards**: Same policies across all teams
  • **Shift-left security**: Catch issues in development
  • **Audit trail**: Track policy decisions over time
  • Implementation Checklist

  • [ ] Install OPA and set up project structure
  • [ ] Write 5-10 critical security policies
  • [ ] Add policy tests
  • [ ] Integrate into CI/CD pipeline
  • [ ] Set up pre-commit hooks
  • [ ] Document policies and remediation steps
  • [ ] Train team on policy framework
  • [ ] Monitor policy effectiveness
  • [ ] Iterate based on feedback
  • Start with your most critical security requirements and expand from there. Remember: perfect policies on day one aren't the goal—preventing the next security incident is.

    ---

    *Implementing policy as code in your infrastructure? I'd love to hear about your challenges and successes. [Let's connect](#contact).*