Module 4: Policy As Code With Conftest
8. Project: Andes Cargo's policy library
Description
Lessons 6 and 7 wrote three policies separately, each tested in isolation. This module's final project brings them together as what they really are from the first moment a pipeline uses them: a library, complete policy/, evaluated in a single run against a plan — the result a real CI job would see, not three separate commands. You run it twice: against Andes Cargo's current plan (all three policies pass, at once), and against a plan that violates them on purpose, with two simultaneous violations (two policies fail, the third keeps passing, and all three deny messages show up in a single output).
Connection to the module
This is the module's close, and the same guided-audit exercise every project in this guide has practiced since Module 1: not "did you write three .rego files?", but "can you prove, with executed evidence, that all three work together, without one masking another's failure?" RISK-MAP.md closes this module's two rows here with consolidated evidence, not repeated lesson by lesson.
Step 1 — policy/, complete, the three files
find policy/ -type f
What to expect (literal):
policy/no-destroy-shipments.rego
policy/least-privilege-iam.rego
policy/no-public-buckets.rego
policy/no-destroy-shipments.rego (lesson 6):
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],
)
}
policy/least-privilege-iam.rego (lesson 7):
package main
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_iam_role_policy"
some action in ["create", "update"]
action in rc.change.actions
policy := json.unmarshal(rc.change.after.policy)
some statement in policy.Statement
statement.Effect == "Allow"
action_is_wildcard(statement.Action)
msg := sprintf(
"%s: statement %q allows Action \"*\" — scope it to the specific actions this role needs",
[rc.address, object.get(statement, "Sid", "<no Sid>")],
)
}
action_is_wildcard(action) if {
action == "*"
}
action_is_wildcard(action) if {
is_array(action)
"*" in action
}
policy/no-public-buckets.rego (lesson 7):
package main
public_access_block_locked_down(pab) if {
pab.block_public_acls == true
pab.block_public_policy == true
pab.ignore_public_acls == true
pab.restrict_public_buckets == true
}
deny contains msg if {
bucket_changes := [rc |
some rc in input.resource_changes
rc.type == "aws_s3_bucket"
"create" in rc.change.actions
]
pab_changes := [rc |
some rc in input.resource_changes
rc.type == "aws_s3_bucket_public_access_block"
]
count(bucket_changes) > count(pab_changes)
some rc in bucket_changes
msg := sprintf(
"%s: no aws_s3_bucket_public_access_block found for this bucket — public access is not blocked",
[rc.address],
)
}
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_s3_bucket_public_access_block"
not public_access_block_locked_down(rc.change.after)
msg := sprintf(
"%s: block_public_acls, block_public_policy, ignore_public_acls and restrict_public_buckets must all be true",
[rc.address],
)
}
All three files share package main — not by oversight, but because conftest, when pointed at a complete directory with -p policy/, loads every .rego file in that package and evaluates every deny rule across all of them in a single run. That exact behavior is what makes a policy library scale: adding a fourth policy, tomorrow, means adding a fourth file to this directory — nothing else changes about how conftest gets invoked.
Step 2 — The complete library, against the current plan: all three pass
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
conftest test tfplan.json -p policy/
What to expect (literal, executed to write this lesson):
4 tests, 4 passed, 0 warnings, 0 failures, 0 exceptions
Four tests, not three — the exact count you already saw in lesson 7: one rule from no-destroy-shipments.rego, one from least-privilege-iam.rego, and two from no-public-buckets.rego (the one checking the block exists, and the one checking it's configured correctly). All four pass, in a single run, against the fifteen-resource plan that closes this module — the same plan a real pipeline's policy-check job would evaluate before letting a merge proceed.
echo $?
0
Step 3 — A plan that violates two policies at once, on purpose
To demonstrate the complete library — not an isolated policy — detects multiple simultaneous problems, propose two real changes at once: broaden AppServerRole to "Action": "*" (violating least-privilege-iam.rego) and temporarily revert lesson 7's aws_s3_bucket_public_access_block (violating no-public-buckets.rego) — without touching Shipments, so no-destroy-shipments.rego should still pass.
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
conftest test tfplan.json -p policy/
What to expect (literal, executed to write this lesson):
FAIL - tfplan.json - main - module.app_server_role.aws_iam_role_policy.this: statement "BroadBucketAccess" allows Action "*" — scope it to the specific actions this role needs
FAIL - tfplan.json - main - module.shipment_docs_bucket.aws_s3_bucket.this: no aws_s3_bucket_public_access_block found for this bucket — public access is not blocked
4 tests, 2 passed, 0 warnings, 2 failures, 0 exceptions
echo $?
1
Read this result carefully, because it's this project's central proof: four tests evaluated — the same four as Step 2 — two passed (no-destroy-shipments.rego, which had no reason to fire because Shipments was never touched, and no-public-buckets.rego's second rule, which also has nothing to evaluate because no block exists yet in this plan), two failed — one for each real violation you introduced. No policy masked another; no policy fired for a problem that wasn't its own. This is exactly the behavior that makes a policy library trustworthy: each rule reports, precisely, only what's relevant to it, and all of them run together with no interference.
Revert both changes before closing this lesson — iam.tf and s3.tf go back to the correct state lesson 7 left, and a final run of conftest test tfplan.json -p policy/ against that state shows 4 tests, 4 passed again.
How to defend this work in an interview
A technical interviewer reviewing policy/ doesn't need you to recite Rego from memory. They need you to answer, without hesitating, three kinds of question:
- "Why these three policies, and not others?" — the answer lives in
RISK-MAP.md: two of the three (no-destroy-shipments.rego,no-public-buckets.rego) close concrete findings documented since Module 1 (TM-06,TM-04); the third (least-privilege-iam.rego) turns a one-time fix from Module 2 (TM-07) into a rule that watches forever, not a single check. - "How do you know they work, not just that they exist?" — the answer lives in lessons 6, 7, and this project: every policy has a real
FAIL, triggered on purpose, and a realPASS, against the correctplan— never "it should work," always "it ran, and this is what happened." - "What happens if two problems occur in the same
plan?" — the answer is this project's Step 3: the complete library detects both, with neither masking the other, with independent, precise messages for each.
Updating RISK-MAP.md: the complete module's summary
With TM-06 (lesson 6) and TM-04 (lesson 7) marked Resolved, RISK-MAP.md stands with four of seven rows closed at this module's close:
| Order | ID | Control | Status |
|---|---|---|---|
| 1 | TM-01 | OIDC federation + scoped trust policy | Resolved (M2.5) |
| 2 | TM-07 | Least-privilege role tightening | Resolved (M2.7) |
| 3 | TM-05 | SSM Parameter Store / Secrets Manager | Open (Module 3) |
| 4 | TM-04 | no-public-buckets.rego | Resolved (M4.7) |
| 5 | TM-06 | no-destroy-shipments.rego | Resolved (M4.6) |
| 6 | TM-02 | SBOM + cosign sign-blob/verify-blob | Open (Module 6) |
| 7 | TM-03 | CloudTrail | Open (Module 7) |
The complete project, at a glance
andes-cargo-infra/
├── THREAT-MODEL.md (M1)
├── RISK-MAP.md (M1 → 4/7 rows Resolved at M4's close)
├── s3.tf (M4.7 added aws_s3_bucket_public_access_block)
├── modules/
│ ├── s3-bucket/
│ ├── iam-role/
│ └── oidc-provider/ (M2)
└── policy/ ← complete, from this module
├── no-destroy-shipments.rego (M4.6, closes TM-06)
├── least-privilege-iam.rego (M4.7, watches TM-07)
└── no-public-buckets.rego (M4.7, closes TM-04)
Common mistakes
Running the three policies separately, in three commands, instead of pointing -p at the complete directory (flow mistake, contradicts this project's point). What happens: someone, out of habit from lessons 6 and 7, keeps running conftest test tfplan.json -p policy/no-destroy-shipments.rego, then the same command for each file, instead of -p policy/ once. How to spot it: if your workflow runs conftest more than once to review the same plan. How to fix it: -p policy/ (a directory, not a file) loads and evaluates every .rego in that package in a single invocation — it's also the only correct way to integrate it into a real pipeline (this entire guide's Module 8), where a policy-check job runs once, not once per policy.
Interpreting Step 3's 2 passed as "half the policies failed" (summary-reading mistake). What happens: someone reads 4 tests, 2 passed, ... 2 failures and concludes the library "half-works" or there's a problem with half the rules. How to spot it: if your reaction to seeing 2 passed out of 4 is to look for what's wrong with the policies that "didn't pass." How to fix it: 2 passed in this specific context is the correct result — two of the four rules (no-destroy-shipments.rego, and no-public-buckets.rego's second rule) had no reason to fire against that specific plan, because neither of the two conditions they watch for was present. A passed means "this rule found no problem," not "this rule is weak" — and the two rules that did fail are, precisely, the ones corresponding to the two real changes you introduced on purpose.
Leaving the test change (AppServerRole broadened, public-access block deleted) unreverted, "for the next lesson" (project-hygiene mistake). What happens: someone finishes Step 3, sees the expected FAIL, and moves on without reverting iam.tf and s3.tf to the correct state. How to spot it: if conftest test tfplan.json -p policy/ keeps showing failures after you thought you'd finished this project. How to fix it: every lesson in this module that introduced a test change — 7, and this one — explicitly reverted that change before declaring the step complete; andes-cargo-infra/'s final state at this project's close is the correct one, with all three policies passing, not the intermediate state with deliberate violations.
Exercises
Exercise 1 — Run the complete library against a fourth scenario: a Shipments destruction combined with a violated least-privilege policy, at the same time. Generate a plan combining lesson 6's destructive scenario (terraform plan -destroy) with lesson 7's least-privilege change. How many tests would fail, and which ones?
See solution
This specific case has an important nuance: terraform plan -destroy generates a plan that only contains destructions (Plan: 0 to add, 0 to change, N to destroy) — it can't, at the same time, describe creating or modifying a broadened IAM policy, because -destroy mode doesn't accept additional HCL changes in the same run (anything not being destroyed simply doesn't appear in the plan). To combine both scenarios in a real plan you would, instead, need to remove the aws_dynamodb_table.shipments resource from the configuration (so a normal plan, not in -destroy mode, computes its removal) while iam.tf's change stays in place. With that combination, you'd expect 2 failures out of 4 tests: no-destroy-shipments.rego would fire for the table, least-privilege-iam.rego would fire for the broadened policy, and no-public-buckets.rego's two rules would keep passing if the public-access block wasn't touched. This exercise's point is noticing that "combining test scenarios" isn't always as simple as adding two commands together — sometimes it requires precisely understanding what can and can't coexist inside a single plan.
Exercise 2 — Explain, to a colleague who only saw the number summary, why 4 tests doesn't change between Step 2 and Step 3. Your colleague notices both the correct plan and the plan with violations show 4 tests in the summary, and asks why that number is the same if the two plans' content is so different.
See solution
The tests count counts how many deny rules exist in policy/ — a property of the policies themselves, fixed as long as you don't add or remove a .rego file — not how many problems a specific plan has. The same four rules are always evaluated, against any input you give conftest — what changes between one plan and another is how many of those four evaluations end in passed versus failure, never how many rules exist to evaluate. It's the same distinction you already saw in lesson 4: the tests count follows the rules, not the files or the content of what's being evaluated.
Exercise 3 — Design, in prose, a fourth policy this project didn't build, and justify why it wasn't needed yet. Based on THREAT-MODEL.md (Module 1), describe in prose a Rego rule you could write for a risk this module didn't cover, and explain why this project's three policies were enough for the two RISK-MAP.md rows that belonged to this specific module.
See solution
A reasonable answer might propose, for example, a policy that forbids any aws_lambda_function with no explicit timeout, or one requiring every aws_dynamodb_table to have point_in_time_recovery enabled — neither corresponds to a specific THREAT-MODEL.md finding, so they'd be general good-practice policies, not the resolution of a documented risk. The reason this project didn't build them is the same one RISK-MAP.md already established since Module 1: this module had exactly two rows assigned (TM-04, TM-06), plus a third policy (least privilege) that watches, without closing a new row, a finding already resolved in another module. Writing a fourth policy with no documented finding backing it would be exactly the premature-scope mistake this module's lesson 7 already warned about — every policy in this guide exists because a real, evidenced risk needs it, not because "it would be a good idea to have it."
Summary and next step
In this final project you brought together the complete policy/ — three .rego files, four deny rules in total — and confirmed, with two real conftest test tfplan.json -p policy/ runs, that the three policies work together with no interference: 4 tests, 4 passed against Andes Cargo's correct plan, 4 tests, 2 passed, 2 failures against a plan with two simultaneous violations, each detected with its own precise message. You closed RISK-MAP.md's TM-06 and TM-04, leaving the document with four of seven rows resolved.
Before closing this module you should be able to: run conftest test <plan>.json -p policy/ from scratch, with no previous lesson to look at; explain why the tests count doesn't change between a correct plan and one with violations; and defend, against this lesson's three interview questions, why these three specific policies — no more, no fewer — were the right ones for this module.
With this, Module 4 of cloud-security-and-guardrails-guide is complete: conftest installed and verified, your first Rego policy written and run against a test YAML, the terraform plan explored as structured JSON, and three real policies protecting andes-cargo-infra/ — the promise terraform-and-iac-guide and cicd-and-gitops-on-aws-guide named without building, fulfilled end to end, with executed evidence at every step. Module 5 opens the next layer of defense: infrastructure-as-code scanning with rules the security community already wrote, not just the three you wrote here.
Resources
- Conftest — Official documentation — the complete reference for
conftest testagainst a policy directory, this project's central command. - This module, lessons 6 and 7 — the origin of each of the three policies brought together in this project, with their individual
FAILandPASSalready verified. - This course, Module 1, lesson 8 (
RISK-MAP.md) — the document this project updates with the two rows this module closes. src/paths/aws-cloud-ecosystem/VALIDACION.md— the market audit that set this module's weight: policy-as-code asterraform-and-iac-guide's explicit gap, closed here with executed evidence.