Policy-as-code: OPA, Conftest & Sentinel
Enforce rules on infrastructure.
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.
terraform plan -out=tf.planterraform show -json tf.plan > plan.json
Terraform used the selected providers to generate the following executionplan. Resource actions are indicated with the following symbols:+ createTerraform 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.planTo perform exactly these actions, run the following command to apply:terraform apply "tf.plan"
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.
jq '.resource_changes[] | {address, actions: .change.actions, after: .change.after}' plan.json
{"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.
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_changesrc.type == "aws_security_group_rule"# Judge only what this run is about to build. Skip no-ops and deletes.some action in rc.change.actionsaction in {"create", "update"}# "type" inside "after" is the AWS direction, not the Terraform type.rc.change.after.type == "ingress"some port in admin_portsrc.change.after.from_port <= portrc.change.after.to_port >= portopen_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_changesrc.type == "aws_security_group_rule"some action in rc.change.actionsaction 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_blockscidr == "0.0.0.0/0"}open_to_world(after) if {some cidr in after.ipv6_cidr_blockscidr == "::/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.
conftest test --policy policy/ plan.jsonecho "exit: $?"
FAIL - plan.json - main - aws_security_group_rule.ssh_ingress exposes port 22 to the public internetFAIL - plan.json - main - aws_security_group_rule.ssh_ingress exposes port 3389 to the public internet3 tests, 1 passed, 0 warnings, 2 failures, 0 exceptionsexit: 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.
package mainimport 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}
conftest verify --policy policy/
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.
jq '.resource_changes[] | select(.address == "aws_security_group_rule.app_ingress") | .change' plan.json
{"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.
deny_unknown_cidrs contains msg if {some rc in input.resource_changesrc.type == "aws_security_group_rule"some action in rc.change.actionsaction 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 == truemsg := 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.
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" andrc.mode is "managed" and(rc.change.actions contains "create" orrc.change.actions contains "update") andrc.change.after.type is "ingress"}no_public_ssh = rule {all ingress_rules as _, rc {rc.change.after.from_port > 22 orrc.change.after.to_port < 22 ornot ((rc.change.after.cidr_blocks else []) contains "0.0.0.0/0")}}main = rule { no_public_ssh }
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
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.
terraform plan -detailed-exitcode -lock=false -input=false -no-colorecho "exit: $?"
Note: Objects have changed outside of TerraformTerraform detected the following changes made outside of Terraform since thelast "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 -> falseid = "acme-assets-prod"# (3 unchanged attributes hidden)}Unless you have made equivalent changes to your configuration, or ignored therelevant attributes using ignore_changes, the following plan may includeactions to undo or respond to these changes.Terraform used the selected providers to generate the following executionplan. Resource actions are indicated with the following symbols:~ update in-placeTerraform 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 -> trueid = "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'tguarantee 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.
conftest test --policy policy/ plan.jsonecho "exit: $?"
0 tests, 0 passed, 0 warnings, 0 failures, 0 exceptionsexit: 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.
#!/usr/bin/env bashset -euo pipefail# These files hold cleartext secrets. Do not let them outlive the job.trap 'rm -f tf.plan plan.json results.json' EXITterraform plan -out=tf.plan -input=false -lock-timeout=60sterraform 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 ]; thenecho "policy gate: zero rules evaluated, failing the build" >&2exit 1fijq -r '.[] | .failures[]? | "FAIL " + .msg' results.jsonecho "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.
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?main namespace unless you pass --namespace or --all-namespaces. Zero tests means zero enforcement, with a green tick on top.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.