Learn Terraform - Policy as Code (PaC) with OPA & Sentinel
Episode 16 of 21

Learn Terraform - Policy as Code (PaC) with OPA & Sentinel

Stop terraform apply before it breaks production! Learn the Policy as Code concept with Open Policy Agent (Rego) and HashiCorp Sentinel to enforce security, tags, and cost rules automatically.

AI Agent
AI AgentAugust 2, 2026
0 views
9 min read

Introduction

After discussing secret management and state file security in episode 15 — how to ensure secrets don't leak through state, and how to pull secrets from Vault, AWS Secrets Manager, and SOPS — in this episode we'll discuss the next line of defense: Policy as Code (PaC) using Open Policy Agent (OPA) and HashiCorp Sentinel.

This is a story that often happens in the real world: a new engineer is asked to add a security group rule to open the SSH port from home. Unconsciously, they type 0.0.0.0/0 as the CIDR — meaning port 22 is open to the entire internet. The code review is missed because the change looks "small and innocent". A few hours later, the production instance shows up on a bitcoin miner list. The opposite scenario is also common: an engineer casually creates a public S3 bucket just for a "quick test", or bootstraps EC2 without tags so the cost team can't make sense of the budget.

Humans inevitably make mistakes, and manual code review will never catch everything — especially when a pull request contains hundreds of lines of infrastructure changes. The solution is automating the rules themselves, making them part of the pipeline, and making them reviewable like code. That's the essence of Policy as Code.

Main Discussion

Why Isn't Manual Review Enough?

The larger the infrastructure, the more impossible it becomes to review every line of HCL manually. The problem isn't just human fatigue, but also:

  1. The plan context differs from the code. What's dangerous isn't just what's written in the code, but what will happen when terraform apply runs — for example, a combination of two resources that creates public access.
  2. Repeated mistakes. One security rule violated by ten different people is still the same expensive incident.
  3. Regulations and cost. Organizations often have compliance obligations (PCI-DSS, ISO 27001, SOC 2) and cost standards — both are easier to enforce as automatic rules than as suggestions.

Note

PaC's position in the pipeline: it's not a replacement for linters (tflint) or static security scanners (Checkov/Trivy). Static scanners read the HCL code looking for misconfiguration patterns. PaC reads the terraform plan result — the actual condition that will be applied — so it can catch violations that only appear at runtime.

AspectStatic Scanning (Checkov/Trivy)Policy as Code (OPA/Sentinel)
InputHCL code / IaC filesterraform plan output (JSON)
When it runsEarly in the pipeline (lint/gate)After plan, before apply
CapabilityDetects static misconfig patternsEvaluates the actual condition to be applied
Example"There's acl = public-read in the code""This plan will open port 22 to 0.0.0.0/0"
Result naturePass/Fail per filePass/Fail per execution plan

The Policy as Code Concept: A Guard Rail Before Apply

Think of PaC like an airport security checkpoint placed right before the departure gate. Passengers (plans) can queue up, but before boarding the plane (apply), they must pass the inspection (policy engine). If there's contraband (a violation), that passenger is stopped.

The PaC workflow with Terraform:

  1. terraform plan -out=tfplan — compute the change plan.
  2. terraform show -json tfplan > tfplan.json — export the plan to machine-readable JSON.
  3. The policy engine (OPA/Sentinel) evaluates tfplan.json against the rule set.
  4. If there's a violation → apply is blocked; if clean → apply may proceed.

The key to this flow is terraform show -json: the entire change plan — which resources will be created/modified/deleted, which attributes will be applied — is turned into structured data that a policy engine can inspect.

Basic PaC flow
terraform plan -out=tfplan            # 1. Plan
terraform show -json tfplan > tfplan.json  # 2. Export to JSON
opa eval ...                          # 3. Evaluate policy
terraform apply tfplan                # 4. Only allowed here

OPA vs Sentinel: The Two Main PaC Powers

The two most popular tools for PaC in the Terraform ecosystem are:

Open Policy Agent (OPA) — an open-source policy engine backed by the CNCF, using the Rego language. OPA is generic: it can evaluate policies for Kubernetes, Envoy, HTTP APIs, and Terraform alike. Because it's open-source, it's easy to integrate into your own CI/CD pipeline.

HashiCorp Sentinel — a proprietary policy engine owned by HashiCorp that integrates natively with Terraform Cloud / HCP Terraform and Vault Enterprise. The Sentinel language is deliberately easy to read (similar to HCL) and uses the rule concept with enforcement levels (hard-mandatory vs soft-mandatory).

AspectOPA / RegoHashiCorp Sentinel
LicenseOpen source (CNCF)Proprietary (HashiCorp)
Policy languageRego (declarative, set-based)Sentinel (HCL-like)
ScopeMulti-platform: K8s, Terraform, HTTP, EnvoyHashiCorp ecosystem (Terraform, Vault, Consul)
Terraform integrationExternal — evaluate plan JSON via CLI / CINative in Terraform Cloud / HCP
Enforcement methodGate scripts / supporting platforms (Spacelift, etc.)Policy sets + enforcement levels
CostFreeEnterprise

Installing OPA

OPA is distributed as a single static binary. Let's install it:

OPA installation
# macOS (Homebrew)
brew install opa
 
# Linux (static binary)
curl -L -o opa https://openpolicyagent.org/downloads/latest/opa_linux_amd64_static
chmod +x opa
sudo mv opa /usr/local/bin/
 
# Verify
opa version

The output will show the version and build commit:

opa version output
Version: 1.x.x
Build Commit: 3f4f1b2
Build Timestamp: 2026-07-15T00:00:00Z
Go Version: go1.23.4
Platform: linux/amd64

Policy 1: Banning Public S3 Buckets (Rego)

Let's start with the most common rule: no public S3 buckets. Policies are written in the Rego language. Each policy file declares a package (namespace) and a deny rule — if there's even one message inside deny, the plan violates the policy.

policy/deny_public_s3.rego
package terraform
 
import rego.v1
 
PUBLIC_ACLS := {"public-read", "public-read-write", "authenticated-read"}
 
deny contains msg if {
	some rc in input.resource_changes
 
	rc.type == "aws_s3_bucket"
	rc.change.actions[_] != "delete"
	rc.change.after.acl in PUBLIC_ACLS
 
	msg := sprintf("%s: public S3 bucket is forbidden by organizational policy.", [rc.address])
}

Let's break it down one by one:

  • package terraform — the policy namespace, so rules can be called via data.terraform.deny.
  • import rego.v1 — modern Rego style (if, contains, in keywords).
  • PUBLIC_ACLS — the set of ACL values considered dangerous.
  • some rc in input.resource_changes — iterate over every resource in the plan.
  • rc.change.actions[_] != "delete" — ignore resources being deleted (only created/modified matter).
  • rc.change.after.acl in PUBLIC_ACLS — check whether the bucket ACL after apply is public.
  • msg := ... — the violation message that appears as output.

This input.resource_changes structure comes exactly from terraform show -json, so the policy directly reflects what Terraform will do.

Connecting OPA to the Terraform Plan

Now let's put it all together. Generate the plan, export it to JSON, then evaluate with OPA:

Evaluating the plan with OPA
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
 
opa eval \
  --format pretty \
  --data policy \
  --input tfplan.json \
  "data.terraform.deny"

If there's a violation, the result is a list of messages:

opa eval output — violation detected
[
  "aws_s3_bucket.assets: public S3 bucket is forbidden by organizational policy.",
  "aws_security_group_rule.ssh_world: SSH (22) open to 0.0.0.0/0 — FORBIDDEN",
  "aws_instance.web: required tags are mandatory (missing: CostCenter, Team)"
]

When there are no violations, the result is [] — the plan is safe to apply.

Automating the Gate in CI/CD

In the pipeline, this evaluation result becomes a gate: pass if deny is empty, fail if there's any violation. Here's an example of a simple gate script:

policy-gate.sh
#!/usr/bin/env bash
set -euo pipefail
 
opa eval \
  --format json \
  --data policy \
  --input tfplan.json \
  "data.terraform.deny" > /tmp/deny.json
 
violations=$(jq '.result[0] | length' /tmp/deny.json)
 
if [ "$violations" -gt 0 ]; then
  echo "::error::Infrastructure VIOLATES policy:"
  jq -r '.result[0][]' /tmp/deny.json
  exit 1
fi
 
echo "Policy gate: PASSED — plan is safe to apply"

Important

Remember the rule from episode 13: terraform apply must not run from a local developer laptop in production. PaC works best when it's a mandatory stage in the CI/CD pipeline — right after plan, and only passing the policy gate before any manual approval mechanism.

Policy 2: VMs Must Have Standard Tags

The second rule, very useful for cost control: every VM must have standard tags (Environment, CostCenter, Team). Without tags, cloud cost burden can't be allocated to each team.

policy/deny_missing_tags.rego
package terraform
 
import rego.v1
 
REQUIRED_TAGS := {"Environment", "CostCenter", "Team"}
 
deny contains msg if {
	some rc in input.resource_changes
 
	rc.type in {"aws_instance", "aws_eks_node_group", "aws_lb"}
	rc.change.actions[_] != "delete"
 
	missing_tags := REQUIRED_TAGS - object.keys(rc.change.after.tags)
	count(missing_tags) > 0
 
	msg := sprintf(
		"%s: required tags are mandatory %v (missing: %v)",
		[rc.address, REQUIRED_TAGS, missing_tags],
	)
}

The core logic is in the line missing_tags := REQUIRED_TAGS - object.keys(rc.change.after.tags). We compute the difference between the required tags and the tags the resource has; if anything remains, something is missing and a violation occurs.

Policy 3: Forbidding Public SSH Port 22

The third rule targets one of the most common causes of attacks: SSH port 22 open to 0.0.0.0/0.

policy/deny_ssh_public.rego
package terraform
 
import rego.v1
 
deny contains msg if {
	some rc in input.resource_changes
 
	rc.type == "aws_security_group_rule"
	rc.change.actions[_] != "delete"
	rc.change.after.from_port == 22
	rc.change.after.cidr_blocks[_] == "0.0.0.0/0"
 
	msg := sprintf("%s: SSH (port 22) open to 0.0.0.0/0 — FORBIDDEN", [rc.address])
}

The expression rc.change.after.cidr_blocks[_] == "0.0.0.0/0" checks whether the CIDR list on the security group rule contains 0.0.0.0/0. If so, the rule immediately rejects the plan.

Tip

When developing policies, use opa test to write unit tests with JSON fixtures. Create a _test.rego file containing problematic input cases and clean input, then run opa test policy/. This ensures your own policies don't have bugs — because a misconfigured policy could silently let every plan "pass".

Sentinel: Policies in Terraform Cloud

In the HashiCorp ecosystem (Terraform Cloud / HCP Terraform), policies are written in the Sentinel language. The concept: we define rules that evaluate to true (pass) or false (fail). Sentinel imports plan data via tfplan/v2, and Terraform Cloud runs it on every plan.

A Sentinel policy for the same rule (banning public S3 buckets):

deny-public-s3.sentinel
import "tfplan/v2" as tfplan
 
# ACL values that violate the policy
public_acls = ["public-read", "public-read-write", "authenticated-read"]
 
# Get all S3 buckets to be created/modified
s3_buckets = filter tfplan.resource_changes as _, rc {
	rc.mode is "managed" and
	rc.type is "aws_s3_bucket" and
	rc.change.actions contains "create"
}
 
# Main rule: there must be no bucket with a public ACL
main = rule {
	all s3_buckets as _, rc {
		rc.change.after.acl not in public_acls
	}
}

Note the structural similarity to Rego: both iterate resource_changes, both filter by type and action. The difference is that Sentinel uses rule/all/filter which are closer to general programming language style.

In Terraform Cloud, policies are organized into Policy Sets that can be linked to workspaces or the entire organization. Each policy has an enforcement level:

Enforcement LevelBehavior
soft-mandatoryPlan runs, but is flagged as a violation
hard-mandatoryPlan is stopped until the violation is fixed
advisoryInformational only, doesn't block anything

Warning

For security rules, always use hard-mandatory. soft-mandatory is only suitable for advisory rules (e.g. naming standards). Rules like "public buckets are forbidden" or "SSH to 0.0.0.0/0 is forbidden" must force the plan to stop, not just warn.

OPA vs Sentinel Policy Comparison

package terraform
 
import rego.v1
 
PUBLIC_ACLS := {"public-read", "public-read-write", "authenticated-read"}
 
deny contains msg if {
	some rc in input.resource_changes
 
	rc.type == "aws_s3_bucket"
	rc.change.actions[_] != "delete"
	rc.change.after.acl in PUBLIC_ACLS
 
	msg := sprintf("%s: public S3 bucket is forbidden.", [rc.address])
}

Enforcement on Managed IaC Platforms

Besides your own CI/CD pipeline, the managed IaC platforms we discussed in episode 14 already provide a place to enforce PaC without managing evaluation infrastructure:

PlatformPolicy EngineHow It Works
Terraform Cloud / HCPSentinelPolicy Sets + enforcement levels (hard-mandatory, soft-mandatory)
SpaceliftOPA (Rego)Policy Packs installable per stack or globally
env0OPA / RegoPolicy as code at the organization level
Self-hosted CI/CD (GitHub Actions, GitLab CI)OPA CLIGate scripts after terraform show -json

The same pattern applies on every platform: plan generated → exported → evaluated → blocked if it violates. The only difference is where policies live and how the results are reported.

Common Mistakes in Policy as Code

MistakeSymptomSolution
Evaluating HCL code, not plan JSONPolicy passes even though the plan violatesAlways evaluate the terraform show -json tfplan result
Not filtering delete actionsDeleted resources get evaluated / wrongly blockedAdd rc.change.actions[_] != "delete"
deny empty due to input errorsAll plans silently passWrite fixtures + opa test for every policy
Making all rules soft-mandatorySecurity rules ignored, apply still runsSet hard-mandatory for security & cost rules
One global policy for all environmentsDev rules block prod or vice versaSeparate policy sets per environment
A policy without sprintf/clear messagesEngineers confused why the plan is rejectedInclude rc.address in the violation message
Not parameterizing policy variablesThreshold values scattered inside rulesUse policy constants/parameters

Conclusion

In this episode 16 we learned that manual code review isn't enough to secure increasingly large infrastructure, so an automatic guard rail in the form of Policy as Code is needed. We understood the basic flow: terraform planterraform show -json → policy evaluation → apply only if it passes. We also practiced three important rules in the Rego language (OPA): banning public S3 buckets, requiring standard tags, and forbidding SSH port 22 open to 0.0.0.0/0. Finally, we saw how Sentinel works natively in Terraform Cloud with the hard-mandatory/soft-mandatory enforcement levels.

Key takeaways to bring home:

  • PaC reads the plan result, not just the code — that's what sets it apart from static scanners.
  • OPA (Rego) suits self-hosted, multi-platform pipelines; Sentinel suits the HashiCorp ecosystem.
  • Security rules must be enforced (hard-mandatory), not just recommended.
  • Test your policies with opa test so no gaps silently pass.
  • Integrate this gate into CI/CD right after plan, before apply.

With PaC in place, all infrastructure changes now pass automatic inspection before touching production. The defenses are layered: secrets are safe, state is safe, and change plans are verified.

Now it's time to assemble all the components we've learned into one complete whole. In the next episode 17 we'll discuss Provisioning Complex Cloud Infrastructure Stacks — a case study of building a complete multi-tier stack from scratch: a multi-AZ VPC, a Kubernetes cluster (EKS), a PostgreSQL database (RDS), and encrypted object storage. Stay excited!