Module 8: Capstone The Andes Cargo Security Gate

5. End-to-end walkthrough: a change the gate stops

Description

This is the lesson that proves this guide's complete thesis. A real attempt, with a completely believable motivation — "I need AppServerRole to stop failing on file uploads, I'm going to open up access" —, widens that role's S3 permission to "Action": "*". The gate stops it at policy-check, before iac-scan or verify-artifact even run a single step. It's not a promise: it's act pull_request really run, with the complete log as evidence, and the total absence of the other two jobs in that log as proof they never triggered.

Connection to the module

Module 1, lesson 3, used exactly this pattern — s3:* when the code only calls s3:GetObject — as STRIDE's technical example of Elevation of Privilege. Module 4, lesson 7, wrote the policy that catches it. Module 4, lesson 8, already demonstrated that policy fails against a plan with that violation, by hand, in your terminal. This lesson doesn't repeat that test — it takes it one level up: the same kind of change, now inside a real pipeline, with two complete jobs (iac-scan, verify-artifact) that never get to run as a direct consequence.


Analogy: the passenger without a valid passport, at the front of the line

Going back to lesson 1's airport: a passenger without valid documents doesn't reach the metal detector to get rejected there — they never leave the document-control line. Nobody scans their luggage "just in case they passed the first checkpoint." The entire system is designed so that passenger never wastes the next checkpoints' time, neither their own nor the line behind them. This lesson is that exact scene, with a Terraform plan in the passenger's role.


Step 1 — The change: "I just want the upload to stop failing"

An Andes Cargo developer is debugging a permissions error in AppServerRole — something that, in real life, happens constantly, and almost never with bad intent. Instead of identifying the exact missing verb, they solve the problem the fastest way possible: opening the permission all the way up.

   statement {
     sid       = "AppServerBucketAccess"
     effect    = "Allow"
-    actions   = ["s3:GetObject", "s3:PutObject"]
+    actions   = ["*"]
     resources = ["arn:aws:s3:::andes-cargo-shipment-docs/*"]
   }
git diff -- iam.tf

What to expect (literal):

diff --git a/iam.tf b/iam.tf
index 9fd7080..d31a915 100644
--- a/iam.tf
+++ b/iam.tf
@@ -35,7 +35,7 @@ data "aws_iam_policy_document" "app_server_inline" {
   statement {
     sid       = "AppServerBucketAccess"
     effect    = "Allow"
-    actions   = ["s3:GetObject", "s3:PutObject"]
+    actions   = ["*"]
     resources = ["arn:aws:s3:::andes-cargo-shipment-docs/*"]
   }
 }

One line, just like lesson 4's change — and that similarity is exactly the point: no human reviewer, looking at a one-line diff in the middle of a Pull Request with fifteen changed files, is guaranteed to notice the difference between ["s3:GetObject", "s3:PutObject"] and ["*"] at a glance. It's precisely the kind of change an automated gate exists to catch when human review, busy or rushed, doesn't.

git add -A
git commit -m "Widen AppServerRole S3 access while debugging an upload issue"

Step 2 — conftest, by hand, first: confirming the violation before the pipeline

Before running the complete gate, confirm in isolation — the same "test small first" habit you already used in Module 4 — that this policy really triggers:

terraform plan -out=tfplan -input=false
terraform show -json tfplan > tfplan.json
conftest test tfplan.json -p policy/

What to expect (literal, run to write this lesson):

FAIL - tfplan.json - main - module.app_server_role.aws_iam_role_policy.this: statement "AppServerBucketAccess" allows Action "*" — scope it to the specific actions this role needs

4 tests, 3 passed, 0 warnings, 1 failure, 0 exceptions
echo $?
1

The message is exact, and points exactly at what changed. module.app_server_role.aws_iam_role_policy.this — the same address you'd see with terraform state show —, the statement's exact Sid ("AppServerBucketAccess", the same one this change touched), and the reason in one clear sentence. None of this is new compared to Module 4, lesson 8 — it's the same policy, the same kind of violation — what's new is what happens next.


Step 3 — The complete gate: policy-check fails, and that's where everything ends

act pull_request -e .github/act-events/pr-event.json

What to expect (literal — run to write this lesson, with no additional trimming from the point where the result is decided):

[ci/policy-check] ⭐ Run Main Evaluate the policy library against the plan
[ci/policy-check]   | FAIL - tfplan.json - main - module.app_server_role.aws_iam_role_policy.this: statement "AppServerBucketAccess" allows Action "*" — scope it to the specific actions this role needs
[ci/policy-check]   |
[ci/policy-check]   | 4 tests, 3 passed, 0 warnings, 1 failure, 0 exceptions
[ci/policy-check]   ❌  Failure - Main Evaluate the policy library against the plan [381.776584ms]
[ci/policy-check] exitcode '1': failure
[ci/policy-check] ⭐ Run Complete job
[ci/policy-check]   ✅  Success - Complete job
[ci/policy-check] 🏁  Job failed
Error: Job 'policy-check' failed

That's it. There are no more lines. Read that output carefully: it doesn't say [ci/iac-scan] anywhere. It doesn't say [ci/verify-artifact] anywhere. act ends the complete process with Error: Job 'policy-check' failed immediately after that job gets marked as failed — the other two jobs in the chain never trigger, it isn't that they run and fail too, nor that they get silently skipped: the entire workflow's execution stops right there, with only one of the three jobs evaluated.

Confirm it yourself, over the complete log, not just the fragment above:

act pull_request -e .github/act-events/pr-event.json 2>&1 | grep -c "iac-scan\|verify-artifact"

What to expect (literal):

0

Zero. Not a single mention of iac-scan or verify-artifact in absolutely any line of the workflow's complete output — not as a job that started, not as a job that got skipped, not in any system message. It's the difference between "the gate reviewed and rejected" and "the gate never got to review what comes next," and it's exactly that second claim this lesson demonstrates with evidence, not a design promise.


Why this matters: the cost the team never paid

Compare it to what would have happened without a chained needs: — the scenario lesson 2's Exercise 1 already had you predict: iac-scan would have installed Trivy (seconds) and scanned the complete project; verify-artifact would have installed cosign (seconds) and verified an artifact that, anyway, had nothing to do with this change. Neither would have changed the final result — the PR wouldn't merge regardless — but both would have spent real runner time, on every run, for every rejected Pull Request, forever. Lesson 3's needs: isn't just a matter of logical order: it's the difference between a gate that really fails fast and one that only looks like it fails fast because the first job to report is also the cheapest, while the others keep running anyway in the background.


Step 4 — Reverting the test change

Just like every lesson in this guide that introduced a test change (Module 4, lesson 6; Module 6, lessons 7 and 8), revert before closing:

git revert --no-edit HEAD
grep -A1 "actions   = " iam.tf | grep AppServerBucketAccess -A1

What to expect (literal — back to the correct state):

    actions   = ["s3:GetObject", "s3:PutObject"]

Common mistakes

Looking for iac-scan/verify-artifact's failure in the output, expecting to see them "also fail." What happens: someone, reading Step 3's output, actively searches for an iac-scan or verify-artifact line marked with , doesn't find it, and concludes there was an error in this lesson's execution. How to spot it: if your expectation was seeing three jobs, all three marked somehow (one failed, two succeeded, or all three failed). How to fix it: this chained needs:'s correct result is that the other two jobs don't appear at all — neither succeeded nor failed, because they never started. Confusing "never ran" with "ran and failed silently" is exactly the mistake Step 3's grep -c is designed to rule out with a number, not a visual impression of the output.

Thinking conftest's deny message mentions the AWS permission name (s3:*) instead of "*". What happens: someone, reading the message allows Action "*", expects to see s3:* instead, because the business change motivating this PR was "I need S3 access." How to spot it: if you compare the literal message against your expectation that "the error should say which service got opened up too much." How to fix it: least-privilege-iam.rego (Module 4, lesson 7) evaluates the IAM policy's Action field's exact value, and in this change that value is the total wildcard "*" — not "s3:*": whoever edited iam.tf didn't scope the permission to the S3 service, they opened the statement to any action on any AWS service against that resource, a broader mistake than the original motivation ("I need to upload files to S3") intended. It's, itself, a real example of how a shortcut under time pressure usually ends up broader than the original need justified — exactly the pattern THREAT-MODEL.md documented since Module 1.

Forgetting Step 4 and leaving AppServerRole widened as the lab's final state. What happens: someone confirms the expected Job failed and moves on to lesson 6 without reverting. How to spot it: if conftest test tfplan.json -p policy/ still shows 1 failure when starting the next lesson. How to fix it: the same hygiene discipline every project in this guide already demanded — your lab's final state, at the end of this lesson, must have policy/'s four rules passing, not the failure demonstration's intermediate state.


Exercises

Exercise 1 — Modify this lesson's change to violate no-public-buckets.rego instead of least-privilege-iam.rego, and confirm the gate stops at the same job anyway. Instead of widening AppServerRole, temporarily remove the aws_s3_bucket_public_access_block block from the s3-bucket module. Run the complete gate. Which job does it stop at, and why does it make sense that it's the same one?

See solution

It stops at policy-check, exactly the same — because policy/'s four rules (one from no-destroy-shipments.rego, one from least-privilege-iam.rego, two from no-public-buckets.rego) are all evaluated inside the same conftest test, in the same job, regardless of which one specifically triggers. policy-check isn't "the job that checks least privilege" — it's "the job that evaluates the entire policy library in a single run," exactly as Module 4, lesson 8 already established with its two-simultaneous-violations example. Any violation of any of the three policies stops the pipeline at the same point, through the same mechanism.

Exercise 2 — Predict what grep -c "iac-scan\|verify-artifact" would show if iac-scan's needs: pointed, by mistake, at a job that doesn't exist (a typo, like needs: policy_check with an underscore instead of a hyphen). Would the pipeline fail the same way, or in a different, more confusing way?

See solution

In a different, more confusing way: GitHub Actions (and act) validate the workflow's syntax before executing any job, and a needs: pointing at a nonexistent job_id is a validation error for the entire workflow, not an execution error for a specific job. The result would be something like Error: yaml: workflow is not valid or an equivalent message about an invalid reference, shown before policy-check even started — no job would run at all, neither the one that really failed nor the ones that depended on it. It's a reminder of why lesson 3 verified, with a real, successful run, that needs: was correctly written before using it to demonstrate a failure — a typo in needs: doesn't show up as "the security control didn't work," it shows up as "the entire workflow is invalid," a completely different kind of error.

Exercise 3 — Explain, to a colleague who only saw the final result (🏁 Job failed), why this lesson's Step 2 (conftest by hand) wasn't redundant with Step 3 (the complete gate). What does Step 2 confirm that Step 3, on its own, wouldn't have confirmed with the same clarity?

See solution

Step 2 isolates the exact cause — which rule, which message, which exit code — outside a complete pipeline's noise (tool installation, checkout, Terraform setup). If Step 3 had failed without Step 2 beforehand, it would be harder to tell, at a glance, whether the failure came from the policy itself or from some infrastructure problem in the job (a failed install, a missing environment variable). Running conftest by hand first — the same habit Module 4 established since its first policy — confirms the root cause precisely before seeing it reappear, identical, inside a real CI job's noisier context. It's the same debugging discipline any experienced engineer applies: isolate before integrating.


Summary and next step

This lesson demonstrated, with executed evidence and not a design claim, this module's central thesis: a real attempt to widen AppServerRole to "Action": "*" was stopped by policy-check, and the chain's other two jobs — iac-scan, verify-artifactnever triggered, confirmed with grep -c showing 0 mentions of both across the workflow's complete log. conftest's message precisely flagged the exact address and exact Sid of the statement that caused the rejection. You reverted the test change, leaving the lab in the correct state to close the module.

With lessons 4 and 5, this guide's complete thesis stands demonstrated in the two ways that matter: what should pass, passes; what should stop, stops, and stops at the right point, before spending the time of the controls that come after. Lesson 6 closes, in one place, the complete honesty of the previous seven modules plus this one: what across all of it really ran, and what stayed representative, with its exact reason.

Resources

  1. This course, Module 1, lesson 3 — the origin of the s3:*/"Action": "*" example as STRIDE's technical case for Elevation of Privilege, revisited here with real pipeline evidence.
  2. This course, Module 4, lessons 7 and 8 — the origin of least-privilege-iam.rego and the first demonstration of its FAIL, in isolation, with no pipeline.
  3. GitHub Docs — jobs.<job_id>.needs — the exact mechanism that makes this lesson's result possible.