Infrastructure as Code Security: Terraform & OPA
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/DenyThe 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 versionProject 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 scriptWriting 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/ -vParameterized 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 issuesPre-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!"
fiMake it executable:
chmod +x .git/hooks/pre-commitMonitoring 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/CDBegin 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:
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:
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:
Implementation Checklist
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).*