CoursesInfrastructure as Code & automationPolicy-as-code: OPA, Conftest & Sentinel

Policy-as-code: OPA, Conftest & Sentinel

Enforce rules on infrastructure.

Advanced14 min · lesson 17 of 23

A sign above the sink that reads WASH YOUR HANDS is documentation. A door that stays locked until the tap has run for twenty seconds is a control. Most rules about infrastructure live on the sign: a wiki page, a checklist item, a line in a code review that someone remembers on a good day.

Infrastructure as code (IaC, writing your servers, networks and permissions as text files that a tool reads and builds for you) made changes cheap. Cheap cuts both ways. One line flips a private storage bucket to public. One line widens a firewall rule from your office address range to the whole internet, and it reviews the same as any other line. Policy-as-code is the door: your rules written as files a machine can run, executed inside the pipeline (the chain of automated steps that fires every time someone pushes code) and failing the build before anything reaches the cloud.

You are going to write one rule, watch it fail a real Terraform plan, unit test it, find the hole in it, and then wire it so nobody can walk past it.

OPA, Rego and Conftest

OPA (Open Policy Agent) is a referee. Hand it the rulebook and a description of what happened, and it tells you whether that was a foul and which rule you broke. It has no opinion about the sport. The data you feed it can be a Terraform plan, a Kubernetes manifest or a login request, and that indifference is why the same skill turns up in Kubernetes admission control, in CI (continuous integration, the automated checks that run on every change), and inside application authorization code.

Rego is the language the rulebook is written in. Think wanted poster rather than search party. You describe what a bad thing looks like and OPA goes and finds every match. A rule body is a list of conditions that must all hold at the same time. Every line true, the rule fires. One line false or undefined, it stays quiet. You never write the loop that walks the data.

Conftest is the clerk who fetches the right documents for the referee. It reads files off disk and parses them (JSON, YAML, Dockerfiles, INI and a dozen other formats, including HCL, the HashiCorp Configuration Language that Terraform files are written in), hands the parsed structure to OPA in a variable called input, collects any deny messages that come back, and exits non-zero when there is at least one. You write policy. Conftest handles files and exit codes.

What the Policy Actually Sees

Look at the thing your rule will inspect before you write the rule. Terraform can dump a saved plan as JSON (JavaScript Object Notation, a plain-text way of writing keys, lists and values), and that dump is what the policy reads. Two commands, because the plan has to be written to a file first.

terminal
terraform plan -out=tf.plan
terraform show -json tf.plan > plan.json
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.ssh_ingress will be created
+ resource "aws_security_group_rule" "ssh_ingress" {
+ cidr_blocks = [
+ "0.0.0.0/0",
]
+ from_port = 0
+ id = (known after apply)
+ protocol = "tcp"
+ security_group_id = "sg-0a1b2c3d4e5f60718"
+ security_group_rule_id = (known after apply)
+ self = false
+ to_port = 65535
+ type = "ingress"
}
Plan: 1 to add, 0 to change, 0 to destroy.
Saved the plan to: tf.plan
To perform exactly these actions, run the following command to apply:
terraform apply "tf.plan"
The plan file is a secret
terraform plan -out=tf.plan writes every value Terraform is about to send to the provider, database passwords and private keys included, in cleartext. terraform show -json copies them into plan.json, where the after_sensitive field marks which values are sensitive without hiding them. Treat both files the way you treat a credential. Never commit them, never publish them as a downloadable build artifact, and delete them when the job ends.

Now read the JSON with jq, the command-line tool for slicing JSON apart, and pull out the one resource that matters. 0.0.0.0/0 is CIDR notation (Classless Inter-Domain Routing, the a.b.c.d/n way of writing a range of addresses) for every address on the internet.

terminal
jq '.resource_changes[] | {address, actions: .change.actions, after: .change.after}' plan.json
output
{
"address": "aws_security_group_rule.ssh_ingress",
"actions": [
"create"
],
"after": {
"cidr_blocks": [
"0.0.0.0/0"
],
"description": null,
"from_port": 0,
"ipv6_cidr_blocks": null,
"prefix_list_ids": null,
"protocol": "tcp",
"security_group_id": "sg-0a1b2c3d4e5f60718",
"self": false,
"to_port": 65535,
"type": "ingress"
}
}

Three fields carry the security meaning. type is the kind of resource. change.actions is what Terraform is about to do to it: create, update, delete or no-op. change.after is the state the resource will be in once applied, and it is null for a delete, so a rule that reads it falls silent on deletes without any help from you. The action that bites is no-op. Resources this plan is not touching at all still appear in resource_changes carrying that action, so a rule that skips the action check will fail somebody's build over a security group that has been sitting in the account since last year and has nothing to do with their change. That is how a team learns to route around the gate.

A Rule That Survives Contact

The obvious rule is "deny anything with port 22 open to 0.0.0.0/0". Read the plan again. from_port is 0 and to_port is 65535. SSH (Secure Shell, the protocol you use to log into a server's command line from somewhere else) sits inside that range, reachable from every address on the internet, and the obvious rule never fires, because it compares from_port against 22 and gets 0. Nobody has to be clever to walk past that check. A copy-pasted allow-all rule does it by accident, and somebody who wants a way in writes exactly that on purpose and labels it a debugging change in the pull request.

policy/aws_security_groups.rego
package main
# Enables the "if" and "contains" keywords on OPA 0.59 and later 0.x releases.
# Accepted and does nothing on OPA 1.x, where that syntax is the default.
import rego.v1
# Ports that never face the public internet: SSH and RDP.
admin_ports := {22, 3389}
deny_public_admin_ports contains msg if {
some rc in input.resource_changes
rc.type == "aws_security_group_rule"
# Judge only what this run is about to build. Skip no-ops and deletes.
some action in rc.change.actions
action in {"create", "update"}
# "type" inside "after" is the AWS direction, not the Terraform type.
rc.change.after.type == "ingress"
some port in admin_ports
rc.change.after.from_port <= port
rc.change.after.to_port >= port
open_to_world(rc.change.after)
msg := sprintf("%s exposes port %d to the public internet", [rc.address, port])
}
deny_public_all_protocols contains msg if {
some rc in input.resource_changes
rc.type == "aws_security_group_rule"
some action in rc.change.actions
action in {"create", "update"}
rc.change.after.type == "ingress"
# "-1" is AWS for every protocol, and the port fields stop meaning anything.
rc.change.after.protocol == "-1"
open_to_world(rc.change.after)
msg := sprintf("%s allows every protocol from the public internet", [rc.address])
}
# Two definitions of one name behave like "or".
open_to_world(after) if {
some cidr in after.cidr_blocks
cidr == "0.0.0.0/0"
}
open_to_world(after) if {
some cidr in after.ipv6_cidr_blocks
cidr == "::/0"
}

deny_public_admin_ports contains msg if { ... } builds a set of message strings. Conftest treats any rule called deny, or named deny_ plus something readable, as a blocking check, so you can write several small ones with honest names instead of one monster. some rc in input.resource_changes walks every planned change. Port 3389 is RDP (Remote Desktop Protocol, the Windows way of getting a desktop session on a machine somewhere else). The two port comparisons ask whether an admin port falls anywhere inside the declared range, which catches 0 to 65535 as well as 22 to 22. open_to_world is written twice, and two definitions of one name in Rego mean "or", so one covers IPv4 and the other covers IPv6. ::/0 is the same mistake spelled differently, and plenty of policies never look for it.

terminal
conftest test --policy policy/ plan.json
echo "exit: $?"
output
FAIL - plan.json - main - aws_security_group_rule.ssh_ingress exposes port 22 to the public internet
FAIL - plan.json - main - aws_security_group_rule.ssh_ingress exposes port 3389 to the public internet
3 tests, 1 passed, 0 warnings, 2 failures, 0 exceptions
exit: 1

Two ports, one resource, two messages, exit code 1. That non-zero exit is the whole mechanism: whatever runs your pipeline sees a failed step and stops. Ready-made scanners (Checkov, Trivy, KICS) ship hundreds of generic rules like this one and are worth running alongside your own. Keep your Rego for the rules only you have. "Production subnets must route through the shared transit gateway." "Every bucket carries an owner tag naming a team that exists." No vendor ships those.

Check one thing before you trust this rule against your own code. Version 5 of the AWS provider added aws_vpc_security_group_ingress_rule as the successor to aws_security_group_rule, and the field names moved: cidr_ipv4 holds a single address range instead of a list, and the protocol lives in ip_protocol. A policy that only knows the old resource type waves the new one straight through without a word. Search your repository for both names, and write the rule for both.

Testing the Policy Itself

Code that has never failed on purpose is code you cannot trust. Rego ships its own test runner: any rule named test_something that comes out true counts as a pass, and with input as swaps in a fake document, so you can hand the policy a plan that never existed. Prove both directions. A bad plan gets caught, a good one is left alone. Your first false positive is what gets the whole gate switched off.

policy/aws_security_groups_test.rego
package main
import rego.v1
# Build a one-resource plan around whatever "after" block we want to try.
plan(after) := {"resource_changes": [{
"address": "aws_security_group_rule.ssh_ingress",
"type": "aws_security_group_rule",
"change": {"actions": ["create"], "after": after},
}]}
test_wide_port_range_is_denied if {
mock := plan({
"type": "ingress",
"protocol": "tcp",
"from_port": 0,
"to_port": 65535,
"cidr_blocks": ["0.0.0.0/0"],
})
# Both admin ports fall inside 0-65535, so two distinct messages.
count(deny_public_admin_ports) == 2 with input as mock
}
test_office_range_is_allowed if {
mock := plan({
"type": "ingress",
"protocol": "tcp",
"from_port": 22,
"to_port": 22,
"cidr_blocks": ["203.0.113.0/24"],
})
count(deny_public_admin_ports) == 0 with input as mock
}
terminal
conftest verify --policy policy/
output
2 tests, 2 passed, 0 warnings, 0 failures, 0 exceptions

The Blind Spot in Every Plan

A plan can only describe values Terraform already knows. Some values are a blank on the form until the work is done: an address belonging to a machine that does not exist yet, a range fetched from a parameter store at apply time. Terraform labels those "known after apply", leaves them out of after altogether, and records them in a parallel structure called after_unknown.

terminal
jq '.resource_changes[] | select(.address == "aws_security_group_rule.app_ingress") | .change' plan.json
output
{
"actions": [
"create"
],
"before": null,
"after": {
"description": "app tier",
"from_port": 22,
"ipv6_cidr_blocks": null,
"prefix_list_ids": null,
"protocol": "tcp",
"self": false,
"to_port": 22,
"type": "ingress"
},
"after_unknown": {
"cidr_blocks": true,
"description": false,
"from_port": false,
"id": true,
"ipv6_cidr_blocks": false,
"prefix_list_ids": false,
"protocol": false,
"security_group_id": true,
"security_group_rule_id": true,
"self": false,
"to_port": false,
"type": false
},
"before_sensitive": false,
"after_sensitive": {}
}

Your rule asks for after.cidr_blocks, gets nothing, the condition is undefined, the rule never fires, and the build goes green over a change that might open port 22 to the entire internet. Undefined is not the same as safe. Turn the gap into a decision a human has to make.

policy/aws_security_groups.rego
deny_unknown_cidrs contains msg if {
some rc in input.resource_changes
rc.type == "aws_security_group_rule"
some action in rc.change.actions
action in {"create", "update"}
# true means the whole list is unknown. A list where only some elements
# are unknown arrives as an array like [false, true], so match that shape
# too if your modules assemble CIDR lists piece by piece.
rc.change.after_unknown.cidr_blocks == true
msg := sprintf("%s: CIDR list is unknown at plan time, policy cannot check it", [rc.address])
}

A rule that never matches and a rule that finds nothing produce the same green tick. A typo in a resource type, a policy sitting in the wrong package, a value unknown at plan time: all three turn into false confidence. So every rule you write needs a test that makes it fire, and at least once you should point the whole policy set at a deliberately terrible plan and watch it go red. A check you have never seen fail is a check you cannot count on.

Sentinel and Enforcement Levels

Conftest is a guard you hire and post at your own door. Sentinel is the guard the building already employs. It is HashiCorp's own policy language, built into HCP Terraform (HashiCorp Cloud Platform's hosted Terraform service, previously called Terraform Cloud) and Terraform Enterprise, and it runs between plan and apply inside their workflow, so there is no pipeline step for anyone to forget to add.

sentinel/no-public-ssh.sentinel
import "tfplan/v2" as tfplan
# Every ingress rule this run would create or update.
ingress_rules = filter tfplan.resource_changes as _, rc {
rc.type is "aws_security_group_rule" and
rc.mode is "managed" and
(rc.change.actions contains "create" or
rc.change.actions contains "update") and
rc.change.after.type is "ingress"
}
no_public_ssh = rule {
all ingress_rules as _, rc {
rc.change.after.from_port > 22 or
rc.change.after.to_port < 22 or
not ((rc.change.after.cidr_blocks else []) contains "0.0.0.0/0")
}
}
main = rule { no_public_ssh }
sentinel/sentinel.hcl
policy "no-public-ssh" {
source = "./no-public-ssh.sentinel"
enforcement_level = "hard-mandatory"
}
policy "require-owner-tag" {
source = "./require-owner-tag.sentinel"
enforcement_level = "soft-mandatory"
}

The enforcement_level line matters more than the policy above it. advisory prints the violation and lets the run carry on. soft-mandatory stops the run, but a user holding the override permission can wave it through, and their name is recorded against that override. hard-mandatory has no override at all: the policy passes or the code changes. Pick on purpose. Use advisory for a rule you are still tuning and hard-mandatory for the handful of rules that would have someone paged at 3am.

One difference between the two engines is worth knowing. Where Rego goes quiet on a missing value, Sentinel does the opposite: an undefined result makes the rule fail rather than pass. That is why else [] is doing real work in the policy above. Without it, a rule that sets only IPv6 ranges would leave cidr_blocks undefined and block a change that was never unsafe. And if you already have Rego you like, HCP Terraform runs OPA policy sets natively too, with two enforcement levels, advisory and mandatory. You do not have to rewrite anything in Sentinel to get an enforcement point inside the run itself.

Where to Put the Gate

Where a bad change can still be stopped
1Pre-commit hook
conftest on the laptop, skippable with --no-verify
2Pull request
plan to JSON, conftest test, required check blocks the merge
3Apply gate
re-check the saved plan file, blocks terraform apply
4Cloud guardrail
service control policy or permission boundary denies the API call
5Nightly drift scan
plan -detailed-exitcode, exit 2 raises an alert

Each box catches a different miss. The pre-commit hook answers in two seconds and is defeated by git commit --no-verify, so treat it as a convenience rather than a control. The pull request check, where a teammate reviews the change before it merges, is where feedback arrives fast enough for a developer to act on it. The apply gate exists because the plan CI checked is not always the plan that gets applied: the job is re-run four hours later, the recorded state of your infrastructure has moved on, and the fresh plan does something the reviewed one never mentioned. Save the plan file, check that file, apply that file.

The cloud guardrail is the strongest of the preventive boxes, because it does not care how the request arrived. A service control policy (an organization-wide rule set at the cloud provider that overrides whatever permissions an individual account hands itself) denies the API call (application programming interface, the machine-to-machine door that every console click and script goes through) whether it came from your pipeline, the web console, or a leaked access key.

The last box is the one teams skip. Every gate above it only sees changes that travel through the pipeline. A console click does not. A stolen key does not. Neither does a break-glass login at 2am, the emergency account with wide permissions that exists for the night everything is on fire. The gap between what your code says and what the account actually looks like has a name: drift. So ask Terraform, on a schedule, whether reality still matches the code.

terminal
terraform plan -detailed-exitcode -lock=false -input=false -no-color
echo "exit: $?"
output
Note: Objects have changed outside of Terraform
Terraform detected the following changes made outside of Terraform since the
last "terraform apply" which may have affected this plan:
# aws_s3_bucket_public_access_block.assets has been changed
~ resource "aws_s3_bucket_public_access_block" "assets" {
~ block_public_acls = true -> false
~ block_public_policy = true -> false
id = "acme-assets-prod"
# (3 unchanged attributes hidden)
}
Unless you have made equivalent changes to your configuration, or ignored the
relevant attributes using ignore_changes, the following plan may include
actions to undo or respond to these changes.
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
~ update in-place
Terraform will perform the following actions:
# aws_s3_bucket_public_access_block.assets will be updated in-place
~ resource "aws_s3_bucket_public_access_block" "assets" {
~ block_public_acls = false -> true
~ block_public_policy = false -> true
id = "acme-assets-prod"
# (3 unchanged attributes hidden)
}
Plan: 0 to add, 1 to change, 0 to destroy.
─────────────────────────────────────────────────────────────────────────────
Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.
exit: 2

Exit code 0 means no changes, 1 means the command itself failed, 2 means Terraform found differences. Alert on 2. What you are reading there is a detection: somebody switched off the public access block on a production bucket without going anywhere near the pipeline, and the nightly run found it and offered to put it back. Cloud-side detective controls belong in the same design and are faster (CloudTrail records every API call in an AWS account, AWS Config tracks how each resource is configured over time, GuardDuty watches for behaviour that looks like an attack, and the other providers have equivalents), catching that class of change in minutes rather than hours. Drift detection is what finds the changes your rules were never written to look for.

Making It Block

The common failure is not a missing policy. It is a policy that runs, prints violations in yellow, and lets the merge happen anyway. People work out inside a week that the yellow text costs them nothing, and from then on the gate is decoration.

The quieter failure is a gate that runs nothing at all. Rename the package in that policy file from main to terraform.aws for tidiness, re-run the same command, and watch what happens.

terminal
conftest test --policy policy/ plan.json
echo "exit: $?"
output
0 tests, 0 passed, 0 warnings, 0 failures, 0 exceptions
exit: 0

Conftest evaluates deny, violation and warn rules in the main namespace only, unless you pass --namespace or --all-namespaces. The package name at the top of a Rego file is that namespace. Conftest found the files, loaded them, ran nothing, and reported success. --all-namespaces fixes this one instance. Counting the checks that actually ran fixes the whole class of them.

ci/policy-gate.sh
#!/usr/bin/env bash
set -euo pipefail
# These files hold cleartext secrets. Do not let them outlive the job.
trap 'rm -f tf.plan plan.json results.json' EXIT
terraform plan -out=tf.plan -input=false -lock-timeout=60s
terraform show -json tf.plan > plan.json
# JSON output so the gate can inspect what ran, not only the exit code.
conftest test \
--policy policy/ \
--all-namespaces \
--output json \
plan.json > results.json && rc=0 || rc=$?
checks=$(jq '[.[] | .successes + (.failures | length) + (.warnings | length)] | add // 0' results.json)
if [ "$checks" -eq 0 ]; then
echo "policy gate: zero rules evaluated, failing the build" >&2
exit 1
fi
jq -r '.[] | .failures[]? | "FAIL " + .msg' results.json
echo "policy gate: $checks checks evaluated"
exit "$rc"

Two habits keep this honest. Make the gate a required status check rather than an optional one, so a red result blocks the merge button instead of sitting quietly beside it. And when a rule produces a false positive, fix it or delete it that same week, because every failure people are told to ignore teaches them that failures are ignorable.

Quick check
01Your pipeline runs conftest test --policy policy/ plan.json, prints 0 tests, 0 passed, 0 warnings, 0 failures, 0 exceptions, exits 0, and the build goes green. What has most likely happened?
Incorrect — Conftest counts rule evaluations, not resources. A rule that matches nothing still counts as a passed test, so an empty plan run against real rules reads like '2 tests, 2 passed'.
Correct — Conftest only runs deny, violation and warn rules in the main namespace unless you pass --namespace or --all-namespaces. Zero tests means zero enforcement, with a green tick on top.
Incorrect — No. Rules that pass are counted in the 'passed' figure, so a clean run against real policies reads like '3 tests, 3 passed, 0 failures'.
Incorrect — No. Input it cannot parse is an error: conftest prints a parse failure and exits non-zero rather than passing quietly.
02The Rego rule includes 'some action in rc.change.actions; action in {"create","update"}' before it ever checks the ports. Why is that action filter essential rather than decorative?
Incorrect — the plan lists every resource, including untouched ones carrying a no-op action, so the filter is doing real work.
Incorrect — a null after makes the condition undefined and the rule simply stays quiet; it does not crash.
Correct — filtering to create/update means you judge only what this run is about to build, not last year's untouched resources.
Incorrect — Conftest evaluates deny rules regardless; reading actions is not a registration requirement.
03A security group rule in the plan has its cidr_blocks 'known after apply' — it is absent from change.after and shows up as cidr_blocks: true under change.after_unknown. Your deny rule reads rc.change.after.cidr_blocks. What happens at the gate, and what should you do?
Correct — in Rego an undefined lookup silently fails the rule, so you must turn 'unknown at plan time' into its own explicit denial.
Incorrect — Rego does not treat undefined as a match; the rule simply stays silent, which is the trap.
Incorrect — a missing key is undefined, not a parse error, so Conftest keeps going and passes.
Incorrect — values fetched at apply time are exactly this case, which is why the after_unknown structure exists.

Start with three rules nobody will argue about. No storage bucket readable by the world. No admin port open to the world. No identity policy granting Action: * on Resource: *. Set all three to hard-mandatory on day one, because a rule nobody disputes is a rule you can enforce without booking a meeting about it. Add a fourth only once the first three have run green for a fortnight and nobody has asked you to switch them off.

Try this

Run terraform plan -out=tf.plan on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: the plan file is a secret. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related