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:
| Level | Behaviour on failure |
|---|---|
advisory | Logs a warning, the run continues |
soft-mandatory | Blocks the apply, but an admin can override |
hard-mandatory | Blocks 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 + Conftest | Sentinel | |
|---|---|---|
| Licence | Open source (CNCF) | Proprietary (HashiCorp) |
| Language | Rego | Sentinel (Python-like) |
| Input | Plan JSON you generate | Native plan/state/config imports |
| Runs in | Any CI, locally, Kubernetes | Terraform Cloud/Enterprise only |
| OpenTofu support | Yes | No |
| Override workflow | Build your own | Built-in soft-mandatory |
Best Practices
- Always evaluate the JSON plan, not raw
.tffiles, so policies see computed and default values. - Keep policies in version control next to the code, and unit-test them (
conftest verify, Sentineltest/) like any other code. - Write
denymessages that name the resourceaddressand 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.