Module 4: Policy As Code With Conftest
6. Hands-on: the "never destroy `Shipments`" policy
Description
This lesson writes no-destroy-shipments.rego, this module's first policy that runs against Andes Cargo's real terraform plan, not a test YAML. It closes RISK-MAP.md's TM-06 — "no control stops a plan that destroys Shipments" — and answers, with an executable policy, the question Module 1, lesson 6, deliberately left open.
Connection to the module
Go back, for a moment, to this guide's Module 1, lesson 6: the Claude Code destroy incident — an agent with real credentials ran terraform destroy over production infrastructure, and a human approved the warning instead of stopping it — was revisited from the angle of "what non-human control would have stopped it?" That lesson mapped every control Modules 2 through 7 of this guide build against the exact point in the failure chain where it would have stopped it. This lesson is one of those answers, the most direct of all: a policy that asks no human if they're sure, shows no warning anyone could approve without reading — it simply fails the plan, with a non-zero exit code, before there's any possibility of an apply.
Analogy: the circuit breaker, not the warning sign
A sign that says "danger of electric shock" is a warning — it works if someone reads it and decides, with good judgment, not to touch the wire. A circuit breaker is different by design: it asks nobody, shows no text anyone could ignore under pressure or distraction — when it detects an overload, it cuts the circuit, regardless of whether the overload was caused by a distracted person, a defective appliance, or — the case that makes this example matter in 2026 — an automated system that went out of control while nobody was watching it at that exact instant.
The Module 1, lesson 6, incident was a warning sign a human read and decided to ignore: Terraform showed the complete plan, with the destruction clearly marked, and the approval came anyway. no-destroy-shipments.rego is the circuit breaker: it shows no warning anyone has to interpret under pressure — it fails the process, with an exit code a CI pipeline can't ignore even if it wanted to — regardless of whether the destructive plan was generated by a rushed person or an agent with credentials running with no direct supervision.
Step 1 — The rule, in prose, before Rego
From the resource_change you explored in lesson 5, the condition you want to forbid is precise: any plan where a resource of type aws_dynamodb_table, whose name is exactly "Shipments", has "delete" among its actions. Note the nuance lesson 5 already previewed: you don't compare change.actions against exact ["delete"] — that would let a replacement through, ["delete", "create"], which also deletes and recreates the table, losing all its data in the process — you compare whether "delete" is contained in that list.
Step 2 — The complete policy
# policy/no-destroy-shipments.rego
package main
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_dynamodb_table"
"delete" in rc.change.actions
table_name := object.get(rc.change.before, "name", rc.address)
table_name == "Shipments"
msg := sprintf(
"%s: destroying the Shipments table (name=%q) is forbidden — this plan must never be applied",
[rc.address, table_name],
)
}
Three new pieces compared to lesson 4's policy, all necessary to work against a plan's real structure:
some rc in input.resource_changes— walks the complete list of changes, testing the rule against each one. This is Rego's way of expressing what in Python would befor rc in input["resource_changes"]:— with the paradigm difference lesson 2 already anticipated: you don't accumulate a result step by step, you declare the rule is true if there exists at least onercthat satisfies the rest of the conditions."delete" in rc.change.actions— Rego'sinoperator, checking list membership, exactly as lesson 5 warned you had to do it.object.get(rc.change.before, "name", rc.address)— reads thenamefield fromchange.before(the resource's state before the destruction, populated because adeletealways hasbefore, neverafter), with a fallback value (rc.address) if for some reasonnameweren't present. This function —object.get(object, key, default)— is Rego's safe way to read a field without the entire rule failing if that key doesn't exist; you'll use it again, for the same purpose, in lesson 7.
Step 3 — Generating a plan that does destroy the table, to really test the policy
A policy you've never seen fail isn't a tested policy — it's a promise with no evidence. To confirm no-destroy-shipments.rego really detects a destruction, you need a real plan where Shipments gets destroyed. The standard, read-only way to generate that plan — the same one you'd use to preview what a terraform destroy would do before confirming it — is terraform plan's -destroy mode:
terraform plan -destroy -out=destroy.tfplan
terraform show -json destroy.tfplan > destroy.tfplan.json
What to expect (literal, executed to write this lesson — trimmed to the relevant resource):
# aws_dynamodb_table.shipments will be destroyed
- resource "aws_dynamodb_table" "shipments" {
- arn = "arn:aws:dynamodb:us-east-1:000000000000:table/Shipments" -> null
- billing_mode = "PAY_PER_REQUEST" -> null
- hash_key = "shipmentId" -> null
- id = "Shipments" -> null
- name = "Shipments" -> null
- tags = {
- "Environment" = "dev"
[...]
}
Plan: 0 to add, 0 to change, 1 to destroy.
terraform plan -destroy is exactly the command that previews a terraform destroy without running it — the same "read the plan before applying it" discipline you already know from terraform-and-iac-guide, applied to the most dangerous case of all: destroying, not creating. It's, in spirit, the same command an engineer (or an agent, like the one in Module 1's incident) would run before a real terraform destroy — and it's exactly the point where no-destroy-shipments.rego is going to intercept the attempt, before it goes any further.
Step 4 — conftest test, against the destruction attempt: FAIL
conftest test destroy.tfplan.json -p policy/no-destroy-shipments.rego
What to expect (literal, executed to write this lesson):
FAIL - destroy.tfplan.json - main - aws_dynamodb_table.shipments: destroying the Shipments table (name="Shipments") is forbidden — this plan must never be applied
1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions
echo $?
1
Compare the complete message, field by field, against Step 2's policy: %s became aws_dynamodb_table.shipments (the real rc.address), %q became "Shipments" (the real table_name, with quotes — that's exactly how Rego's %q formats it). This is the same sprintf mechanism from the policy, working against real data for the first time in this module.
Step 5 — A harmless change, the same plan.json from lesson 5: PASS
The same policy, without changing a line, against andes-cargo-infra/'s normal plan — the one that only adds resources, generated in lesson 5, with no destruction at all:
conftest test tfplan.json -p policy/no-destroy-shipments.rego
What to expect (literal, executed to write this lesson):
1 test, 1 passed, 0 warnings, 0 failures, 0 exceptions
echo $?
0
This is the result that makes a policy trustworthy in a real pipeline: it not only detects the bad case (Step 4), it also lets through, with no friction, any change that doesn't violate it — a plan adding fourteen new resources, none of them a Shipments destruction, passes exactly as it should.
Updating RISK-MAP.md: the first row this module closes
- | 5 | TM-06 | Denial of service | No control blocks a destructive `plan` on `Shipments` | `no-destroy-shipments.rego` | M4 | Open |
+ | 5 | TM-06 | Denial of service | No control blocks a destructive `plan` on `Shipments` | `no-destroy-shipments.rego` | M4 | Resolved (M4.6): `no-destroy-shipments.rego` fails any plan where `aws_dynamodb_table.shipments` has "delete" in its actions, verified via `conftest test` against a real `terraform plan -destroy` output |
Common mistakes
Comparing rc.change.actions against "delete" as if it were a string, instead of using in (repeated from lesson 5, with real consequences here). What happens: someone writes rc.change.actions == "delete" in the policy. How to spot it: the policy never fires, not even against Step 3's destroy.tfplan.json — because rc.change.actions is ["delete"], a list, and a list is never equal to a string even if it contains exactly that value. How to fix it: use "delete" in rc.change.actions, exactly as in Step 2 — this is the most silent of this lesson's three mistakes, because it produces no error message at all: the policy simply never fails, giving you a false sense of security.
Writing the policy against rc.change.after instead of rc.change.before to read the table's name (data-structure mistake, specific to destructions). What happens: someone, used to after being where the relevant information lives (as in lesson 5, where the resource was being created), tries to read rc.change.after.name for a destruction. How to spot it: rego_type_error or a condition that never holds, because change.after is null for any resource being destroyed — it has no field to read. How to fix it: for a destruction, the resource's complete information lives in change.before (the state before the plan, which is the only thing that exists when the outcome is that the resource stops existing) — the relationship is exactly the reverse of a creation's, exactly as lesson 5 already predicted in its Exercise 3.
Testing the policy only against the destructive plan, without also confirming a harmless change passes (incomplete coverage mistake). What happens: someone writes the rule, watches it fail against destroy.tfplan.json, and calls the lesson done without running Step 5. How to spot it: if you never ran conftest test against a plan that destroys nothing. How to fix it: a policy you only know fails, but never confirmed lets the correct case through, could be written wrong in a way that makes it fail always — for example, if you accidentally removed the rc.type == "aws_dynamodb_table" condition and it now fires against any destroyed resource type. This lesson's Step 5 isn't optional: it's half the evidence that the policy does exactly what it's supposed to do, no more, no less.
Exercises
Exercise 1 — Extend the policy to also protect the andes-cargo-shipment-docs bucket. Without changing the existing rule, add a second deny rule to no-destroy-shipments.rego (or a new file inside policy/) that fires if any aws_s3_bucket with name or bucket equal to "andes-cargo-shipment-docs" has "delete" in its actions.
See solution
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_s3_bucket"
"delete" in rc.change.actions
bucket_name := object.get(rc.change.before, "bucket", rc.address)
bucket_name == "andes-cargo-shipment-docs"
msg := sprintf(
"%s: destroying the shipment-docs bucket (bucket=%q) is forbidden — this plan must never be applied",
[rc.address, bucket_name],
)
}
The pattern is identical to Shipments's, with two changes: rc.type == "aws_s3_bucket" instead of aws_dynamodb_table, and the field identifying the resource is bucket (an S3 bucket's name in Terraform's schema), not name. If your solution follows this same structure — same in operator, same use of object.get with a fallback value — you have this lesson's pattern correctly generalized.
Exercise 2 — Explain why this policy uses object.get with a fallback value, instead of reading rc.change.before.name directly. What would happen, in theory, if rc.change.before didn't have a name field for some reason, and the policy tried to read it with direct access (rc.change.before.name) instead of object.get?
See solution
With direct access (rc.change.before.name), if the name field didn't exist in that object for any reason, Rego would treat that expression as undefined (not as an error) — the entire rule simply wouldn't fire for that resource, with no notice that something was different than expected. With object.get(rc.change.before, "name", rc.address), on the other hand, you always get a value — the real name if it exists, or the resource's address as a fallback if not — meaning the table_name == "Shipments" comparison always evaluates against something, never left undefined by a missing field. It's a way to make the policy more resilient against plan structures slightly different from the ones you tested, without that meaning it's less strict on the case that actually matters.
Exercise 3 — Predict what conftest test would show if the destructive plan included, besides Shipments, the destruction of an unrelated resource (for example, module.app_server_role.aws_iam_role_policy.this). Without running it yet, predict: would this lesson's policy fire for that second resource too? Why or why not?
See solution
It would not fire for the second resource — no-destroy-shipments.rego, as written, explicitly filters by rc.type == "aws_dynamodb_table" before checking any other condition. A destroyed aws_iam_role_policy, no matter how unexpected that change is, doesn't satisfy that first condition, so the rule never gets to evaluate the rest of the body for that resource. This is, on purpose, a narrow-scope policy — it protects exactly one resource, Shipments, not "any unexpected destruction" — protecting other specific resources (as this lesson's Exercise 1 did with the bucket) requires writing an additional rule for each one, not broadening this rule to cover too much.
Summary and next step
In this lesson you wrote no-destroy-shipments.rego, the policy that replaces cicd-and-gitops-on-aws-guide's handcrafted grep with a real Rego rule, run against Andes Cargo's terraform plan. You confirmed it in both directions: a real FAIL against a plan -destroy that does destroy the table (conftest test destroy.tfplan.json, exit code 1), and a real PASS against the normal plan that only adds resources (exit code 0). You closed RISK-MAP.md's TM-06 — the first of the two rows this module resolves, and the direct answer to the question Module 1, lesson 6, left open about the Claude Code destroy incident.
Before moving on you should be able to: write, from memory, a policy that walks resource_changes filtering by type and by "delete" in actions; explain why this policy reads change.before, not change.after; and generate a test destructive plan yourself with terraform plan -destroy, with no real apply needed.
Lesson 7 writes two more policies, with an important structural difference: instead of checking whether a resource gets destroyed, they're going to check the content of an IAM policy and a bucket's configuration — this module's first time a Rego rule needs to read a field that, within the JSON, is itself another JSON document encoded as a string.
Resources
- Terraform CLI —
terraform plan,-destroymode — the official reference for the mode used in this lesson's Step 3 to generate a read-only destructiveplan. - Open Policy Agent — Built-in Functions,
object.get— the reference for the function used to safely read fields in this policy. - This course, Module 1, lesson 6 — the Claude Code
destroyincident, the open question this lesson answers with an executable policy. - This course, Module 1, lesson 8 (
RISK-MAP.md) — theTM-06row this lesson closes.