diff --git a/.github/workflows/access.yml b/.github/workflows/access.yml index e22df9c..1bcc810 100644 --- a/.github/workflows/access.yml +++ b/.github/workflows/access.yml @@ -241,6 +241,26 @@ jobs: echo "high=false" >> "$GITHUB_OUTPUT" echo "### Severity: ordinary, no guardrail file changed" > severity.md fi + + # A replacement destroys and recreates a resource under one address, + # and on IAM that changes an ARN underneath whatever trusts it. The + # delta lists them, and this names them where a reviewer cannot miss + # them, because "approve the change" means the change including the + # part where something stops existing (decision 63). + replacements=$(jq -r '.replacements[]? | " " + .address + " forced by " + .forced_by' delta.json) + if [ -n "$replacements" ]; then + echo "replacement=true" >> "$GITHUB_OUTPUT" + { + echo + echo "### Replacement: this plan destroys and recreates" + echo + echo "Each of these loses its ARN. Whatever trusts or names the old one breaks at apply." + echo + printf '%s\n' "$replacements" + } >> severity.md + else + echo "replacement=false" >> "$GITHUB_OUTPUT" + fi cat severity.md - name: The job summary @@ -280,6 +300,15 @@ jobs: gh pr edit "${{ github.event.pull_request.number }}" --add-label guardrail-change || echo "could not add the label, the summary carries the severity" + - name: Label a replacement + if: steps.severity.outputs.replacement == 'true' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + run: | + gh pr edit "${{ github.event.pull_request.number }}" --add-label replacement || + echo "could not add the label, the summary carries the replacement" + # The comment, same posture. Posting one needs pull-requests: write, # which a fork PR does not get, so the delta always lands in the job # summary and the comment is the convenience on top. diff --git a/access/.gitignore b/access/.gitignore index 9b063cd..22095e4 100644 --- a/access/.gitignore +++ b/access/.gitignore @@ -4,6 +4,9 @@ terraform.tfstate terraform.tfstate.backup .terraform.tfstate.lock.info *.tfplan +tfplan* +plan*.json +old-digest generated.tf # The copy access/scripts/double-refusal works on. It is created and destroyed diff --git a/access/README.md b/access/README.md index 5cc2fad..2d9d5cb 100644 --- a/access/README.md +++ b/access/README.md @@ -989,3 +989,52 @@ deleted from the repo. The adopted files import resources that exist only where a hand made them, so they live on the laptop that adopted them and the lesson's compare excludes them. The reference tree carries the mechanism and no adoption. + +## Lesson 14, approve the change, not the diff + +The reviewer approves a plan, the apply job refuses anything else, and the +things the plan cannot say are named. + +``` +access/ + scripts/ + plan-digest the digest an approval binds to, over what changes + render-delta the access delta, with replacements on their own +.github/workflows/ + access.yml the pr job saves the plan and its digest, the apply job compares +``` + +### The digest + +`plan-digest` hashes a normalised reading of the plan, the resource changes +sorted by address with the timestamp and the prior state dropped, and on +an update in place only the attributes that differ from before. That last +reduction is why two jobs that each build the same base in their own +container compute one digest for one change, which lesson 14 proves with +two containers and which the first in-place update the pipeline merged +did not have (decision 63). `--normalise` prints what is hashed. + +### Two refusals + +The apply job recomputes the digest over its own plan and refuses when it +differs from the one the pr job recorded, which is the estate having moved +between approval and apply. Terraform refuses a saved plan whose state +moved on its own, `Saved plan is stale`, which is the state having moved. +They are different facts and the lesson shows both. + +### Replacements + +A replacement is a destroy and a create under one address, and on IAM it +changes an ARN underneath whatever trusts it. `render-delta` lists them on +their own with the attribute that forced each, the pr job writes them into +the summary and labels the pull request `replacement`, and a reviewer who +reads "site-publisher-v2 gains read" is also told that site-publisher is +about to stop existing. + +### The gate, honestly + +`environment: prod` on the apply job is where a maintainer adds required +reviewers in the repository settings. Today the environment exists and has +none, so the merge is the approval on this repository, and the page says so. +The digest proves the plan did not move and not who produced it, which is +property XIV half closed. diff --git a/access/scripts/render-delta b/access/scripts/render-delta index 9299aed..1443a21 100755 --- a/access/scripts/render-delta +++ b/access/scripts/render-delta @@ -117,6 +117,18 @@ delta="$(jq --argjson files "$files_json" ' | { address: .address, type: .type, verb: verb(.) } ] | sort_by(.address), + # A replacement is a destroy and a create under one address, and on IAM + # it changes an ARN underneath whatever trusts it. It is listed on its + # own, with the attribute that forced it, because a reviewer who reads + # "site-publisher-v2 gains read" has not been told that site-publisher + # is about to stop existing (decision 63). + replacements: [ + $changed[] + | select(verb(.) == "replace") + | { address: .address, type: .type, + forced_by: ((.change.replace_paths // []) | map(map(tostring) | join(".")) | join(", ")) } + ] | sort_by(.address), + files: ($files | map(select(. != ""))) } ' "$plan_json")" || { @@ -168,6 +180,13 @@ printf '%s' "$delta" | jq -r ' (.other | map(" " + sym(.verb) + " " + .address) | join("\n")), "") end ), + ( if (.replacements | length) == 0 then empty + else (" Replacements. These are destroyed and created again under the same", + " address, and on IAM that changes an ARN underneath whatever trusts it.", + " A replacement waits for a person, and the PR job says so.", + (.replacements | map(" ! " + .address + " forced by " + .forced_by) | join("\n")), + "") + end ), ( if (.files | length) == 0 then empty else (" Files changed", (.files | map(" " + .) | join("\n")), diff --git a/content/courses/iam/14-approve-the-change-not-the-diff.md b/content/courses/iam/14-approve-the-change-not-the-diff.md index faf8c3b..6102aac 100644 --- a/content/courses/iam/14-approve-the-change-not-the-diff.md +++ b/content/courses/iam/14-approve-the-change-not-the-diff.md @@ -3,58 +3,337 @@ title: "Approve the change, not the diff" id: "I14" lesson: 14 weight: 14 -summary: "The reviewer approves the rendered plan, bound by a digest." +summary: "The reviewer approves a plan and its digest, and the apply refuses anything else." # skill. a directory in this repo with a SKILL.md that drives the lesson. empty renders nothing -skill: "" +skill: "skills/i14-approve-the-change-not-the-diff" # card. empty renders as TODO -goal: "" -done_when: "" -restart_from: "lesson 6" +goal: "Make the change a pull request would make, save the plan, and read the two things a reviewer is handed, the access delta and the digest. Then move the account underneath the approved plan and watch the digest change, move the state and watch Terraform refuse the saved plan on its own, plan the same change from two separate emulators and get one digest, and rename a role to see what a replacement looks like when the delta is honest about it." +done_when: >- + `access/scripts/render-delta` on the saved plan reads `site-publisher` + `+ write on waterpark-artifacts`, the digest for the same in-place change + planned against two separate Floci containers is one string while each + plan's `before` carries its own `create_date` and `unique_id`, a hand + edit to the account changes the digest and the normalised diff names + `assume_role_policy` or `tags`, `terraform apply tfplan` after the state + moved fails with `Saved plan is stale`, and a rename renders a + Replacements section naming `name` as what forced it. +restart_from: "checkpoint/i15" properties: ["VIII", "XIV", "VII"] closes: ["P14"] -# media. provider is youtube, vimeo, file or todo -video: - provider: todo - title: "" - length: "" # activity. kind is hands-on, watch-along or discuss activity: kind: hands-on - time: "20 min" - needs: [] - solo: true - live: true + time: "35 min" + needs: + - "a water park checkout with the tags fetched" + - "Docker running, and the Start-here stack up or not, since this lesson starts a second container either way" + - "the patched Floci image ghcr.io/lex00/floci:iam-boundary" + - "terraform 1.9 or newer" + - "tflint from terraform-linters/tap/tflint" + - "jq" + - "just access-init run once per clone" + - "lesson 6 finished or at least read, since this lesson reads the jobs it built" --- ## Context -- The reviewer approves the plan block. It holds the access delta, the proof verdicts and a digest over the saved plan file. The apply job replans and refuses if the digest or the estate moved. Terraform refuses a saved plan whose state has moved on its own, so the check and the applier agree. -- The saved plan is the manifest. `terraform show -json tfplan` is its native form. The E1 schema is the cross-backend version of the same object (decision 23). -- A change that replaces a resource rather than updating it waits for a person. Terraform names these in the plan, and on IAM a replacement means an ARN changes underneath whatever trusts it. -- Provenance is a stated gap. The digest proves the plan did not move, not who produced it. An OIDC-attested build checked before apply is the follow-on lesson, and property XIV is only half closed until then. +- The reviewer approves a plan. Not the diff of the files, which says what somebody typed, and not the words in the description, which say what somebody meant, but the saved plan, which says what the apply will do, rendered as the access delta and bound by a digest (decisions 24 and 35). The PR job saves the plan and records its digest, and the apply job recomputes the digest over its own plan and refuses when the two differ. +- The digest is over a normalised reading of the plan. The resource changes sorted by address, the timestamp and the prior state dropped, and on an update in place only the attributes that differ from before. That last reduction was learned the hard way. The first in-place update the pipeline ever merged, lesson 9's trust change, was refused by the apply job, because a role's `create_date` and `unique_id` from the PR job's container had been copied into the after-value and the apply job's container had its own (decision 63). This lesson proves the fix with two containers. +- There are two refusals and they are different facts. The digest refuses when the estate moved between approval and apply, which is the account being touched by something other than the plan. Terraform refuses a saved plan whose state moved, `Saved plan is stale`, which is the state being touched by another operation. The check and the applier agree, and neither depends on the other. +- A change that replaces a resource rather than updating it waits for a person. Terraform names replacements in the plan, and on IAM a replacement means an ARN changes underneath whatever trusts it. `render-delta` lists them on their own with the attribute that forced each, and the PR job writes them into the summary and labels the pull request, because "approve the change" includes the part where something stops existing. +- The gate is declared before it is configured. The apply job carries `environment: prod`, which is where a maintainer adds required reviewers in the repository settings. On this repository the environment exists and has no reviewers, so the merge is the approval today, and the page says so rather than claiming a gate the settings do not hold. +- No standing apply server runs. The apply is a job on a push to `main`, and it rebuilds the account it plans against from the merged pull request's base, the same way the PR job did, which is why the two digests are comparable at all. [pr-automation](https://github.com/INTENTIUS/waterpark/blob/main/project/archive/pr-automation.md) has the argument. +- Provenance is a stated gap. The digest proves the plan did not move, not who produced it. An OIDC-attested build checked before apply is the follow-on nobody has scheduled, and property XIV is half closed until then. -## Watch +## Do -{{< todo "Video script or link. Optional." >}} +Lesson 6 built two jobs and said the whole lesson was the difference between them. This lesson takes the object that travels from one to the other and reads it. -## Do +1. Start from the checkpoint lesson 15 left, get an emulator, and apply central. The satellite stays out of this lesson, because the digest covers `envs/prod` and that is what the apply job applies. + + ```sh + git fetch origin --tags + git worktree add ../waterpark-i14 checkpoint/i15 + cd ../waterpark-i14 + just access-init + ``` + + If the Start-here stack is up, its Floci answers on 4566 and this lesson + uses it, enforcement off and all, because nothing here is refused by IAM. + If it is down, start the container the earlier lessons started. + + ```sh + docker run -d --name wp-i14-floci -p 4566:4566 \ + -e FLOCI_SERVICES_IAM_ENFORCEMENT_ENABLED=true \ + ghcr.io/lex00/floci:iam-boundary + ``` + + Either way, + + ```sh + curl -s --retry 15 --retry-all-errors --retry-delay 1 \ + -o /dev/null -w '%{http_code}\n' http://localhost:4566/ + terraform -chdir=access/envs/prod init + terraform -chdir=access/envs/prod apply -auto-approve + export AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test AWS_DEFAULT_REGION=us-east-1 + ``` + + `200`, then `Resources: 18 added`. + +2. Bring in the two files this lesson changes, the delta with its replacement section and the workflow with its replacement label, then read the two jobs before running anything, because every step below is one of their steps done by hand. + + ```sh + git checkout checkpoint/i14 -- access/scripts/render-delta .github/workflows/access.yml + grep -n 'Save the plan\|Find the approved plan\|Compare the digest\|environment: prod\|Label a replacement' .github/workflows/access.yml + gh api repos/INTENTIUS/waterpark/environments/prod --jq '{name, protection_rules: (.protection_rules | length)}' + ``` + + The PR job saves the plan, digests it and uploads both as an artifact + keyed by the head commit. The apply job finds the merged pull request, + downloads that artifact, replans against an account it rebuilt from the + same base, recomputes, and compares. `environment: prod` is the line + between, and the API answers `{"name":"prod","protection_rules":0}`. The + environment exists so it can be configured, and nobody has. On this + repository the merge is the approval, and the digest is what makes that + approval mean the plan rather than the diff. + +3. Make the change a pull request would make, and save the plan. Give `site-publisher` write on the artifacts bucket by adding a fourth grant to `access/envs/prod/iam_role.site_publisher.tf`, after the `read` grant. + + ```hcl + { + resource = "waterpark-artifacts" + access = "write" + reason = "Publishes the checkpoint bundle after a lesson lands." + }, + ``` + + ```sh + terraform -chdir=access/envs/prod plan -out=tfplan + terraform -chdir=access/envs/prod show -json tfplan > plan.json + access/scripts/render-delta plan.json + access/scripts/plan-digest plan.json + ``` + + `Plan: 2 to add, 0 to change, 0 to destroy.`, then + + ``` + Access delta + + site-publisher + + write on waterpark-artifacts + + + Other resources + + module.site_publisher.aws_iam_role_policy_attachment.grant["write-waterpark-artifacts"] + ``` + + and a digest, `sha256:a5c9fd50...`. Those two things are what the PR + job puts in front of a reviewer. The delta is the plan translated into a + sentence about access, which is what the reviewer is being asked to + approve. The digest is the plan's fingerprint, which is what the + approval binds to. Read what the fingerprint is over. + + ```sh + access/scripts/plan-digest --normalise plan.json | jq -c '.resource_changes[] | {address, actions, after: (.after | keys)}' + ``` + + Two creates, each with its whole after-value, and no timestamp, no + provider version and no prior state anywhere in it. + +4. Move the account underneath the approved plan. This is the case the digest exists for. The reviewer approved `sha256:a5c9fd50...`, and between the approval and the apply somebody touches the account. + + ```sh + aws --endpoint-url http://localhost:4566 iam tag-role --role-name site-publisher --tags Key=owner,Value=intruder + terraform -chdir=access/envs/prod plan -out=tfplan2 + terraform -chdir=access/envs/prod show -json tfplan2 > plan2.json + access/scripts/plan-digest plan2.json + diff <(access/scripts/plan-digest --normalise plan.json) <(access/scripts/plan-digest --normalise plan2.json) | head -12 + ``` + + `Plan: 2 to add, 1 to change, 0 to destroy.`, a different digest, and a + diff that names it, an `update` on + `module.site_publisher.aws_iam_role.this[0]` whose after-value carries + `tags`. The apply job at this point prints `The plan moved between + approval and apply. Refusing (decisions 24 and 35).` and exits 1, with + that same diff under it. The reviewer approved two creates. The apply + would have done three things, and the third was never in front of + anyone. Put the account back and confirm the digest comes back with it. + + ```sh + aws --endpoint-url http://localhost:4566 iam tag-role --role-name site-publisher --tags Key=owner,Value=platform + terraform -chdir=access/envs/prod plan -out=tfplan3 + terraform -chdir=access/envs/prod show -json tfplan3 > plan3.json + access/scripts/plan-digest plan3.json + ``` + + `sha256:a5c9fd50...` again. The digest is the change and nothing else, + so undoing the intrusion restores it exactly. -{{< todo "Numbered steps. Imperative. One job." >}} +5. Now the other refusal, which is Terraform's own. Apply the change, then try to apply the saved plan from step 3 anyway. -1. {{< todo >}} -2. {{< todo >}} -3. {{< todo >}} + ```sh + terraform -chdir=access/envs/prod apply -auto-approve + terraform -chdir=access/envs/prod apply tfplan + ``` + + `Resources: 2 added`, then + + ``` + Error: Saved plan is stale + + The given plan file can no longer be applied because the state was changed by + another operation after the plan was created. + ``` + + This one is not the digest. It is Terraform refusing to apply a plan + whose state has moved since the plan was made, and it would refuse even + if the digest matched, because the two check different things. The + digest asks whether the estate the reviewer saw is the estate being + applied to. Terraform asks whether the state the plan was computed from + is the state it would write. Decision 24 calls the second one native, and + it is the reason the saved plan and not a re-plan is what the apply job + applies. + +6. Prove the digest does not belong to a container. Start a second emulator on another port, put a second worktree at the same commit on it, and plan the same in-place change in both. + + ```sh + docker run -d --name wp-i14-b -p 4567:4566 \ + -e FLOCI_SERVICES_IAM_ENFORCEMENT_ENABLED=true \ + ghcr.io/lex00/floci:iam-boundary + curl -s --retry 15 --retry-all-errors --retry-delay 1 \ + -o /dev/null -w '%{http_code}\n' http://localhost:4567/ + git worktree add ../waterpark-i14-b checkpoint/i15 + git -C ../waterpark-i14-b checkout checkpoint/i14 -- access/scripts/render-delta + export TF_VAR_floci_endpoint=http://localhost:4567 + terraform -chdir=../waterpark-i14-b/access/envs/prod init + terraform -chdir=../waterpark-i14-b/access/envs/prod apply -auto-approve + unset TF_VAR_floci_endpoint + ``` + + `18 added` in the second container. Now the same edit in both + worktrees. Change the description on `site-publisher` in + `access/envs/prod/iam_role.site_publisher.tf` from + `Builds the site and writes it to the site bucket.` to + `Builds the site and writes it to the site bucket, on every push to main.` + in this worktree and in `../waterpark-i14-b`, then plan both. + + ```sh + terraform -chdir=access/envs/prod plan -out=tfplan4 + terraform -chdir=access/envs/prod show -json tfplan4 > plan-a.json + TF_VAR_floci_endpoint=http://localhost:4567 terraform -chdir=../waterpark-i14-b/access/envs/prod plan -out=tfplan + terraform -chdir=../waterpark-i14-b/access/envs/prod show -json tfplan > plan-b.json + access/scripts/plan-digest plan-a.json + access/scripts/plan-digest plan-b.json + for f in plan-a.json plan-b.json; do jq -c '.resource_changes[] | select(.change.actions == ["update"]) | .change.before | {create_date, unique_id}' $f; done + access/scripts/plan-digest --normalise plan-a.json | jq -c '.resource_changes[] | {address, actions, after}' + ``` + + `1 to change` in both, then one digest printed twice, then two + `before` values that differ in both fields, then an after-value that is + only `description`. Each container created its own `site-publisher` at + its own moment with its own id, an in-place update copies every + unchanged attribute forward, and the digest keeps only what changes. + Now read what the digest did before lesson 11 fixed it. + + ```sh + git show checkpoint/i10:access/scripts/plan-digest > old-digest + bash old-digest plan-a.json + bash old-digest plan-b.json + ``` + + Two different strings for one change. That is what refused the apply of + pull request 89 on `main`, the first in-place update this pipeline ever + merged, and the apply job's log has the diff, `create_date` and + `unique_id` and nothing else. The PR job and the apply job each build + the same base in their own container by design, and a digest that + remembered the container would refuse every update forever. + +7. Rename a role, and read what a replacement looks like. Put the description back in both worktrees, then in this one change `name = "site-publisher"` to `name = "site-publisher-v2"` and plan. + + ```sh + terraform -chdir=access/envs/prod plan -out=tfplan5 + terraform -chdir=access/envs/prod show -json tfplan5 > plan5.json + access/scripts/render-delta plan5.json + ``` + + The plan says `must be replaced` on the role and every grant policy, + with `# forces replacement` beside `name`, and the delta ends with a + section the earlier lessons never printed. + + ``` + Replacements. These are destroyed and created again under the same + address, and on IAM that changes an ARN underneath whatever trusts it. + A replacement waits for a person, and the PR job says so. + ! module.site_publisher.aws_iam_policy.grant["list-waterpark-site"] forced by name + ! module.site_publisher.aws_iam_policy.grant["read-waterpark-artifacts"] forced by name + ! module.site_publisher.aws_iam_policy.grant["write-waterpark-artifacts"] forced by name + ! module.site_publisher.aws_iam_policy.grant["write-waterpark-site"] forced by name + ! module.site_publisher.aws_iam_role.this[0] forced by name + ! module.site_publisher.aws_iam_role_policy_attachment.grant["list-waterpark-site"] forced by policy_arn, role + ... + ``` + + Read the top of the same delta. It lists `site-publisher-v2` with four + grants marked `!` and a `Principals` block with `! site-publisher-v2`, + which read quickly says a new role with four grants, which is true and + is not the story. The story is that + `site-publisher` stops existing, its ARN with it, and the GitHub Actions + trust anchor that names its subject, the CODEOWNERS line derived from + its file, and anything live that trusts the old ARN all break at apply. + The replacements section is the delta refusing to let a rename read as a + grant. In the PR job that section lands in the summary under + `### Replacement: this plan destroys and recreates` and the pull request + gets the `replacement` label, which is the person the replacement waits + for being told. Do not apply it. Put the name back. + +8. Say the two things the digest cannot say, out loud. It proves the plan did not move between approval and apply. It does not prove who produced the plan, because a PR job's Terraform is whatever the pull request's checkout runs, and a pull request can change the workflow, which is why lesson 6 routes that path to platform review and labels it. An attested build checked before apply would close that, nobody has scheduled it, and property XIV is half closed until someone does. And it does not prove the apply landed on the account the reviewer imagined, because on this repository the apply job's account is a container it built from the base commit, which proves the digest check and nothing about a real account, which is lesson 6's honesty line and still true. + +9. Take the step 3 grant back out of `iam_role.site_publisher.tf`, so the leaf file is the checkpoint's again, then compare with the reference repo and tear down both worktrees and the second container. + + ```sh + git add -A access .github + git diff --cached --stat checkpoint/i14 -- access .github ':!*README.md' + + terraform -chdir=access/envs/prod destroy -auto-approve + TF_VAR_floci_endpoint=http://localhost:4567 terraform -chdir=../waterpark-i14-b/access/envs/prod destroy -auto-approve + docker rm -f wp-i14-b + cd .. && git -C waterpark-i14 worktree remove --force ../waterpark-i14-b + ``` + + Nothing printed means the delta and the workflow are the reference's + and the leaf file is back to what the checkpoint holds. The saved plans + and the old digest are ignored by name. If the diff names + `iam_role.site_publisher.tf`, the grant from step 3, the description from + step 6 or the name from step 7 is still in it. If you started your own + container in step 1, `docker rm -f wp-i14-floci` as well. ## Self-paced -{{< todo "What Floci or your own machine can and cannot show." >}} +The whole lesson runs on the emulator and makes no model turn, and it needs two containers for step 6, which is the one step that cannot be shown with one. The Start-here stack's Floci serves as the first, enforcement off, because nothing here is refused by IAM. + +What the emulator proves and what it does not. Every plan, digest and refusal here is Terraform and a script reading Terraform's own JSON, so they are the same on a real account. What the emulator cannot show is the two jobs running, which happen on GitHub on every access pull request, and the page reads their steps instead. Lesson 6's status row records the digest check running for real on pull request 71 and its merge, and lesson 11's records the refusal on pull request 89 and the fix. + +The `prod` environment on this repository has no required reviewers. That is a fact about the settings and not about the design, and the page says it because a lesson that claimed a gate the settings do not hold would be teaching the diff rather than the change. ## Live -{{< todo "What the room sees. Timing. The line to say." >}} +Twenty five minutes, in three moves, and the room needs steps 4, 6 and 7. + +Open on step 4 with the digest from step 3 on the projector as the thing the room has just approved. Tag the role from a second window, replan, and let the room watch the string change and the diff name the tag. The line to say is that the reviewer approved two creates and the apply would have done three, and nobody was ever asked about the third. + +Then step 6 on two containers side by side, and the two `before` values with their different ids, and then one digest. Then the old digest and two strings, and say that this refused a real merge on this repository's `main` on 2026-09-10, and that the log is in the status page's row for lesson 11. A digest that remembers the machine it was computed on is a digest that refuses every update forever, and the honest fix was to hash the change and not the world around it. + +Close on step 7, and read the top of the delta first, `site-publisher-v2` gains four grants, and ask the room to approve it. Then scroll to the replacements section. The honesty line is that the delta was telling the truth both times, and that the second truth is the one a reviewer is paid to read. + +Live, the same pull request runs both jobs on GitHub, the replacement label lands on it, and a maintainer who has added required reviewers to the `prod` environment watches the apply job wait for them. ## Further reading -- Decisions 6, 23 and 24 -- [PR automation](https://github.com/INTENTIUS/waterpark/blob/main/project/archive/pr-automation.md) +- [access/README.md](https://github.com/INTENTIUS/waterpark/blob/main/access/README.md), "Lesson 6, one path to prod" and "Lesson 14, approve the change, not the diff" +- [access/scripts](https://github.com/INTENTIUS/waterpark/blob/main/access/scripts/README.md), `plan-digest` and `render-delta` +- [PR automation](https://github.com/INTENTIUS/waterpark/blob/main/project/archive/pr-automation.md), why no standing apply server - [The AWS desk](../../docs/aws-desk.md), the digest +- [Prescriptions](../../docs/prescriptions.md), 14 +- [Decisions](../../docs/decisions.md), 6, 23, 24, 31, 35 and 63 +- [Lesson 6, one path to prod](06-one-path-to-prod.md), the two jobs +- [Lesson 9, federation trust](09-federation-trust.md), the merge whose apply was refused diff --git a/content/docs/decisions.md b/content/docs/decisions.md index 27a3bff..729a25f 100644 --- a/content/docs/decisions.md +++ b/content/docs/decisions.md @@ -612,3 +612,25 @@ it. Reversing one requires editing this file in the same PR. cost is that an estate whose provider carries no default tags gets an empty plan instead, and the check passes that too. ([IAM, lesson 15](../courses/iam/15-adopt-in-place.md)) +63. **The approval binds to what changes, a replacement is named where a + reviewer cannot miss it, and the gate is declared before it is + configured.** The digest is over the normalised plan, and on an update + in place only over the attributes that differ, because the first + in-place update the pipeline merged was refused when a role's + `create_date` and `unique_id` from one container reached a digest + compared in another (lesson 9). Two containers that build the same + base and plan the same change now compute one digest, and lesson 14 + proves it. `render-delta` lists replacements on their own with the + attribute that forced each, and the PR job writes a replacement + section into the summary and labels the pull request, because a + replacement on IAM changes an ARN underneath whatever trusts it and + "approve the change" includes the part where something stops + existing. The apply job's `environment: prod` exists so a maintainer + can add required reviewers in the repository settings, and today it + has none, so on this repository the merge is the approval and the + page says so rather than claiming a gate the settings do not hold. + The digest proves the plan did not move and not who produced it, and + property XIV stays half closed until an attested build is checked + before apply, which no lesson schedules. The cost is one more label + and one honest sentence about the environment. + ([IAM, lesson 14](../courses/iam/14-approve-the-change-not-the-diff.md)) diff --git a/content/status.md b/content/status.md index 047384f..3eec6d0 100644 --- a/content/status.md +++ b/content/status.md @@ -35,6 +35,7 @@ not built by this workflow. | 2026-09-05 | IAM lessons 1 to 5 written against `checkpoint/i0` to `checkpoint/i5` and taken end to end by an agent playing the student from a fresh clone with only the pages, the skills and the tags. Lessons 3, 4 and 5 passed their done-when as written, 1 and 2 failed on a checkpoint compare that could not be silent. Nineteen stumbles filed and fixed, the changed steps re-run clean. | | 2026-09-05 | IAM lessons 6 to 8 written against `checkpoint/i5` to `checkpoint/i8` and taken end to end by an agent playing the student from a fresh clone. All three done-whens passed as written and every promised compare was silent. Sixteen stumbles filed, fifteen fixed in text, one left as a script finding. The access workflow's PR job and apply job each ran for real on PR 71 and its merge, the apply job matching the approved plan digest and applying. | | 2026-09-06 | Fountain lesson 3, the egress allowlist, written and taken end to end by an agent playing the student from a fresh clone. The done-when passed as written. The class stack's runner holds no egress policy, so Fountain refuses a `limited` environment rather than running it open, and the lesson teaches that refusal. It needs no inference key, since the refusal comes before the model is called. | +| 2026-09-10 | IAM lesson 14, approve the change not the diff, written against `checkpoint/i15` to `checkpoint/i14` and taken end to end by an agent playing the student from a fresh clone, on the class stack's Floci and a second container. All five done-when clauses passed as written. The saved plan's delta and digest read as what a reviewer approves, a hand edit to the account changes the digest with the diff naming why, Terraform refuses a stale saved plan on its own, one in-place change planned from two emulators gives one digest where the digest from before lesson 11's fix gave two, and a rename renders a replacements section the PR job now labels. Five stumbles filed, two the saved plans and the step 3 grant reaching the compare, three wording, all fixed. | | 2026-09-10 | IAM lesson 15, adopt in place, written against `checkpoint/i11` to `checkpoint/i15` and taken end to end by an agent playing the student from a fresh clone, on the class stack's own Floci. All five done-when clauses passed as written and the checkpoint compare was silent. A role, a bucket and an open security group made by hand come under management one file at a time with `import` blocks reviewed out of generated config, `adopt-check` passes each because the only change is the estate's three tags and fails a file that misstates the account by naming the attribute, the rule pack refuses the adopted role as it would a written one, and a `removed` block leaves the group in the account. Five stumbles filed, one the check not seeing a landed import, one the adopted role's missing team as a fifth day-two item, three wording, all fixed. | | 2026-09-10 | Fountain lesson 11, no approval gate in Fountain, written and taken end to end by an agent playing the student from a fresh clone, two model turns on the class stack. All three done-when clauses passed as written. The runtime's command line read off the runner mid-turn carries `--allow-dangerously-skip-permissions` with its permission prompt wired to stdio, a file write lands with zero `request_permission` messages in the record, and the destructive prompt went both ways across two runs, declined once on the model's judgment and complied with once, deleting the sandbox's own runtime install with the same zero in the record, which the page now teaches as the point. Seven stumbles filed, all fixed. | | 2026-09-10 | Fountain lesson 10, the self-hosted runner, written and taken end to end by an agent playing the student from a fresh clone, three model turns on the class stack. All four done-when clauses passed as written. The runner reads as one process and one user holding the API key, a `limited` environment refuses with `provider: runner`, a second sandbox lists its sibling and both directories are one owner's, a stopped container gives a new run a 409 naming the fix and a parked conversation a `runner_offline` failure, and the restart brings the same conversation back. Five stumbles filed, all wording, all fixed. | @@ -48,9 +49,9 @@ not built by this workflow. ## Written, not yet verified -- 6 of the 26 lesson bodies are skeletons with TODO markers. Written - and student-run: Fountain lessons 1 to 6, 10 and 11, and IAM lessons 1 to 11 and 15. - `just todos` counts 48 today. +- 5 of the 26 lesson bodies are skeletons with TODO markers. Written + and student-run: Fountain lessons 1 to 6, 10 and 11, and IAM lessons 1 to 11, 14 and 15. + `just todos` counts 41 today. - The access repo exists under `access/` for what IAM lessons 1 to 8 need (layout, personas, the check stack, the Floci deploy, the boundary, the PR and apply jobs, drift and reconcile, the satellite and diff --git a/skills/README.md b/skills/README.md index fedc30d..3d4456d 100644 --- a/skills/README.md +++ b/skills/README.md @@ -101,3 +101,4 @@ that it is `student run`. | `i10-break-glass` | IAM lesson 10, Break-glass. Worktree at `checkpoint/i9`, brings in the grant shape and the TTL rule, writes the `on-call` stand-in, grants prod write for two minutes with `access/scripts/break-glass`, reads the `DateLessThan` condition and the tags back, gets a three-hour grant refused by the script, the rule pack and `plan`, leaves the cleanup unrun past the expiry, watches `drift` report the leftover, sweeps and revokes, and reads the apply job's approver stamp. Reuses `skills/start/check.sh`. | student run | | `i11-offboard-and-access-review` | IAM lesson 11, Offboard and the access review. Worktree at `checkpoint/i10`, brings in the four read-side scripts and the quarterly workflow, asks `whocan` and `expiring` against the account and watches a console-attached policy appear, reads the review artifact, offboards `course-author` with the preview reading nothing live and the grep reading zero, offboards `desk-operator` with a five-resource destroy and a `NoSuchEntity` read back, and restores both from the checkpoint. Reuses `skills/start/check.sh`. | student run | | `i15-adopt-in-place` | IAM lesson 15, Adopt in place. Worktree at `checkpoint/i11`, makes a role, a bucket and an open security group by hand, watches `drift` not see them, adopts the role with an import block and a resource block reviewed out of `-generate-config-out`, proves with `adopt-check` that only the estate's tags change and watches it fail a misstated file, adopts the other two, watches the rule pack fail the adopted role and warn on the group, backs the group out with a `removed` block, and says what walking away costs. Reuses `skills/start/check.sh`. | student run | +| `i14-approve-the-change-not-the-diff` | IAM lesson 14, Approve the change, not the diff. Worktree at `checkpoint/i15`, reads the two jobs' digest steps and the unconfigured `prod` environment, saves a plan and reads its delta and digest, tags the role by hand to watch the digest change with the diff naming `tags`, applies and watches Terraform refuse the stale saved plan, plans one in-place change from two emulators for one digest against the old digest's two, renames a role to read the replacements section, and says what the digest cannot say. No model turns. Reuses `skills/start/check.sh`. | student run | diff --git a/skills/i14-approve-the-change-not-the-diff/SKILL.md b/skills/i14-approve-the-change-not-the-diff/SKILL.md new file mode 100644 index 0000000..79df2a3 --- /dev/null +++ b/skills/i14-approve-the-change-not-the-diff/SKILL.md @@ -0,0 +1,241 @@ +--- +name: waterpark-i14-approve-the-change-not-the-diff +description: Walk a student through IAM lesson 14, Approve the change, not the diff. Use when they finished IAM lesson 15, or lesson 6, and want lesson 14, or when they ask what the apply job compares. Saves a plan and reads its delta and digest, moves the account and watches the digest change with the diff naming why, moves the state and watches Terraform refuse the saved plan, plans one change from two emulators and gets one digest against the old one's two, renames a role and reads the replacements section, and says what the digest cannot say. No model turns. +--- + +# water park, IAM lesson 14, Approve the change, not the diff + +You are walking a student through IAM lesson 14, Approve the change, not +the diff +(https://intentius.io/waterpark/courses/iam/14-approve-the-change-not-the-diff/). +The outcome is the object that travels from the PR job to the apply job, +read by hand, with both refusals shown, the digest proven to belong to the +change and not the container, and a replacement read as what it is. About +35 minutes and no model turns. + +This lesson never touches a real AWS account. Every command points at a +local emulator, `floci` stays at its default of true, and the +`AWS_ACCESS_KEY_ID=test` pair is the throwaway the provider already uses. +If the student wants to run this against a real account, say that is the +live path and a facilitator runs it. + +Confirm with the student before creating each worktree, before starting a +container, before the `git checkout` that brings in the two files, before +each edit to a leaf file, before the hand edits to the account in step 5c, +before each apply and destroy, and before the teardown. Those steps are +marked **confirm**. Reads run freely, which is every `plan`, `show`, +`render-delta`, `plan-digest`, `diff`, `grep`, `gh api` and `git diff`. + +## 1. Say what this is + +In two or three sentences say this is lesson 14 of the IAM course, that +lesson 6 built two jobs and said the whole lesson was the difference between +them, and that this lesson takes the object that travels between them, the +saved plan with its delta and its digest, and reads it. Say the rule, the +reviewer approves a plan and not a diff, and the apply job refuses anything +that is not that plan. Link the lesson page above. + +## 2. Check the ground + +Run the same check Start here uses. + +```sh +bash skills/start/check.sh +``` + +Also read `.waterpark/profile.json` at the checkout root if it exists. A +profile file is a claim from a previous run and the check's live calls are +the truth, so when they disagree believe the check. If `completed` does not +carry `"i15"`, say the checkpoint carries lesson 15's work anyway and offer +to run this. If it does not carry `"i6"` either, say lesson 6 is where the +two jobs come from and this lesson reads their steps, and offer to carry +on. + +Require `waterpark.checkout` true and `tools.docker.installed` true. The +check does not report `terraform` or `tflint`, so ask for those directly. + +```sh +terraform version +tflint --version +jq --version +``` + +Terraform 1.9 or newer, any tflint, and any jq, which the check does +report. `gh` is used once for a read of the environment and can be skipped +if missing. `docker` is needed +whatever the stack is doing, because step 5e starts a second container. + +Note the check's `floci.reachable`. If it is true, the Start-here stack's +Floci is up on 4566 and the lesson uses it as the first emulator, with IAM +enforcement off, which this lesson never needs. If it is false, section 3 +starts one. + +## 3. The starting point + +**confirm**, then + +```sh +git fetch origin --tags +git worktree add ../waterpark-i14 checkpoint/i15 +cd ../waterpark-i14 +just access-init +``` + +Every command after this runs from `../waterpark-i14`. If `floci.reachable` +was false, **confirm** and start the container from step 1 of the lesson +page, which is `content/courses/iam/14-approve-the-change-not-the-diff.md` +in this checkout. Either way, the curl, `200`. + +## 4. Apply central + +**confirm**, then + +```sh +terraform -chdir=access/envs/prod init +terraform -chdir=access/envs/prod apply -auto-approve +export AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test AWS_DEFAULT_REGION=us-east-1 +``` + +`Resources: 18 added`. Say the satellite stays out because the digest +covers `envs/prod`, which is what the apply job applies. + +## 5. The lesson + +### 5a. Read the two jobs + +**confirm**, then the checkout of the two files from step 2 of the page, +first, because the fifth line the grep looks for arrives with it. Then +the `grep` and the `gh api`. Walk the five lines, save, find, compare, +environment, label. `{"name":"prod","protection_rules":0}`, and say that +the environment exists so it can be configured and nobody has, so on this +repository the merge is the approval and the digest is what makes that +mean the plan. + +### 5b. The change, the delta, the digest + +**confirm**, then have the student add the fourth grant to +`access/envs/prod/iam_role.site_publisher.tf` from step 3 of the page, +after the `read` grant. Then the four commands. `Plan: 2 to add`, the delta +with `site-publisher` `+ write on waterpark-artifacts`, and a digest. Say +what each is for, the delta is what the reviewer approves and the digest is +what the approval binds to. Then the `--normalise` read, two creates with +whole after-values and no timestamp or prior state. + +### 5c. The account moves + +**confirm**, then the `tag-role` from step 4, the replan, the digest and +the diff. `2 to add, 1 to change`, a different digest, and a diff naming an +`update` on `module.site_publisher.aws_iam_role.this[0]` with `tags`. Say +what the apply job prints here, `The plan moved between approval and +apply. Refusing (decisions 24 and 35).`, and that the reviewer approved +two creates while the apply would have done three. + +**confirm**, then the `tag-role` back to `platform`, the replan and the +digest. The same digest as 5b. Say that the digest is the change and +nothing else. + +### 5d. Terraform's own refusal + +**confirm**, then the apply and then `terraform -chdir=access/envs/prod apply tfplan`. +`2 added`, then `Error: Saved plan is stale`. Walk the difference from step +5 of the page. The digest asks whether the estate the reviewer saw is the +one being applied to, Terraform asks whether the state the plan was +computed from is the one it would write, and they check different things. + +### 5e. Two containers, one digest + +**confirm**, then the second container on 4567, the second worktree at +`checkpoint/i15` with the reference `render-delta` checked into it, and its +apply with `TF_VAR_floci_endpoint` set, from step 6. `18 added`. + +**confirm**, then the description edit in both worktrees, exactly as step 6 +spells it, and the two plans, two `show`s, two digests, the two `before` +reads and the `--normalise` read. `1 to change` twice, one digest twice, +two `before` values with different `create_date` and `unique_id`, and an +after-value that is only `description`. + +Then the old digest from `checkpoint/i10`, two different strings. Say +that this refused pull request 89's apply on `main`, the first in-place +update the pipeline merged, and that a digest which remembers the container +refuses every update forever. If the student's `TF_VAR_floci_endpoint` is +still exported, the first worktree's plan goes to the wrong container and +the numbers differ, so `unset` it as the page does. + +### 5f. A replacement + +**confirm**, then the description back in both worktrees and the rename in +this one, then the plan and the delta from step 7. `must be replaced` on +the role and every grant policy, and the `Replacements` section listing +each with `forced by name` or `forced by policy_arn, role`. Read the top of +the delta first with the student, `site-publisher-v2` with four `!` lines +and a `Principals` block, which read quickly says a new role with four +grants, and then the section, and say that the delta was telling the truth both +times and the second truth is the one a reviewer is paid to read. Say what +the PR job does, the summary section and the `replacement` label. Do not +apply. **confirm**, then the name back. + +### 5g. What the digest cannot say + +Read step 8 with the student. Not who produced the plan, which is +property XIV half closed until an attested build is checked before apply, +and not which account the apply landed on, which on this repository is a +container the job built. + +### 5h. Compare with the reference repo + +**confirm**, then have the student take the step 3 grant back out of +`iam_role.site_publisher.tf`, so the leaf file is the checkpoint's. Then + +```sh +git add -A access .github +git diff --cached --stat checkpoint/i14 -- access .github ':!*README.md' +``` + +Nothing printed means the delta and the workflow are the reference's and +the leaf file is back, and the saved plans are ignored by name. If +`iam_role.site_publisher.tf` is named, one of the three edits is still in +it, and the checkpoint's copy is the fix. + +## 6. Done when + +All five have to be true, and verify each one yourself rather than taking +earlier output on trust. + +- `render-delta` on the saved plan in 5b read `site-publisher` + `+ write on waterpark-artifacts`. +- In 5e the two digests were one string, the two `before` values differed + in `create_date` and `unique_id`, and the old digest gave two strings. +- In 5c the digest changed after the `tag-role` and the diff named `tags`, + and it came back after the tag was restored. +- In 5d `terraform apply tfplan` failed with `Saved plan is stale`. +- In 5f the delta printed a `Replacements` section with `forced by name`. + +If the two digests in 5e differ, the edits are not byte for byte the same, +or one plan went to the wrong container, check `TF_VAR_floci_endpoint`. If +5d does not refuse, the apply before it did not run. If 5f prints no +replacements section, the reference `render-delta` did not arrive in 5a. + +## 7. Tear down + +**confirm**, then the destroys, the second container and the second +worktree from step 9 of the page. If section 3 started a container, +`docker rm -f wp-i14-floci` as well. Leave the `../waterpark-i14` +worktree. + +## 8. Record + +**confirm**, then update `.waterpark/profile.json` at the checkout root, +appending `"i14"` to its `completed` array, creating the file and the +array if either is missing. Leave every other field untouched. Write it in +the original checkout rather than in the `../waterpark-i14` worktree. + +```json +{"...": "...", "completed": ["start", "i1", "i2", "i3", "i4", "i5", "i6", "i7", "i8", "i9", "i10", "i11", "i15", "i14"]} +``` + +## 9. Hand off + +Say that the IAM course's remaining lessons, 12 and 13, are the concierge +and the watcher, and that both wait on the AWS desk, which is not built, +so their pages are placeholders for now. Say the same of Fountain lessons +7 to 9, and that every other lesson in both courses is written.