Module 8: Capstone The Andes Cargo Security Gate

4. End-to-end walkthrough: a change that passes the complete gate

Description

Lesson 3 confirmed the gate exists and is correctly chained. This lesson gives it a real job: a small, honest business change, with no intention of breaking anything — a new cost tag on the manifest bucket —, proposed as a real Pull Request, evaluated by this guide's five new controls (OIDC, secrets, conftest, Trivy, cosign), and carried through to the exact point where apply.yml would take over.

Connection to the module

This is the "positive" half of Module 8's central thesis, the one this guide's DESIGN sets as proof the gate isn't purely restrictive: a change that should pass, passes, with no friction and without anyone having to ask for an exception. Lesson 5 is the other half — a change that should stop, stops. Together, they're the complete evidence that this security gate correctly distinguishes between the two, not just between "changes something" and "changes nothing."


Analogy: the passenger with a valid passport

An airport that only proves it stops someone without documents hasn't proven its checkpoint system works — it's proven it at least doesn't let through the obviously bad. An airport that really works also lets through, with no unnecessary delays, the passenger with a valid passport, luggage with nothing prohibited, the correct boarding pass. This lesson is that second passenger: someone who isn't trying to break anything, and whom the system recognizes as such, at each of the three checkpoints, without asking for an extra explanation.


Step 1 — The change: a cost tag on the manifest bucket

Andes Cargo needs, for a reason completely unrelated to security, to start tracking the manifest bucket's cost by cost center. It's the kind of change a FinOps team would ask for — outside this guide's scope (Module 7, boundary with finops-and-cost-guardrails-guide), but perfectly normal as real traffic on an infrastructure repository:

   tags = {
     Environment = "dev"
     ManagedBy   = "terraform"
     Project     = "andes-cargo"
+    CostCenter  = "logistics"
   }
git diff -- s3.tf

What to expect (literal):

diff --git a/s3.tf b/s3.tf
index cce01db..56a1cfa 100644
--- a/s3.tf
+++ b/s3.tf
@@ -6,5 +6,6 @@ module "shipment_docs_bucket" {
     Environment = "dev"
     ManagedBy   = "terraform"
     Project     = "andes-cargo"
+    CostCenter  = "logistics"
   }
 }

One line. No IAM permission touched, no bucket policy modified, no new dependency. Exactly the kind of change a team would expect a security gate to ignore — not because the gate doesn't review it, but because there's nothing in it any control in this module is designed to catch.

git add -A
git commit -m "Add CostCenter tag to the shipment docs bucket"

Step 2 — The five controls, one by one, before the pipeline

Before running the complete gate, it's worth naming, explicitly, the five new controls this change goes through — not all of them run a new command in this lesson, but all five are part of why this PR arrives clean at the end:

ControlModuleWhat it confirms about this change
OIDC identityM2The workflow running this PR authenticates with a short-lived federated identity, not a long-lived key stolen from a .secrets file — the change never needed any static credential to be proposed
Managed secretsM3No plain-text secret exists in this diff, nor in any file this change touches — secrets.tf stays untouched
conftest (policy-check)M4The three policies evaluate the complete plan, not just the line that changed — confirming nothing else in the project drifted out of line at the same time
Trivy (iac-scan)M5Confirms adding a tag didn't accidentally introduce any new CRITICAL/HIGH finding
cosign (verify-artifact)M6Confirms lambda/function.zip — completely unrelated to this tag change — is still exactly the artifact the team signed

The first two don't run any command in this specific lesson — they're already built and verified since M2 and M3 — but they're why this PR could be proposed in the first place, with a trusted identity and no secret exposed along the way.


Step 3 — The complete gate, end to end

terraform plan -out=tfplan -input=false

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

Plan: 18 to add, 0 to change, 0 to destroy.

Saved the plan to: tfplan

Note on this number. As in Module 5, lesson 7, and Module 6, lesson 8, this lesson runs on lesson 3's isolated lab — a freshly cloned andes-cargo-infra/, with no previous state applied against LocalStack. 18 to add is, therefore, a complete project creation, not an incremental diff of a single tag against already-deployed infrastructure — the plan a real ci.yml would evaluate in either case, with the only real difference in this PR being the line Step 1 already showed with git diff.

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

What to expect (literal — run to write this lesson, trimmed to each job's result):

[ci/policy-check] ⭐ Run Main Evaluate the policy library against the plan
[ci/policy-check]   | 4 tests, 4 passed, 0 warnings, 0 failures, 0 exceptions
[ci/policy-check] 🏁  Job succeeded

[ci/iac-scan    ] ⭐ Run Main IaC security scan (Trivy)
[ci/iac-scan    ]   | Report Summary
[ci/iac-scan    ]   | ┌───────────────────────────┬───────────┬───────────────────┐
[ci/iac-scan    ]   | │          Target           │   Type    │ Misconfigurations │
[ci/iac-scan    ]   | ├───────────────────────────┼───────────┼───────────────────┤
[ci/iac-scan    ]   | │ .                         │ terraform │         0         │
[ci/iac-scan    ]   | ├───────────────────────────┼───────────┼───────────────────┤
[ci/iac-scan    ]   | │ dynamodb.tf               │ terraform │         0         │
[ci/iac-scan    ]   | ├───────────────────────────┼───────────┼───────────────────┤
[ci/iac-scan    ]   | │ lambda.tf                 │ terraform │         0         │
[ci/iac-scan    ]   | ├───────────────────────────┼───────────┼───────────────────┤
[ci/iac-scan    ]   | │ modules/s3-bucket/main.tf │ terraform │         0         │
[ci/iac-scan    ]   | ├───────────────────────────┼───────────┼───────────────────┤
[ci/iac-scan    ]   | │ secrets.tf                │ terraform │         0         │
[ci/iac-scan    ]   | └───────────────────────────┴───────────┴───────────────────┘
[ci/iac-scan    ] 🏁  Job succeeded

[ci/verify-artifact] ⭐ Run Main Verify function.zip against manifest.sig
[ci/verify-artifact]   | WARNING: Skipping tlog verification is an insecure practice that lacks transparency and auditability verification for the blob.
[ci/verify-artifact]   | Verified OK
[ci/verify-artifact] 🏁  Job succeeded

All three, again, green. The result is identical to lesson 3's — and that's exactly what you'd expect: adding a tag to a bucket changes no IAM policy (policy-check has nothing new to reject), introduces no insecure configuration (iac-scan has nothing new to report), and doesn't touch lambda/function.zip at all (verify-artifact verifies the same artifact as always, against the same signature as always).


Step 4 — How far this change reaches: the exact point where apply.yml would take over

echo $?
0

Exit code 0 for the complete workflow — the signal that, in a real repository, would enable the Merge button in the GitHub interface. (Representative from here on.) With no LocalStack token in this writing environment, apply.yml can't really run — the same honest limitation already documented since Module 1, lesson 4 —, but the flow that would follow, if this PR merged against a repository with real access to LocalStack or a real AWS account, is exactly the one lesson 2 drew: push to main triggers apply.yml, which runs fetch-reviewed-plan and verify-artifact in parallel (Stage 0, inherited from Module 6, lesson 8), and only if both finish successfully, terraform-apply (Stage 1) would apply the new tag against real infrastructure.

git push origin feature/bucket-tag
# → PR opened, ci.yml runs (Step 3, above) → all three jobs pass
# → someone approves and merges the PR
# → push to main triggers apply.yml
# → fetch-reviewed-plan + verify-artifact (Stage 0) → both pass
# → terraform-apply (Stage 1, representative): CostCenter=logistics applied to the real bucket

Common mistakes

Interpreting 18 to add as if this change had created eighteen new resources. What happens: someone, seeing the plan's summary, concludes adding a tag generated seventeen additional resources out of nowhere. How to spot it: if your explanation of this number doesn't mention that this lesson's lab starts from zero, with no previous state. How to fix it: Step 3's note already explains it — this number reflects a complete project creation in an isolated lab, not an incremental diff. On an andes-cargo-infra/ with real state already applied from earlier modules, this same tag change would show Plan: 0 to add, 1 to change, 0 to destroy — a single modification, the one Step 1's git diff already showed precisely.

Assuming a clean plan in policy-check means Trivy or cosign have nothing to say either. What happens: someone, after seeing policy-check pass, assumes the rest of the pipeline is a formality, with no real value. How to spot it: if your understanding of this PR is "conftest already said it's fine, the rest is a rubber stamp." How to fix it: the three controls evaluate different things — policy over the plan, configuration over the raw HCL, integrity over a binary artifact with no relation to Terraform at all. All three agreeing on "PASS" for this specific change is confirmation, not redundancy: a change that broke only Trivy's scan (for example, disabling the bucket's versioning) would pass policy-check with no problem, and iac-scan would be the only one to catch it.

Forgetting to restore the repository's state after this lesson, before moving to lesson 5. What happens: someone leaves the tag commit as the lab's most recent one, and starts lesson 5 without a clean base. How to spot it: if git log shows the CostCenter commit as the most recent one when starting the next lesson. How to fix it: nothing needs reverting for this specific case — this lesson's change is valid and can stay merged, unlike lesson 5's test change, which does need reverting. Just confirm, before moving on, that you know how to tell which of this module's two changes is "the one that stays" and which is "the one that gets tested and reverted."


Exercises

Exercise 1 — Repeat this same walkthrough with a different change, chosen by you, that should also pass the gate with no friction. Pick a small, honest business change — another tag, a clearer description on a resource, a Lambda timeout adjusted to a reasonable value — and run this lesson's three steps against it. Did it pass clean, as expected?

See solution

Any change that doesn't touch an IAM policy (avoiding least-privilege-iam.rego), doesn't create a bucket without its corresponding aws_s3_bucket_public_access_block (avoiding no-public-buckets.rego), doesn't try to destroy the Shipments table (avoiding no-destroy-shipments.rego), doesn't introduce new insecure configuration (avoiding Trivy), and doesn't touch lambda/function.zip (avoiding any cosign problem) should pass all three jobs with no findings. This exercise's value isn't the result — predictable, if you chose the change well — but the practice of predicting, before running the gate, which of the three controls (if any) might object to something, and confirming that prediction with the real run.

Exercise 2 — Explain why OIDC (M2) and secrets (M3) are in Step 2's table, even though no ci.yml job explicitly mentions them. A colleague asks why Step 2's table includes two controls that don't correspond to any named pipeline job (policy-check, iac-scan, verify-artifact).

See solution

Because they're preconditions for the entire pipeline, not controls that run inside a specific job. OIDC (M2) is why ci.yml itself can authenticate against an AWS account without using a long-lived credential — without it, this PR would have no safe way to even exist inside a real pipeline. Managed secrets (M3) is why no file in this repository, including this PR, ever needs to contain a plain-text credential. Neither shows up as a visible job in ci.yml because they aren't controls that evaluate a proposed change — they're the trust infrastructure the three visible controls (policy-check, iac-scan, verify-artifact) operate on top of. This module's lesson 1 already flagged it: the five new controls aren't five jobs — they're five pieces, and only three of them show up as ci.yml jobs.

Exercise 3 — Predict what would happen if this same tag change were proposed in the same Pull Request as lesson 5's attempt (widening AppServerRole). If someone combined both changes — the innocuous tag and the permission widening — into a single commit, would the gate let the tag through while blocking the permission, or would it block the whole PR?

See solution

It would block the whole PRconftest test evaluates the entire plan in a single run (exactly as Module 4, lesson 8 already demonstrated with two simultaneous violations: 4 tests, 2 passed, 2 failures), not change by change within the same plan. If the least-privilege-iam.rego policy finds any statement with Action: "*" anywhere in the plan, conftest test's result is FAIL, and policy-check fails as a whole job — there's no way for the pipeline to distinguish "this part of the change is fine, this other part isn't" within a single Pull Request. The practical consequence is real: mixing an innocuous change with a risky one in the same PR blocks both equally, another concrete reason (besides code-review best practices) to keep Pull Requests small and focused on one change at a time.


Summary and next step

This lesson took a real, small business change, with no intention of breaking anything — a cost tag on the manifest bucket — and carried it through the security gate's three jobs, with lesson 3's same ci.yml, no adjustment: policy-check, iac-scan, and verify-artifact, all three green. You explicitly named the five controls this PR goes through — OIDC and secrets as preconditions, conftest/Trivy/cosign as the three visible jobs — and saw how far this change reaches within the $0 lab before apply.yml (representative, no token) would take over.

Lesson 5 is this exact one's counterpart: the same ci.yml, unchanged, against a real attempt to widen AppServerRole — proof the gate precisely distinguishes between what should pass and what shouldn't.

Resources

  1. This course, Module 4, lesson 8 — the evidence that conftest test evaluates the complete plan in a single run, the basis for this lesson's Exercise 3.
  2. This course, Module 6, lesson 8 — apply.yml's verify-artifact job, the exact point where this walkthrough stays representative.
  3. cicd-and-gitops-on-aws-guide, Module 5, lesson 4 — the original fetch-reviewed-planterraform-apply flow this walkthrough describes in its last step.