Terraform Lesson 29 of 89

Policy-as-Code for Terraform with OPA and Conftest on the Plan JSON

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:

OPA/conftest policy gate over terraform plan JSON in CI

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 init first, but for plan-only policy checks in CI you usually want terraform init -backend=false so 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:

  1. Walk resource_changes, not planned_values. resource_changes is the documented, change-oriented surface and it tells you the action. planned_values is a flattened end-state tree that omits deletions and is easy to over-trust.
  2. Respect after_unknown. A value computed at apply time (an assigned ARN, a generated password, an autoscaled count) shows up as null in after and true in after_unknown. If your rule reads change.after.some_field and that field is unknown, you will get null and may produce a false deny or, worse, a false pass. When a field can be computed, check after_unknown before asserting on after.

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_tags at the provider level, so a bucket can be compliant at apply time even though its own tags block is empty. If you use provider default_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:

# 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:

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:

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:

conftest verify --policy policy/
conftest test tfplan.json infracost.json --combine --policy policy/ --namespace budget
# 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

Glossary

oparegoconftestterraformpolicy-as-codeci
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments