Skip to content
Infrastructure as Code iac testing 4 min read

Policy as Code (OPA & Sentinel)

Linters and security scanners catch generic mistakes, but every organization also has its own rules: only deploy in eu-west-1, every resource must carry a CostCenter tag, no S3 bucket may be publicly readable. Policy as code expresses those guardrails as machine-evaluated programs that run against a Terraform plan — before any change reaches the cloud — and fail the pipeline when a rule is violated. The two dominant engines are open-source OPA (with Conftest as its Terraform front-end) and HashiCorp Sentinel, built into Terraform Cloud/Enterprise.

Why evaluate the plan, not the code

A policy engine could read your .tf files directly, but that misses computed values, defaults injected by the provider, and resources created indirectly by modules. The reliable input is the plan in JSON form — the fully-resolved set of changes Terraform intends to make. You generate it once and feed it to whichever engine you use. This step works identically with OpenTofu (tofu plan / tofu show).

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

The JSON contains resource_changes[], where each entry has an address, a type, a change.actions array (create, update, delete), and the resolved change.after attributes. Policies pattern-match over that structure.

OPA and Conftest (Rego)

OPA policies are written in Rego, a declarative query language. Conftest wraps OPA so it can ingest the plan JSON and report deny/warn messages with a clean exit code. A policy is a set of rules in a package; any deny rule that produces a message fails the test.

This policy enforces an allowed-region list and forbids publicly-accessible S3 buckets:

package main

import input as plan

allowed_regions := {"eu-west-1", "eu-central-1"}

# Mandatory tag on every taggable resource being created or updated.
deny[msg] {
	rc := plan.resource_changes[_]
	rc.change.actions[_] != "delete"
	rc.type == "aws_instance"
	not rc.change.after.tags.CostCenter
	msg := sprintf("%s is missing required tag 'CostCenter'", [rc.address])
}

# Block public S3 buckets.
deny[msg] {
	rc := plan.resource_changes[_]
	rc.type == "aws_s3_bucket_public_access_block"
	rc.change.after.block_public_acls == false
	msg := sprintf("%s must set block_public_acls = true", [rc.address])
}

# Restrict the provider region.
deny[msg] {
	rc := plan.resource_changes[_]
	rc.type == "aws_instance"
	region := rc.change.after.availability_zone
	not startswith_allowed(region)
	msg := sprintf("%s is in a disallowed region (%s)", [rc.address, region])
}

startswith_allowed(az) {
	some r in allowed_regions
	startswith(az, r)
}

Run it against the plan with Conftest:

conftest test --policy policy/ tfplan.json

Output:

FAIL - tfplan.json - main - aws_instance.web is missing required tag 'CostCenter'
FAIL - tfplan.json - main - aws_s3_bucket_public_access_block.logs must set block_public_acls = true

2 tests, 0 passed, 0 warnings, 2 failures, 0 exceptions

A non-zero exit code stops the pipeline. Once the configuration adds the tag and locks the bucket down, the same command exits clean.

HashiCorp Sentinel

Sentinel is HashiCorp’s proprietary engine, integrated directly into Terraform Cloud and Enterprise runs — there is no separate “generate JSON” step, because Sentinel reads the run’s plan, state, and configuration through dedicated imports (tfplan/v2, tfconfig/v2, tfstate/v2). Policies are attached to a workspace via a policy set and run automatically after every plan.

import "tfplan/v2" as tfplan

allowed_regions = ["eu-west-1", "eu-central-1"]

# Collect all aws_instance resources being created or updated.
instances = filter tfplan.resource_changes as _, rc {
	rc.type is "aws_instance" and
	rc.mode is "managed" and
	(rc.change.actions contains "create" or rc.change.actions contains "update")
}

mandatory_tag = rule {
	all instances as _, rc {
		rc.change.after.tags.CostCenter is not null
	}
}

main = rule {
	mandatory_tag
}

Each policy carries an enforcement level set in the policy set, which decides what a failure does:

LevelBehaviour on failure
advisoryLogs a warning, the run continues
soft-mandatoryBlocks the apply, but an admin can override
hard-mandatoryBlocks the apply with no override possible

Sentinel only runs inside Terraform Cloud/Enterprise. If you are on the open-source CLI or OpenTofu, OPA/Conftest is the portable choice and runs anywhere you can produce plan JSON.

Where it fits in the pipeline

Policy checks belong after terraform plan and before apply, because they need resolved values and they gate the deploy. A typical CI job chains the stages: validate and tflint first (no plan needed), then plan, then the policy gate.

- run: terraform plan -out=tfplan.binary
- run: terraform show -json tfplan.binary > tfplan.json
- run: conftest test --policy policy/ tfplan.json   # fails the job on any deny

OPA/Conftest vs. Sentinel

OPA + ConftestSentinel
LicenceOpen source (CNCF)Proprietary (HashiCorp)
LanguageRegoSentinel (Python-like)
InputPlan JSON you generateNative plan/state/config imports
Runs inAny CI, locally, KubernetesTerraform Cloud/Enterprise only
OpenTofu supportYesNo
Override workflowBuild your ownBuilt-in soft-mandatory

Best Practices

  • Always evaluate the JSON plan, not raw .tf files, so policies see computed and default values.
  • Keep policies in version control next to the code, and unit-test them (conftest verify, Sentinel test/) like any other code.
  • Write deny messages that name the resource address and the exact fix, so failures are self-explanatory.
  • Start new rules at advisory / warn, observe real plans, then promote to mandatory once you trust them.
  • Pin engine and policy-bundle versions in CI to keep results reproducible across runs.
  • Prefer OPA/Conftest for portability across Terraform and OpenTofu; reserve Sentinel for teams already standardized on Terraform Cloud/Enterprise.
  • Run policy as a hard gate before apply, not as an after-the-fact audit of deployed state.
Last updated June 14, 2026
Was this helpful?