A terraform plan someone skims in a PR review is not a guardrail; it is a hope. The moment your estate has more than a handful of contributors, “no public storage,” “everything is tagged with a cost center,” and “no instance bigger than this SKU” stop being things you can enforce by reading diffs. You enforce them by evaluating the plan with code, in CI, before apply. This guide does exactly that: it takes Terraform’s machine-readable plan JSON and gates it with Open Policy Agent (OPA) and Conftest, including reusable Rego, unit tests, waivers, and versioned policy bundles distributed over an OCI registry.
The key insight that makes all of this tractable: the plan JSON is a stable, documented contract. You write policy against that contract once, and it works across every provider, module, and team.
In a nutshell
A terraform plan is a shipping manifest: a precise, itemised list of everything about to cross the border into your cloud — every bucket, database, and security-group rule, and the exact attributes each will have. Open Policy Agent (OPA) is the customs officer. The rulebook the officer checks against is written in a small language called Rego (“no public buckets,” “everything carries a cost-center tag,” “no instance bigger than this SKU”). Conftest is the officer’s desk that you bolt onto your CI checkpoint. Every item on the manifest is checked against every rule; if anything violates one, the officer stamps REJECT — a non-zero exit code — and the shipment is turned back at the border before it ever reaches real infrastructure.
The manifest has to be machine-readable for any of this to work, which is the one translation step: Terraform renders the plan as JSON (terraform show -json), and OPA reads that JSON. You write the rulebook once against that JSON and it works for every provider, module, and team — because the plan JSON is a stable, documented contract.
For a beginner the payoff is concrete: “no public storage” and “everything must be tagged” stop being a wiki page nobody reads and become a wall the pipeline enforces on every change, for every contributor, with no exceptions except the ones you wrote down and dated. For the experienced engineer, the same Rego skill set later gates Kubernetes admission and image policy too — one language, the whole platform.
Level: Advanced · Time: ~35 min
Prerequisites. You are comfortable with the Terraform plan/apply workflow and reading a plan diff (see Terraform fundamentals), you know what runs in CI on a pull request, and you have met JSON and YAML before. No prior policy-language experience is assumed — Rego is built up from scratch here.
After this you can:
- Render a Terraform plan as JSON that a policy engine can read, and inspect it with
jq. - Write Rego
denyandwarnrules againstresource_changes,change.actions, andafter. - Factor shared logic into a
libpackage and prove each rule withopa test/conftest verify. - Wire Conftest into pre-commit and CI as a hard, unbypassable merge gate.
- Build an attributable, expiring waiver workflow instead of disabling rules.
- Publish and pin a versioned policy bundle, and see how the same Rego reaches Sentinel, Gatekeeper, and native
check.
The diagram traces one change left to right: Terraform renders the plan as JSON, a versioned Rego bundle loads, Conftest (OPA underneath) evaluates every resource_change against the rules while honouring dated waivers, deny becomes a non-zero exit that blocks the merge while warn only nudges, and terraform apply runs only on the very same saved plan the gate approved.
1. Generate machine-readable plan output
OPA does not understand HCL. It understands JSON. Terraform’s job here is to turn a proposed change into a JSON document that describes every resource it intends to create, update, or destroy. That is a two-step dance: produce a binary plan, then render it as JSON.
# 1. Produce a saved, binary plan file.
terraform plan -out=tfplan.binary
# 2. Render that exact plan as JSON (this does NOT re-plan).
terraform show -json tfplan.binary > tfplan.json
The separation matters. terraform show -json against the saved binary renders the same plan you would apply, with no fresh refresh and no chance of the world shifting between evaluation and apply. Do not pipe terraform plan -json instead; that emits a stream of log-line JSON objects (machine-readable UI events), not the plan representation Conftest expects. You want terraform show -json of a saved plan file.
For a quick look at what you are about to feed the policy engine:
# Pretty-print the top-level keys.
jq 'keys' tfplan.json
# Just the resource addresses and their planned actions.
jq -r '.resource_changes[] | "\(.address): \(.change.actions | join(","))"' tfplan.json
Run
terraform initfirst, but for plan-only policy checks in CI you usually wantterraform init -backend=falseso the job never touches remote state. You are evaluating the shape of the change, not reconciling it.
2. Understand resource_changes, before/after, and the plan schema
The plan JSON has a documented format version (currently 1.x, surfaced as format_version at the top). Check it; do not assume. The pieces you actually write policy against:
| Field | What it holds |
|---|---|
resource_changes[] |
The change set: one entry per resource being created, updated, deleted, or read. This is your primary surface. |
resource_changes[].address |
The full module-qualified address, e.g. module.network.aws_subnet.private[0]. Use it in messages. |
resource_changes[].type / .name |
Provider resource type and local name. |
resource_changes[].change.actions |
An array: ["create"], ["update"], ["delete"], ["create","delete"] (replace), or ["no-op"]. |
resource_changes[].change.before |
State of the resource before the change (null on create). |
resource_changes[].change.after |
State after the change (null on destroy). |
resource_changes[].change.after_unknown |
A mirror of after where values not known until apply are true. |
configuration |
The parsed config (references, expressions). Rarely needed for guardrails. |
prior_state / planned_values |
The full resource trees. Convenient but less stable; prefer resource_changes. |
Two rules save you from the most common policy bugs:
- Walk
resource_changes, notplanned_values.resource_changesis the documented, change-oriented surface and it tells you the action.planned_valuesis a flattened end-state tree that omits deletions and is easy to over-trust. - Respect
after_unknown. A value computed at apply time (an assigned ARN, a generated password, an autoscaled count) shows up asnullinafterandtrueinafter_unknown. If your rule readschange.after.some_fieldand that field is unknown, you will getnulland may produce a false deny or, worse, a false pass. When a field can be computed, checkafter_unknownbefore asserting onafter.
Here is the canonical helper you will reuse everywhere: filter to resources of a type that are being created or updated (ignore destroys and no-ops, which usually should not trip a “must be configured correctly” rule).
package lib.tf
import rego.v1
# All resource_changes of a given type that are being created or updated.
resources(type) := [r |
some r in input.resource_changes
r.type == type
is_managed_change(r)
]
is_managed_change(r) if {
some action in r.change.actions
action in {"create", "update"}
}
3. Write your first Rego deny rule
Conftest’s default convention is a package named main containing rules named deny, warn, or (older syntax) violation. A failing policy run exits non-zero, which is what fails your pipeline. We use Rego v1 syntax (import rego.v1, contains, if), which is the current, OPA-1.0-aligned dialect; the older deny[msg] { ... } partial-set form still works but the v1 form is what you should write new code in.
Two guardrails everyone needs first: required tags and encryption at rest. This example targets AWS, but the structure is provider-agnostic.
# policy/tagging.rego
package main
import rego.v1
import data.lib.tf
required_tags := {"environment", "owner", "cost_center"}
# Resource types we expect to be tagged. Extend as needed.
taggable := {"aws_instance", "aws_s3_bucket", "aws_db_instance", "aws_ebs_volume"}
deny contains msg if {
some type in taggable
some r in tf.resources(type)
provided := object.keys(object.get(r.change.after, "tags", {}))
missing := required_tags - provided
count(missing) > 0
msg := sprintf("%s is missing required tags: %v", [r.address, missing])
}
# policy/encryption.rego
package main
import rego.v1
import data.lib.tf
deny contains msg if {
some r in tf.resources("aws_ebs_volume")
r.change.after.encrypted != true
msg := sprintf("%s must have encryption enabled (encrypted = true)", [r.address])
}
deny contains msg if {
some r in tf.resources("aws_db_instance")
r.change.after.storage_encrypted != true
msg := sprintf("%s must enable storage_encrypted", [r.address])
}
object.get(r.change.after, "tags", {}) is deliberate: if tags is absent the resource still violates the rule, and object.get with a default avoids a key-not-found that would silently drop the rule. Run it:
conftest test tfplan.json --policy policy/
FAIL - tfplan.json - main - aws_ebs_volume.data must have encryption enabled (encrypted = true)
FAIL - tfplan.json - main - aws_s3_bucket.assets is missing required tags: ["cost_center", "owner"]
2 tests, 0 passed, 0 warnings, 2 failures
A subtle trap with tags: many resources support
default_tagsat the provider level, so a bucket can be compliant at apply time even though its owntagsblock is empty. If you use providerdefault_tags, either merge them in your module so they appear on the resource, or relax the rule to account for them. Policy that ignores how your modules actually assign tags produces noise, and noisy policy gets disabled.
4. Structure reusable libraries, helpers, and unit tests
A folder of copy-pasted some r in input.resource_changes blocks rots fast. Put shared logic in a lib package and import it. The layout that scales:
policy/
lib/
tf.rego # resources(), is_managed_change(), tag helpers
tf_test.rego # unit tests for the helpers
tagging.rego # package main: deny rules
encryption.rego
instances.rego
network.rego
exceptions.rego # waiver logic (section 6)
Now the part most teams skip and then regret: policy is code, so it gets unit tests. OPA has a first-class test runner. Test files live next to the policy, in a package, with rules prefixed test_. You feed them synthetic input with with input as ... and assert the rule fires (or does not).
# policy/tagging_test.rego
package main
import rego.v1
# A minimal plan fragment: one bucket missing two tags.
mock_plan(after) := {"resource_changes": [{
"address": "aws_s3_bucket.assets",
"type": "aws_s3_bucket",
"name": "assets",
"change": {"actions": ["create"], "after": after},
}]}
test_denies_bucket_missing_tags if {
result := deny with input as mock_plan({"tags": {"environment": "prod"}})
count(result) == 1
}
test_allows_fully_tagged_bucket if {
tags := {"environment": "prod", "owner": "platform", "cost_center": "cc-42"}
result := deny with input as mock_plan({"tags": tags})
count(result) == 0
}
test_ignores_destroyed_resource if {
plan := {"resource_changes": [{
"address": "aws_s3_bucket.old",
"type": "aws_s3_bucket",
"change": {"actions": ["delete"], "after": null},
}]}
result := deny with input as plan
count(result) == 0
}
Run the whole suite. --explain fails prints the trace for any failing assertion, which is the fastest way to debug a rule that is not matching what you think it is.
opa test policy/ -v
opa fmt -w policy/ # format, like terraform fmt
opa check policy/ # type-check / compile without running
These three commands belong in CI as their own job. A policy that has not been tested against a synthetic plan is a policy you do not actually trust.
5. Enforce instance types, regions, and public-access prevention
With the pattern established, the high-value guardrails fall out quickly. Keep allow-lists in data so they are configuration, not logic — you can override them per environment with --data files.
Allowed instance types. Deny anything outside an approved set; this is your primary cost guardrail.
# policy/instances.rego
package main
import rego.v1
import data.lib.tf
allowed_instance_types := {
"t3.micro", "t3.small", "t3.medium",
"m6i.large", "m6i.xlarge",
}
deny contains msg if {
some r in tf.resources("aws_instance")
itype := r.change.after.instance_type
not allowed_instance_types[itype]
msg := sprintf("%s uses disallowed instance_type %q (allowed: %v)", [r.address, itype, allowed_instance_types])
}
Allowed regions. Region usually comes from the provider block, not the resource, so the most reliable signal is the provider configuration in the plan. A pragmatic alternative many teams prefer: pass the target region in as data and assert on it, since the region is known in CI.
# policy/region.rego
package main
import rego.v1
allowed_regions := {"us-east-1", "us-west-2", "eu-west-1"}
# input.region is supplied via --data at evaluation time (see Verify).
deny contains msg if {
region := input.region
not allowed_regions[region]
msg := sprintf("region %q is not in the approved list %v", [region, allowed_regions])
}
Public-access prevention. The classic. Block public S3 buckets and security groups that open the world to sensitive ports.
# policy/network.rego
package main
import rego.v1
import data.lib.tf
# S3 public access block must turn everything off.
deny contains msg if {
some r in tf.resources("aws_s3_bucket_public_access_block")
after := r.change.after
not all([
after.block_public_acls == true,
after.block_public_policy == true,
after.ignore_public_acls == true,
after.restrict_public_buckets == true,
])
msg := sprintf("%s must enable all four public-access-block settings", [r.address])
}
sensitive_ports := {22, 3389, 3306, 5432}
# No security group ingress from 0.0.0.0/0 on a sensitive port.
deny contains msg if {
some r in tf.resources("aws_security_group")
some rule in r.change.after.ingress
"0.0.0.0/0" in rule.cidr_blocks
some port in sensitive_ports
rule.from_port <= port
rule.to_port >= port
msg := sprintf("%s allows 0.0.0.0/0 to sensitive port %d", [r.address, port])
}
That last rule shows why you walk structured data instead of grepping HCL: it correctly handles a single rule that spans a port range (from_port/to_port) covering a sensitive port, which a regex would miss.
6. Soft-fail warnings vs hard-fail denials and waivers
Not every finding should block a merge. Conftest gives you two rule families:
deny— hard fail. Non-zero exit. Use for security and compliance non-negotiables.warn— soft fail. Printed, but the run still exits zero unless you pass--fail-on-warn. Use for “you probably shouldn’t, but we won’t stop you” guidance and for newly introduced rules you are rolling out gradually.
# policy/cost_warn.rego
package main
import rego.v1
import data.lib.tf
# Nudge, don't block: gp2 is legacy; prefer gp3.
warn contains msg if {
some r in tf.resources("aws_ebs_volume")
r.change.after.type == "gp2"
msg := sprintf("%s uses gp2; gp3 is cheaper and faster", [r.address])
}
The hard part of any real policy program is the exception/waiver workflow. Blanket rules will eventually be wrong for one legitimate resource, and if your only escape hatch is “disable the rule,” people disable the rule. Build waivers into the policy so exceptions are explicit, attributable, and expirable.
A clean pattern: keep a checked-in waivers.yaml, load it as data, and skip a violation only if there is a matching, unexpired waiver.
# waivers.yaml
waivers:
- address: "aws_security_group.legacy_bastion"
rule: "sensitive-port-public"
reason: "Vendor appliance requires 0.0.0.0/0:22 until migration KV-1422"
approved_by: "vinod"
expires: "2026-09-30"
# policy/exceptions.rego
package main
import rego.v1
# Is there a valid (unexpired) waiver for this address+rule?
waived(address, rule) if {
some w in input.waivers
w.address == address
w.rule == rule
time.parse_rfc3339_ns(sprintf("%sT00:00:00Z", [w.expires])) > time.now_ns()
}
Then gate the network rule through it (note the explicit rule ID so waivers reference something stable):
deny contains msg if {
some r in tf.resources("aws_security_group")
some rule in r.change.after.ingress
"0.0.0.0/0" in rule.cidr_blocks
some port in sensitive_ports
rule.from_port <= port
rule.to_port >= port
not waived(r.address, "sensitive-port-public")
msg := sprintf("%s allows 0.0.0.0/0 to sensitive port %d", [r.address, port])
}
Expiry is the whole point. A waiver without a date is a permanent hole nobody revisits; one that expires forces a conscious renewal and shows up in CI the day it lapses.
7. Wire Conftest into GitHub Actions and pre-commit
Run policy in two places: locally before the commit (fast feedback, fewer round-trips) and in CI as the authoritative gate (cannot be skipped). They share the same policy/ directory, so behavior is identical.
pre-commit. Use the official Conftest hook to evaluate any plan JSON a developer generates. A thin wrapper script keeps the plan generation and the policy call together:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/open-policy-agent/conftest
rev: v0.56.0
hooks:
- id: conftest-test
files: 'tfplan\.json$'
args: ["--policy", "policy/", "--all-namespaces"]
GitHub Actions. Generate the plan JSON, then evaluate it. This stages cleanly after validate and before any expensive integration job.
# .github/workflows/policy.yml
name: policy
on:
pull_request:
jobs:
opa-unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: open-policy-agent/setup-opa@v2
with:
version: latest
- run: opa fmt --list --fail policy/ # fail if unformatted
- run: opa check policy/
- run: opa test policy/ -v
conftest:
needs: opa-unit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- name: Plan and render JSON
run: |
terraform init -backend=false
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json
- uses: open-policy-agent/setup-conftest@v0
- name: Evaluate policy
run: |
conftest test tfplan.json \
--policy policy/ \
--all-namespaces \
--data waivers.yaml
--all-namespaces evaluates every package, not just main, which you want once your policies are organized into sub-packages. Add --no-color for clean log scraping and --output github to get annotations rendered inline on the PR diff.
8. Distribute and version policy bundles via OCI registries
A folder of Rego in one repo is fine for one team. Across an organization you want one source of policy, versioned and pulled by every pipeline, not copied. Conftest can push and pull policy bundles as OCI artifacts to any registry that supports them (GHCR, ECR, ACR, Artifactory).
Publish from the policy repo’s CI on a tagged release:
# Push the contents of policy/ as an OCI artifact, versioned by tag.
conftest push ghcr.io/kloudvin/policies:1.4.0 policy/
# Optionally also move a floating tag for "latest stable".
conftest push ghcr.io/kloudvin/policies:stable policy/
Consume it in any downstream pipeline. conftest pull fetches the bundle into a local policy/ directory, then you evaluate as normal:
conftest pull ghcr.io/kloudvin/policies:1.4.0
conftest test tfplan.json --all-namespaces --data waivers.yaml
Pin to an immutable version tag (1.4.0), never stable or latest, in the pipelines that gate production. A floating tag means your merge gate can change behavior with no commit in your repo — exactly the kind of invisible drift policy-as-code exists to prevent. Treat the policy bundle like any other dependency: pin it, bump it deliberately, and let the bump go through review.
OPA itself can also serve and pull bundles (the OPA bundle protocol, or OCI via the oci:// service), which is the right path if you run OPA as a long-lived service for admission control elsewhere. For Terraform CI specifically, Conftest’s push/pull is the lighter-weight, more direct fit.
Going deeper
Sections 1–8 give you a working gate. This section is the internals behind it: how Rego actually evaluates, every corner of the plan-JSON contract, the Conftest and OPA subcommands you did not need yet, and where OPA sits relative to Sentinel, Gatekeeper, and Terraform’s own check blocks.
Rego in one page
Rego is a declarative query language, not an imperative script. A handful of mechanics explain almost everything you will read:
packagenamespaces a set of rules. Conftest evaluates themainpackage by default;--all-namespacesevaluates them all. A rule’s full path isdata.<package>.<rule>— exactly whatopa evalqueries.inputis the whole document you hand in. For usinputis the plan JSON, soinput.resource_changesis the change set.- A rule is a definition that produces a value.
denyandwarnare partial set rules:deny contains msg if { ... }addsmsgto a set every time its body is satisfiable. Multiple satisfying resources produce multiple messages. Multiple separatedenyrules with the same name union their sets — an implicit OR across rules, an implicit AND across the lines inside one body. - Iteration is
some x in collection; assignment is:=; equality is==.sprintfbuilds the message. There are no loops or mutation — you describe the set of violations and let OPA find them.
The dialect matters. This lesson uses import rego.v1, the OPA 1.0-aligned grammar (if, contains, in, every as keywords). Historically you enabled those one at a time with import future.keywords (or the granular import future.keywords.if, .contains, .in). import rego.v1 is shorthand for “all of the future keywords, plus the stricter v1 parser.” On OPA 1.0+ the v1 grammar is the default, so import rego.v1 becomes a harmless no-op you keep for back-compat with older engines. You will still meet the pre-v1 partial-set form in the wild:
# Pre-v1 syntax — still valid, but write new code in the rego.v1 form above.
package main
import future.keywords.in
deny[msg] { # note: [msg], not "contains msg if"
some r in input.resource_changes
r.type == "aws_s3_bucket"
r.change.after.acl == "public-read"
msg := sprintf("%s is public-read", [r.address])
}
A third rule name, violation, appears when you reach Gatekeeper below — its constraint framework expects violation[{"msg": msg}] (an object, not a bare string).
The input contract, in full
terraform show -json plan.tfplan is the whole game (some teams name the plan file plan.tfplan rather than tfplan.binary — identical idea). Rendering a saved plan file gives you the exact change set that would apply; terraform show -json with no file argument renders current state instead, a different document. Beyond the fields in section 2, three more matter at depth:
format_versionandterraform_versionat the top — pin your expectations to the format version, not the Terraform version.resource_changes[].mode—"managed"for real resources,"data"for data sources. Most guardrails only care aboutmanaged.change.actionsis the signal you gate on. The full enumeration:
change.actions |
Meaning |
|---|---|
["no-op"] |
No change. Usually skip. |
["create"] |
New resource. |
["update"] |
In-place update. |
["delete"] |
Destroy. Exclude from “must be configured correctly” rules. |
["create","delete"] |
Replace, create before destroy. |
["delete","create"] |
Replace, destroy before create. |
["read"] |
Data-source read (mode: "data"). |
Two sensitivity mirrors round out the picture: after_unknown marks values not known until apply (true where unknown), and before_sensitive/after_sensitive mark values Terraform will redact in output. The after_unknown field is the one that silently breaks security rules — see the mistake and the challenge on it below.
Conftest beyond test
conftest test tfplan.json --policy policy/ is 90% of daily use, but four more capabilities earn their place:
- Namespaces. Organise rules into packages like
main.aws.s3and select them with--namespace main.aws.s3, or evaluate everything with--all-namespaces. Namespacing keeps a large policy set navigable and lets different pipelines gate on different subsets. conftest verifyruns your Rego unit tests (thetest_rules) without needing theopabinary — Conftest’s built-in equivalent ofopa test:
conftest verify --policy policy/
--combinechanges the shape ofinput. Normally each input file is evaluated separately withinputset to that one file. With--combine, all inputs are merged into a single array where each element is{"path": "...", "contents": {...}}, so one rule can reason across files — a plan JSON plus an Infracost breakdown, or several plans at once:
conftest test tfplan.json infracost.json --combine --policy policy/ --namespace budget
- Built-in exceptions. Alongside the hand-rolled
waivers.yamlfrom section 6, Conftest has a nativeexceptionconvention: anexceptionrule returns the suffixes of rule names to skip for the matched input.
# Skip the "public_ingress" deny for an explicitly blessed input.
exception contains rules if {
input.metadata.waiver == "vendor-appliance"
rules := ["public_ingress"]
}
Use the waivers.yaml pattern when you need attribution and expiry; use the built-in exception for coarse, input-level opt-outs. And conftest parse tfplan.json simply dumps how Conftest parsed the input — handy when a rule “should” match but does not.
OPA as a server: opa eval, opa run, decision logs
Conftest is a thin, opinionated wrapper. Underneath is plain OPA, and two OPA entry points are worth knowing:
# Ad-hoc query: evaluate the deny set against a plan, pretty-printed.
opa eval -d policy/ -i tfplan.json 'data.main.deny' --format pretty
# As a gate: --fail-defined exits non-zero when the query returns ANY result.
opa eval -d policy/ -i tfplan.json 'data.main.deny[_]' --fail-defined --format raw
opa eval is the scriptable, no-Conftest way to gate — the exit code, not the text, is the verdict. For a shared decision service you run OPA as a server:
opa run -s policy/ # HTTP server on :8181, policy preloaded
# Push the plan as {"input": ...} and read the decision back.
curl -s localhost:8181/v1/data/main/deny \
--data-binary @<(jq '{input: .}' tfplan.json) | jq
A long-lived OPA server can also stream decision logs — every query’s input, result, and timestamp — to a remote sink, giving you a tamper-evident audit trail of what the gate decided, on what change, when. For Terraform CI you rarely need the server; for org-wide, always-on enforcement it is the backbone.
Gatekeeper: the same Rego at the Kubernetes door
OPA Gatekeeper runs OPA inside a Kubernetes admission webhook, so the cluster itself rejects non-compliant objects at create time. You write a ConstraintTemplate whose Rego uses violation[{"msg": msg}], then parameterise it with Constraint custom resources:
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
name: k8srequiredlabels
spec:
crd:
spec:
names:
kind: K8sRequiredLabels
targets:
- target: admission.k8s.gatekeeper.sh
rego: |
package k8srequiredlabels
violation[{"msg": msg}] {
required := input.parameters.labels
provided := {label | input.review.object.metadata.labels[label]}
missing := required - provided
count(missing) > 0
msg := sprintf("missing required labels: %v", [missing])
}
The point is not Kubernetes specifically; it is that the Rego skill you built for Terraform transfers directly. One policy language, two very different enforcement points — CI for infrastructure-as-code, admission for what actually runs.
OPA/Conftest vs Sentinel vs native check
Three ways to enforce policy on Terraform, and they are not interchangeable:
| OPA + Conftest | Sentinel | Native check blocks |
|
|---|---|---|---|
| Language | Rego (open, CNCF) | Sentinel (HashiCorp) | HCL assertions |
| Runs in | Any CI, CLI, server, K8s admission | HCP Terraform / TFE run stage | terraform plan / apply |
| Input | Any JSON/YAML — plan JSON here | tfplan/v2, tfconfig, tfstate imports |
Provider data + resource state |
| Gate strength | Exit code — hard | advisory / soft- / hard-mandatory | Warning by default, non-blocking |
| Separation of duties | Yes — policy lives outside the config | Yes — enforced inside the run | No — the module author can edit the check |
| Cost | Free / open source | Paid (Team & Governance tiers) | Free (built-in) |
The decisive axis is separation of duties. A check block (Terraform 1.5+) is excellent for continuous validation — assert a health endpoint responds, a post-condition holds — but it lives in the same config the author controls, so it cannot be a compliance gate: whoever can write the resource can delete the check. Sentinel and OPA both sit outside that config. Choose Sentinel when you are all-in on HCP Terraform and want a native run stage (covered in Sentinel policy sets); choose OPA/Conftest when you want an open, portable engine that also gates Kubernetes and, alongside scanners like Checkov (see IaC scanning), covers the whole platform. They coexist happily — check and the native terraform test framework for validation, OPA or Sentinel for the gate.
Exit codes are the whole contract
Everything above reduces to one integer. Conftest exits 0 when all deny rules pass (warnings do not fail the run unless you pass --fail-on-warn), and non-zero when any deny fires or a policy errors. --no-fail forces exit 0 for report-only rollouts; opa eval --fail-defined turns a raw query into a gate. The rule for a trustworthy pipeline: let the process exit code be the verdict and never parse log text to decide pass or fail — text formats change, exit codes do not.
Verify
Confirm the whole chain actually blocks bad changes and lets good ones through.
# 1. Helpers and rules pass their own unit tests.
opa test policy/ -v
# 2. Generate fresh plan JSON from your config.
terraform init -backend=false
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json
# 3. Evaluate. A compliant plan exits 0; a violating one exits non-zero.
conftest test tfplan.json --policy policy/ --all-namespaces \
--data waivers.yaml --data region=us-east-1
echo "exit code: $?"
# 4. Prove a deny fires: point an EBS volume at encrypted = false,
# re-plan, re-render, and confirm a FAIL line + non-zero exit.
# 5. Prove a waiver works: add a matching, unexpired entry to
# waivers.yaml and confirm the previously-failing rule now passes.
# 6. Prove expiry works: set that waiver's `expires` to a past date
# and confirm the rule fails again.
The decisive test is #6. If an expired waiver does not re-break the build, your time comparison is wrong and every waiver is effectively permanent.
Checklist
Pitfalls and next steps
The failure mode that quietly destroys a policy program is false confidence from unknown values. A rule that reads change.after.storage_encrypted passes happily when that field is null because it is computed at apply time — so a resource that will be unencrypted sails through the gate. Audit every rule that touches a possibly-computed attribute and make it consult after_unknown; treat “unknown” as “fail closed” for anything security-relevant.
The second is policy that fights your modules. Provider default_tags, computed names, and wrapper modules all mean the raw resource may not carry the attribute your rule inspects, even though the applied resource is compliant. The fix is to test policy against the plan JSON your actual modules produce, not hand-written fragments alone, so the gate matches reality and does not generate noise that trains people to ignore it.
From here, the high-value extensions are: a dedicated, separately tested policy repo published as a versioned OCI bundle (so the gate is an artifact, not a folder you copy); cost-aware policy by feeding an Infracost breakdown alongside the plan and denying changes whose monthly delta exceeds a threshold; and, once Terraform CI is solid, reusing the same Rego against Kubernetes admission and CI image policy so one Rego skill set covers the whole platform.
Practice challenges
Work these against a real plan JSON from any Terraform config you have (even a couple of null_resources and a local_file will do). Each solution says why it matters.
1 (Beginner) — Render and read the plan. Produce a plan JSON and print the address of every resource being created (not updated or destroyed).
<details> <summary>Solution</summary>
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json
jq -r '.resource_changes[] | select(.change.actions | index("create")) | .address' tfplan.json
Why: change.actions is the field every rule keys on, and index("create") is how you filter to one action. Selecting on the action rather than the resource type is the habit the whole gate is built on.
</details>
2 (Beginner) — Your first deny. Write a rule that blocks any aws_db_instance where deletion_protection is not true.
<details> <summary>Solution</summary>
# policy/db_protection.rego
package main
import rego.v1
import data.lib.tf
deny contains msg if {
some r in tf.resources("aws_db_instance")
r.change.after.deletion_protection != true
msg := sprintf("%s must set deletion_protection = true", [r.address])
}
Why: it reuses the tf.resources helper (create/update only) and the after read pattern from section 3 — the exact shape every attribute guardrail follows.
</details>
3 (Intermediate) — Nudge, don’t block. Emit a warn (not a deny) for any aws_instance on a previous-generation t2.* type.
<details> <summary>Solution</summary>
warn contains msg if {
some r in tf.resources("aws_instance")
startswith(r.change.after.instance_type, "t2.")
msg := sprintf("%s uses previous-gen %q; prefer t3/t3a", [r.address, r.change.after.instance_type])
}
Why: warn prints but keeps the exit code at 0 (unless --fail-on-warn) — the right family for guidance and for rules you are rolling out gradually, versus a hard deny.
</details>
4 (Intermediate) — Prove it with a unit test. Write opa test cases for challenge 2: the rule must fire on an unprotected DB, pass on a protected one, and not fire on a DB being destroyed.
<details> <summary>Solution</summary>
# policy/db_protection_test.rego
package main
import rego.v1
db(after, actions) := {"resource_changes": [{
"address": "aws_db_instance.main",
"type": "aws_db_instance",
"change": {"actions": actions, "after": after},
}]}
test_denies_unprotected_db if {
count(deny with input as db({"deletion_protection": false}, ["create"])) == 1
}
test_allows_protected_db if {
count(deny with input as db({"deletion_protection": true}, ["create"])) == 0
}
test_ignores_destroyed_db if {
count(deny with input as db(null, ["delete"])) == 0
}
Run with opa test policy/ -v or conftest verify --policy policy/. Why: the destroy case is the one people forget — tf.resources filters it out, and this test locks that behaviour in so a later refactor cannot silently start denying deletes.
</details>
5 (Advanced) — Fail closed on unknown values. Some attributes are computed at apply time and appear as null in after with true in after_unknown. Write a rule that fails an aws_ebs_volume whose encrypted is unknown at plan time, so a possibly-unencrypted volume cannot sail through.
<details> <summary>Solution</summary>
# policy/encryption_unknown.rego
package main
import rego.v1
import data.lib.tf
deny contains msg if {
some r in tf.resources("aws_ebs_volume")
object.get(r.change.after_unknown, "encrypted", false) == true
msg := sprintf("%s: 'encrypted' is unknown at plan time — set it explicitly (fail-closed)", [r.address])
}
Why: this is the single most common way a policy program gives false confidence. A rule that only reads after.encrypted passes on null; consulting after_unknown and treating unknown as a failure closes the hole for anything security-relevant.
</details>
6 (Advanced) — Cross-file cost gate with --combine. Feed the plan JSON and an Infracost infracost.json breakdown together, and deny when the monthly delta exceeds $500.
<details> <summary>Solution</summary>
infracost breakdown --path . --format json --out-file infracost.json
conftest test tfplan.json infracost.json \
--combine --policy policy/ --namespace budget
# policy/budget.rego (sketch — field name per your Infracost version)
package budget
import rego.v1
monthly_delta := to_number(doc.contents.diffTotalMonthlyCost) if {
some doc in input
endswith(doc.path, "infracost.json")
}
deny contains msg if {
monthly_delta > 500
msg := sprintf("monthly cost delta $%v exceeds the $500 budget gate", [monthly_delta])
}
Why: --combine reshapes input into an array of {path, contents} objects, which is the only way one rule can reason across two files. It is also the bridge to cost-aware policy — the highest-value extension in this lesson’s closing.
</details>
Common beginner mistakes
-
“I’ll just scan the
.tffiles.” Raw HCL hides everything resolved at plan time — variable defaults, module inputs, providerdefault_tags,for_eachexpansion. A rule against HCL can pass while the applied resource is non-compliant. Right model: gate the plan JSON, the resolved change that will actually apply. (Scanners like Checkov read HCL too and are complementary — but the plan is the source of truth.) -
“
terraform plan -jsongives me the plan as JSON.” No — that flag streams machine-readable log events (the UI as JSON), not the plan representation. Conftest will choke or, worse, evaluate garbage. Right model:terraform plan -out=plan.tfplanthenterraform show -json plan.tfplan. -
“My rule reads
after.encryptedand passes, so we’re safe.” If that attribute is computed,after.encryptedisnulland your check may quietly pass on a resource that will be unencrypted. Right model: consultafter_unknownand fail closed on anything security-relevant (challenge 5). -
“I’ll walk
planned_values— it’s the whole resource tree.”planned_valuesis a flattened end-state that omits deletions and hides the action. Right model: walkresource_changes, which carrieschange.actionsso you know create vs update vs destroy. -
“A rule that never denies means we’re compliant.” A rule that has never fired is a rule you have never tested, and it is probably broken — wrong type string, wrong field path. Right model: every rule ships with a paired pass/fail unit test proving it discriminates.
-
“Conftest and OPA are different tools I have to learn separately.” Conftest is OPA underneath, with conventions (
mainpackage,deny/warn/exception, namespaces). The Rego is identical. Right model: learn Rego once; it carries toopa eval, an OPA server, and Gatekeeper. -
“A waiver without an expiry is fine — we’ll clean it up later.” You won’t. A dateless waiver is a permanent hole nobody revisits. Right model: every waiver carries an
expiresdate so CI re-breaks the day it lapses and forces a conscious renewal.
Glossary
- OPA (Open Policy Agent) — a CNCF open-source policy engine that evaluates rules (written in Rego) against arbitrary JSON/YAML input and returns a decision. Provider- and tool-agnostic.
- Rego — OPA’s declarative policy language. You describe the set of violations; OPA finds them. No loops, no mutation.
- Conftest — a CLI wrapper around OPA with conventions for testing structured config files:
mainpackage,deny/warn/exceptionrules, namespaces, and OCI bundle push/pull. - Plan JSON — the machine-readable rendering of a Terraform plan, produced by
terraform show -jsonof a saved plan file. The stable contract you write policy against. resource_changes— the array in the plan JSON with one entry per resource being created, updated, deleted, or read. The primary surface for guardrails.change.actions— the per-resource action array (["create"],["update"],["delete"],["create","delete"],["no-op"],["read"]). Gate on this to skip destroys and no-ops.after/after_unknown— the resource’s post-change attributes, and a mirror marking which are unknown until apply (true= unknown). Readingafterwithout checkingafter_unknownis the classic false-pass bug.input— in Rego, the whole document passed in. Hereinputis the plan JSON, soinput.resource_changesis the change set.package— the namespace a set of Rego rules lives in. Conftest evaluatesmainby default;--all-namespacesruns them all.deny/warn— Conftest rule families.denyhard-fails (non-zero exit);warnprints but exits 0 unless--fail-on-warn.violation— the rule name Gatekeeper’s constraint framework expects (violation[{"msg": msg}]), returning an object rather than a bare string.- Partial set rule — a Rego rule (like
deny) that adds to a set every time its body holds, giving you one message per violating resource. import rego.v1— the OPA 1.0-aligned grammar (keywordsif,contains,in,every). Shorthand for all ofimport future.keywordsplus the stricter v1 parser; a no-op on OPA 1.0+.--combine— a Conftest flag that merges all input files into one array of{path, contents}objects so a rule can reason across files (e.g. plan + cost breakdown).conftest verify— runs Rego unit tests (test_rules) without theopabinary; Conftest’s equivalent ofopa test.opa eval— OPA’s raw query tool.--fail-definedexits non-zero when the query returns any result, turning a query into a gate.- Decision log — a stream of every decision an OPA server makes (input, result, timestamp) to a remote sink; the audit trail for enforcement.
- Gatekeeper — OPA running inside a Kubernetes admission webhook, so the cluster rejects non-compliant objects at create time. Same Rego, different enforcement point.
- Sentinel — HashiCorp’s proprietary policy language, native to HCP Terraform / Terraform Enterprise run stages with advisory/soft/hard enforcement levels.
checkblock — Terraform 1.5+ in-config assertions for continuous validation; non-blocking by default and editable by the config author, so not a compliance gate.- Waiver / exception — an explicit, attributable, ideally expiring exemption for one address+rule, so a legitimate special case does not mean disabling the rule.
- Policy bundle — the
policy/directory packaged as a versioned artifact (Conftest can push/pull it as an OCI image) so every pipeline pulls one pinned source of policy. - Fail closed — when a needed fact is unknown, treat it as a violation rather than a pass. The safe default for security-relevant attributes.