OPA policies for Terraform

Rego rules that block public buckets and open ingress.

Intermediate30 min · lesson 7 of 12

Every building site has an inspector. If the rules live only in that inspector's head, you get decisions that drift with their mood, arguments you win by being the loudest person in the room, and a rulebook that walks out the door the day they quit. Write the rules down and everything changes. Anyone can read them, a disagreement is about the text instead of the person, and you can check a wall against them before the concrete sets. **Policy as code** is that written rulebook for your infrastructure: the rules your company already has, in a form a machine can evaluate, so a program decides whether a change is allowed. Same answer at 5 p.m. on a Friday as at 10 a.m. on a Tuesday.

**Open Policy Agent (OPA)** is the program that reads the rulebook. It is a general purpose policy engine and a graduated project at the Cloud Native Computing Foundation, the same foundation that looks after Kubernetes. OPA knows nothing about Terraform, or Amazon Web Services (AWS), or containers. You hand it a JSON document (JavaScript Object Notation, the plain text format tools use to pass structured data around), it evaluates that document against rules written in **Rego** (OPA's query language, pronounced ray-go), and it hands back a decision. **Conftest** is the small command line tool that does the plumbing. It reads your file, hands it to OPA under the name `input`, loads every `.rego` file under `policy/`, and exits non-zero when a rule fires. That exit code is the whole enforcement mechanism. Every CI system (continuous integration, the automated checks that run on each proposed change) already knows how to fail a job when a command exits non-zero.

Why the Ready-Made Scanners Stop Short

Trivy (which absorbed tfsec) and Checkov ship hundreds of ready-made checks for the mistakes everybody makes: world-readable buckets, unencrypted disks, wildcard IAM (Identity and Access Management, the service that decides who can do what in a cloud account) policies. Run them. What a fixed catalogue cannot contain is *your* rules. Every resource carries an `owner` tag. Databases live only in `eu-central-1`. Nobody outside the platform team creates IAM roles. Rules like those stay tribal knowledge until a tired reviewer approves the plan that breaks one.

There is a second gap, and it cuts deeper. Catalogue scanners mostly judge one resource at a time, and the most dangerous cloud configurations are about what is *missing*. A bucket is rarely dangerous because of an attribute on the bucket. It is dangerous because nothing standing next to it says never make this public.

Turn the Plan Into a Document

Terraform will export its plan as JSON, and that export is the whole integration. `terraform plan -out` writes a binary plan file, a frozen decision about what Terraform intends to do. `terraform show -json` turns that file into text you can read, diff and query. Building the plan needs read access to the cloud account, because Terraform refreshes state first. Grading it needs nothing at all. The JSON is self-contained, evaluation happens offline, and the same plan always produces the same verdict. Here is a four-resource plan, trimmed below to the one resource we are going to argue about.

terminal
terraform plan -out=tfplan.binary
output
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# aws_security_group_rule.allow_ssh will be created
+ resource "aws_security_group_rule" "allow_ssh" {
+ cidr_blocks = [
+ "0.0.0.0/0",
]
+ from_port = 22
+ id = (known after apply)
+ protocol = "tcp"
+ security_group_id = "sg-0a1b2c3d4e5f67890"
+ security_group_rule_id = (known after apply)
+ self = false
+ source_security_group_id = (known after apply)
+ to_port = 22
+ type = "ingress"
}
Plan: 4 to add, 0 to change, 0 to destroy.
Saved the plan to: tfplan.binary
To perform exactly these actions, run the following command to apply:
terraform apply "tfplan.binary"

Two parts of that document matter. `resource_changes` is an array with one entry per resource Terraform intends to touch: the `address` (`aws_s3_bucket.data`), the `type`, and a `change` object holding `actions` (`create`, `update`, `delete`, `no-op`), the current values in `before`, the planned values in `after`, and a map called `after_unknown` that marks everything Terraform cannot work out until apply time. A replacement arrives as `["delete", "create"]`, which is why testing for `create` catches rebuilds as well as brand new resources. The second part is `configuration`, your parsed source code, including which resource references which. Most policy examples online use only the first. You need both.

terminal
terraform show -json tfplan.binary > tfplan.json
jq -c '.resource_changes[] | {address, type, actions: .change.actions}' tfplan.json
output
{"address":"aws_ebs_volume.scratch","type":"aws_ebs_volume","actions":["create"]}
{"address":"aws_instance.web","type":"aws_instance","actions":["create"]}
{"address":"aws_s3_bucket.data","type":"aws_s3_bucket","actions":["create"]}
{"address":"aws_security_group_rule.allow_ssh","type":"aws_security_group_rule","actions":["create"]}
From configuration to a blocked merge
1terraform plan -out
Reads cloud state, writes a binary plan, changes nothing
2terraform show -json
resource_changes plus configuration, one file
3conftest test
Loads policy/*.rego, hands the file to OPA as input
4Rego deny set
One message per violation, or empty
5Exit code
0 carries on, 1 fails the job
Everything after the first step runs offline: no cloud credentials, same plan in, same verdict out.

A Rule That Closes Port 22 to the Internet

A Rego rule is a query, not a script. Read `deny contains msg if { ... }` the way you would read a request to a librarian: find every combination of values that makes all the lines in the body true, and for each one, put `msg` in a set called `deny`. The lines are conditions joined by AND. Their order does not matter, there are no loops, and nothing is ever reassigned. You write `some rc in input.resource_changes` and OPA tries every element for you. OPA 1.0 made the `if` and `contains` keywords compulsory; the `import rego.v1` line keeps the same file compiling on the 0.x builds still baked into plenty of Conftest images. Conftest looks for rules named `deny`, `violation` and `warn` in `package main` by default, and every file in that package feeds the same `deny` set.

policy/network.rego
package main
import rego.v1
open_to_everyone := {"0.0.0.0/0", "::/0"}
# A port range only means something for a specific protocol. "-1" is AWS for
# "every protocol", and then from_port / to_port are ignored entirely.
covers_port(rule, port) if {
rule.from_port <= port
rule.to_port >= port
}
covers_port(rule, _) if rule.protocol == "-1"
covers_port(rule, _) if rule.ip_protocol == "-1"
# Shape 1: standalone rule, still the most common thing in the wild
ssh_open_to_everyone(rc) if {
rc.type == "aws_security_group_rule"
rc.change.after.type == "ingress"
some cidr in rc.change.after.cidr_blocks
cidr in open_to_everyone
covers_port(rc.change.after, 22)
}
# Shape 2: the newer per-CIDR resource, one rule holds one range
ssh_open_to_everyone(rc) if {
rc.type == "aws_vpc_security_group_ingress_rule"
rc.change.after.cidr_ipv4 in open_to_everyone
covers_port(rc.change.after, 22)
}
# Shape 3: inline ingress block on the security group itself
ssh_open_to_everyone(rc) if {
rc.type == "aws_security_group"
some ingress in rc.change.after.ingress
some cidr in ingress.cidr_blocks
cidr in open_to_everyone
covers_port(ingress, 22)
}
deny contains msg if {
some rc in input.resource_changes
"create" in rc.change.actions
ssh_open_to_everyone(rc)
msg := sprintf("%s opens port 22 to the whole internet", [rc.address])
}
deny contains msg if {
some rc in input.resource_changes
"create" in rc.change.actions
rc.type in {"aws_instance", "aws_db_instance", "aws_s3_bucket"}
not rc.change.after.tags.owner
msg := sprintf("%s has no owner tag", [rc.address])
}

Three resource types, one question. The AWS provider has changed how an ingress rule (ingress means traffic coming in from outside) is written twice, and real repositories contain all three shapes at once: an inline `ingress` block inside `aws_security_group`, a standalone `aws_security_group_rule`, and the newer `aws_vpc_security_group_ingress_rule`, which takes a single range in `cidr_ipv4` (CIDR is the `10.0.0.0/16` notation for a block of IP addresses) instead of a list in `cidr_blocks`. A policy that knows only the middle shape is a fence with a gate-shaped hole in it. Port 22 is SSH (Secure Shell, the protocol engineers use to log into a machine and run commands as themselves), which is why it is the port worth guarding.

`covers_port` is defined three times on purpose. Several definitions of one Rego function are OR'd together, and the last two exist because `protocol = "-1"` is AWS shorthand for every protocol on every port, which turns the `from_port` and `to_port` values (conventionally 0 and 0) into a lie. The second definition reads `protocol`, the third reads `ip_protocol`, because the newer resource renamed the field.

The second `deny` rule shows the habit that matters most in security policy: **deny on absence**. `not rc.change.after.tags.owner` is true when the tag is missing, when `tags` is `null`, and when nobody wrote a `tags` block at all. The resource has to prove it is compliant rather than merely dodge a known-bad value. Now run the gate.

terminal
conftest test tfplan.json --policy policy/
echo "exit=$?"
output
FAIL - tfplan.json - main - aws_instance.web has no owner tag
FAIL - tfplan.json - main - aws_security_group_rule.allow_ssh opens port 22 to the whole internet
2 tests, 0 passed, 0 warnings, 2 failures, 0 exceptions
exit=1

Public Buckets Are a Missing Neighbor Problem

Since version 4 of the AWS provider, one bucket is not one resource. `aws_s3_bucket` holds the name and the tags. The pieces that decide who can read it live in siblings: `aws_s3_bucket_acl`, `aws_s3_bucket_policy`, and the one that actually saves you, `aws_s3_bucket_public_access_block`. Think of it as a deadbolt fitted to the door frame instead of a note stuck on the door. Its four booleans tell S3 (Simple Storage Service, Amazon's object storage) to refuse public grants whatever anyone attaches later. `block_public_acls` and `block_public_policy` reject new public ACLs (access control lists, the older per-object permission system) and bucket policies as they are submitted. `ignore_public_acls` ignores any public ACL already attached. `restrict_public_buckets` cuts anonymous and cross-account access off from a bucket that already carries a public policy, leaving only the owning account and AWS services. An attacker does not need to break your Terraform. They need one hurried teammate attaching a bucket policy with `"Principal": "*"` to unblock a demo. With the block on, that API call is rejected.

AWS has switched Block Public Access on by default for new buckets since April 2023, and disabled ACLs on new buckets at the same time. That helps. The setting can still be turned off, older buckets predate it, and a default you never wrote down is a default you cannot show an auditor. So make the plan state it, and make the policy demand it.

policy/s3.rego
package main
import rego.v1
# Every bucket address that a fully closed public access block points at.
# This reads `configuration`, not `resource_changes`, because at plan time the
# block's `bucket` attribute is (known after apply): the bucket has no id yet.
locked_down_buckets contains ref if {
some res in input.configuration.root_module.resources
res.type == "aws_s3_bucket_public_access_block"
res.expressions.block_public_acls.constant_value == true
res.expressions.block_public_policy.constant_value == true
res.expressions.ignore_public_acls.constant_value == true
res.expressions.restrict_public_buckets.constant_value == true
some ref in res.expressions.bucket.references
}
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_s3_bucket"
"create" in rc.change.actions
addr := split(rc.address, "[")[0] # drop any count / for_each index
not addr in locked_down_buckets
msg := sprintf("%s has no public access block, so an ACL or bucket policy can still make it public", [rc.address])
}
# The loud version of the same mistake
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_s3_bucket_acl"
rc.change.after.acl in {"public-read", "public-read-write"}
msg := sprintf("%s sets a public canned ACL (%s)", [rc.address, rc.change.after.acl])
}

`locked_down_buckets` collects every bucket address that a fully closed public access block points at, and it reads `configuration` for a reason. At plan time the block's `bucket` attribute is usually `(known after apply)`, because the bucket does not exist yet and has no id. Your source code has no such doubt, and Terraform records it. `references` lists both `aws_s3_bucket.data.id` and the shorter `aws_s3_bucket.data`, and that short form is exactly what `resource_changes` calls the resource. `split(rc.address, "[")[0]` drops the `[0]` that `count` and `for_each` append to an address. And if a colleague writes `block_public_acls = var.locked`, there is no `constant_value`, the condition is false, and the bucket is denied. That is the right direction to fail: unclear means blocked.

terminal
conftest test tfplan.json --policy policy/
output
FAIL - tfplan.json - main - aws_instance.web has no owner tag
FAIL - tfplan.json - main - aws_s3_bucket.data has no public access block, so an ACL or bucket policy can still make it public
FAIL - tfplan.json - main - aws_security_group_rule.allow_ssh opens port 22 to the whole internet
3 tests, 0 passed, 0 warnings, 3 failures, 0 exceptions

Both files declare `package main`, so their rules pile into one `deny` set and one report. Fix the findings the way you would fix a failing test: add the block below, narrow the security group rule to your office range, and put `owner = "platform-team"` on the instance. Then prove it.

main.tf
resource "aws_s3_bucket_public_access_block" "data" {
bucket = aws_s3_bucket.data.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
terminal
terraform plan -out=tfplan.binary > /dev/null
terraform show -json tfplan.binary > tfplan.json
conftest test tfplan.json --policy policy/
echo "exit=$?"
output
1 test, 1 passed, 0 warnings, 0 failures, 0 exceptions
exit=0

A gate can also pass for the wrong reason. Values Terraform cannot compute until apply time are not in `after` at all. They sit in `after_unknown` instead, and a rule that compares one of them against something is comparing against nothing.

terminal
jq '.resource_changes[]
| select(.type == "aws_ebs_volume")
| .change
| {after: .after, unknown: .after_unknown}' tfplan.json
output
{
"after": {
"availability_zone": "eu-central-1a",
"multi_attach_enabled": false,
"size": 100,
"tags": null,
"type": "gp3"
},
"unknown": {
"arn": true,
"encrypted": true,
"id": true,
"iops": true,
"kms_key_id": true,
"snapshot_id": true,
"tags_all": true,
"throughput": true
}
}
"Known after apply" passes silently
`rc.change.after.encrypted == false` will never match the EBS volume above (Elastic Block Store, the virtual hard disks you attach to an instance), because `after.encrypted` does not exist. It sits in `after_unknown` as `true`. The plan sails through the gate and the disk may well be created unencrypted. Write the check as `not rc.change.after.encrypted`, so missing or unknown means denied, then set `encrypted = true` in the code so the value is known at plan time. You will collect a few false positives from resources that would have been fine anyway. Failing closed is worth them.

Test the Policy, Because It Fails Open

A Rego rule with a typo in a resource type does not throw an error. It matches nothing, every plan passes, and nobody hears a thing for months. Silence from a policy engine looks identical whether you are compliant or broken. That is why Rego gets unit tests like any other production code. Tests are ordinary rules named `test_*` that pin `input` to a hand-built fixture using the `with` keyword, and you write both directions for every rule: one plan that must be denied, one that must pass.

policy/s3_test.rego
package main
import rego.v1
bucket_change := {"resource_changes": [{
"address": "aws_s3_bucket.data",
"type": "aws_s3_bucket",
"change": {
"actions": ["create"],
"after": {"bucket": "acme-analytics-raw", "tags": {"owner": "platform-team"}},
},
}]}
closed_block := {"root_module": {"resources": [{
"address": "aws_s3_bucket_public_access_block.data",
"type": "aws_s3_bucket_public_access_block",
"expressions": {
"bucket": {"references": ["aws_s3_bucket.data.id", "aws_s3_bucket.data"]},
"block_public_acls": {"constant_value": true},
"block_public_policy": {"constant_value": true},
"ignore_public_acls": {"constant_value": true},
"restrict_public_buckets": {"constant_value": true},
},
}]}}
test_bucket_without_public_access_block_is_denied if {
plan := object.union(bucket_change, {"configuration": {"root_module": {"resources": []}}})
count(deny) == 1 with input as plan
}
test_bucket_with_public_access_block_is_allowed if {
plan := object.union(bucket_change, {"configuration": closed_block})
count(deny) == 0 with input as plan
}
terminal
opa test policy/ -v
output
data.main.test_ssh_open_to_everyone_is_denied: PASS (1.24ms)
data.main.test_ssh_from_the_office_is_allowed: PASS (712.6µs)
data.main.test_bucket_without_public_access_block_is_denied: PASS (1.03ms)
data.main.test_bucket_with_public_access_block_is_allowed: PASS (688.1µs)
--------------------------------------------------------------------------------
PASS: 4/4

`opa test` runs anything named `test_*` in the packages it loads, which is why the two network tests from the sibling file turn up in the same run. Conftest can run those tests without a second binary in your image, using `conftest verify --policy policy/`, and that is the version that belongs in CI. The fixture that hands the bucket an `owner` tag is not decoration: leave it out and the tag rule fires too, so `count(deny) == 1` fails for a reason that has nothing to do with S3. The negative test matters more than it looks. It is the one that breaks when you tighten a rule until it blocks everything, which is the other way policy engines get deleted.

One trap to close before it bites you. `input.configuration.root_module.resources` covers the root module only. Wrap that bucket in a module and the plan calls it `module.storage.aws_s3_bucket.data`, while the configuration files it under `module_calls.storage.module.resources` with the bare address. The cross-resource match quietly stops matching, and a rule that matches nothing is a pass. Walk `module_calls` recursively, and keep a module-shaped fixture in your tests so nobody can break that walk in silence.

Put the Gate Where the Credentials Are

A policy check on a pull request stops nothing if an engineer can run `terraform apply` from a laptop with their own admin keys. The gate has to sit on the only road into production. Take write credentials away from humans, hand them to the pipeline identity, and the plan that gets graded becomes the plan that gets applied. `set -euo pipefail` does the rest, because any non-zero exit ends the job.

ci/policy-gate.sh
#!/usr/bin/env bash
set -euo pipefail
# 1. unit-test the rules before trusting anything they say
conftest verify --policy policy/
# 2. build the plan document
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json
# 3. grade it: any deny message exits 1 and fails the job
# --all-namespaces also picks up policies outside package main
conftest test tfplan.json --policy policy/ --all-namespaces

Ship the rules like software. Keep them in their own repository with owners and tagged releases, and let pipelines pull a pinned version instead of copying `.rego` files between repos. `conftest push ghcr.io/acme/tf-policies:1.4.0` publishes a bundle to any OCI registry (Open Container Initiative, the same registry format container images use) and `conftest pull` fetches it back. Introduce every new rule as `warn` first, which reports without failing unless you pass `--fail-on-warn`, watch how often it would have fired, then promote it to `deny`. Conftest's `exception` rules are the auditable escape hatch, which is why the summary line counts exceptions separately from failures. Run `opa fmt -w policy/` and `regal lint policy/` on the way in. HashiCorp Sentinel does a similar job, but it is proprietary and tied to HCP Terraform (HashiCorp's hosted service, formerly Terraform Cloud), which now runs OPA policy sets too.

Quick check
01Your rule denies an ingress rule when `cidr_blocks` contains `0.0.0.0/0` and `from_port <= 22` and `to_port >= 22`. A teammate merges an `aws_security_group_rule` with `protocol = "-1"`, `from_port = 0`, `to_port = 0`, `cidr_blocks = ["0.0.0.0/0"]`. Conftest passes the plan. What happened?
Incorrect — Both values are sitting in `change.after`, set to 0. Nothing was hidden from the policy.
Correct — The values are readable and meaningless, which is why `covers_port` has a definition that matches on `protocol == "-1"` instead of the numbers.
Incorrect — A misplaced package would have silenced every other finding in the same file, and those still reported.
Incorrect — The plan JSON encodes ports as numbers and Rego compares them as numbers. Type confusion is not the failure here.
02The owner-tag rule denies a resource with `not rc.change.after.tags.owner`, the lesson's 'deny on absence' habit. Which case does this pattern catch that testing for a specific bad value would not?
Incorrect — a present tag is a truthy value, so `not ...owner` is false and the rule passes; catching a bad value would need a different check.
Correct — `not` is true when the key is missing, when `tags` is null, and when no tags block was written, forcing the resource to prove it is compliant.
Incorrect — region has nothing to do with the presence of a tag; that would need a separate rule.
Incorrect — deletions are excluded by the `"create" in rc.change.actions` line, not by the absence check.
03You add a rule `... rc.type == "aws_ebs_volume"; rc.change.after.encrypted == false ...` for Elastic Block Store (EBS) volumes. Volumes with no `encrypted` argument keep passing, even though they will be created unencrypted. What is happening, and what is the fix?
Incorrect — the problem is not a string/boolean mismatch; `encrypted` is not present in `after` at all.
Incorrect — a misplaced package would silence every rule in the file, but the other rules still report.
Correct — comparing an absent attribute matches nothing, so fail closed on missing-or-unknown and make the value known at plan time.
Incorrect — the lesson queries EBS volumes straight out of `resource_changes`, so they are certainly there.

One more thing worth doing this week. Run the gate against `main` on a schedule, not only on pull requests. A rule you write today does not retroactively fail the buckets you shipped last year, and nothing else is going to tell you how many of those there are. A nightly `conftest test` over a fresh plan gives you that number, and the number is the only honest measure of whether the policy is doing anything.

Related