Field notes

Least-privilege Terraform deploy roles without hour-long retries

Each fix to a Terraform deploy role took an hour through the company's central policy repo, so I iterated in the dev account at five minutes a try.

At Cigna, Terraform ran in test and prod as a least-privilege IAM role vended into the account. The policies lived in an enterprise-managed GitHub repo, and the enterprise pushed them into the accounts. Changing one meant a lap: edit the policy in the repo, go through the deployment process that pushed it into my account, run Terraform as that role, see where it failed, add the missing permission. “Rinse repeat, rinse, repeat, rinse, repeat.” Each lap took an hour to an hour and a half, “if not more.” The same policy, iterated somewhere I could edit it directly, took about five minutes a lap.

Through the enterprise repo 1–1.5 hours “if not more” edit the policy in the repo · enterprise push into the account · apply as the role · read the failure In the dev account about 5 minutes edit the role directly · apply from the EC2 box · read the failure 0 15 30 45 60 75 90 minutes per lap one greenfield project, the enterprise way: 3–4 days of laps
Fig. 01 — One lap, to one scale in minutes. Through the enterprise repo, an hour to an hour and a half — solid to the hour, dashed to the half. On a box in the dev account, where I could edit the role directly, about five minutes. Same policy document either way — only the lap changed.

Fig. 01 · pinch or scroll to zoom · drag to pan

The loop

The deploy roles exist for a good reason: “you don’t simply give full admin rights to everything. You want to give only the kind of rights that are necessary to do the deployments and nothing more.” The cost was the feedback. Every lap paid the full enterprise push to find the next failure.

On the project I was on, getting the roles right took “probably three days or four days of just going back and forth in this monotonous, painful process of one hour or one and a half hour cycles.” That was one greenfield application on the path to prod — the station where getting least-privilege roles into the test account was a journey in itself.

Why it surfaced at test

Dev had “full admin rights, mostly admin rights.” So the infrastructure got built in dev with nothing recording what it needed. Least privilege only became a question at test, where the vended role was the only way in. By then the build touched “a whole bunch of different services within AWS,” and the policy had to be worked out backwards from failures.

Working it out up front doesn’t close the gap: “you’re always going to miss a little bits here and there.” The enterprise’s golden modules made it worse. They were pre-baked Terraform, and “you don’t necessarily know all the things that they’re baking within their modules, and so you have to go also add all the capabilities that they need.” Trial and error was the only option at the time.

Iterating in the dev account

So I moved the loop. I stood up an EC2 instance in the dev account, where I already had admin, gave it a role of its own that I could edit directly, and ran Terraform from inside it against the environment. Same edit, same apply, same failure to read — about five minutes a lap instead of an hour and up.

It still wasn’t pleasant. “I was able to iterate a lot faster, but the whole thing was super painful.” The part that paid off came at the end. When I took the finished policy to the enterprise repo for test, it worked on the first try, because “it was an actual policy that I was working with.” I copied the policy definition into the vended role “and it simply worked.” No translation step, no approximation to reconcile. The document I iterated was the document I shipped.

I taught it to other teams in my org who were in the same spot. I didn’t try to make it an enterprise process. The pain is mostly a greenfield tax anyway: “for people that have brownfield applications, the vast majority of times you’re not modifying infrastructure.”

Module owners ship the policy

The platform fixed the golden-module half since. The role-vending service now offers “pre-canned policies that are built by the people that create the golden modules,” so whatever a golden module needs is already available when you vend the role. The team that knows what the module does writes down what it needs, once, instead of every consuming team rediscovering it one denial at a time.

That pattern isn’t standard in public Terraform. A 2019 request asked the AWS provider for the least-privilege actions each resource needs. The workarounds listed in it start with applying the plan and adding actions from the error messages. It was closed as not planned in 2025, with no substantive reply from the maintainers. Some public modules check in a minimum policy file instead, which is the same idea at the repo level.

What each tool catches

Several tools try to produce the policy before the first failure. None of them produces a complete deploy policy on its own. Each sees a different slice of what Terraform does, and the table is what their docs, and one public failure, say they miss. It’s reference, not something I ran at Cigna.

Tool Works from Misses
IAM Access Analyzer policy generation Up to 90 days of CloudTrail for one role iam:PassRole; action-level detail for data events such as S3 objects; conditions. Some services come back as service-level templates you fill by hand. Denied calls are counted, so a failing run inflates it
Last-accessed data IAM’s own access tracking It only tells you what to remove, never what’s missing. No action-level data for data-plane events, no iam:PassRole, and failed attempts count as use
iamlive, proxy mode The provider’s HTTPS calls, live Whatever the run didn’t exercise — a create-only run misses update-path calls (below). CSM mode records actions only; resources need proxy mode
Pike A static scan of the .tf files Conditions; partial resources come out wildcarded; some iam:PassRole and service-linked-role needs go unflagged
IAM Policy Autopilot terraform plan JSON Identity-based policies only; no native .tf input; its README says nothing about conditions

Create-only capture misses updates

A capture is only as complete as the run it watched. The EKS Blueprints project published a minimum IAM policy generated by iamlive in its own end-to-end tests, and a user’s apply failed on it with AccessDenied for iam:TagOpenIDConnectProvider and eks:TagResource. The maintainer’s diagnosis: “this API will only happen if we update (AKA ‘add’) new tag, as the first apply that we do is just a create.” He reproduced it — no tag call on create, the tag call after adding a tag and applying again. The fix was adding the two actions by hand.

So a capture runs the whole lifecycle: create, then change a tag and apply again, then destroy. Each phase calls APIs the others don’t.

Blind spots you add by hand

Some permissions no capture is guaranteed to see. iam:PassRole is the big one: AWS says it “is not an API call,” so no CloudTrail event exists for it. Scope it to the role ARNs you actually pass, with iam:PassedToService. Service-linked-role creation only shows up if the role doesn’t exist yet in the account you capture in. Conditions — on tags, on KMS grants — are a manual hardening pass every time. None of the tools above documents generating them.

The state backend needs no discovery at all. HashiCorp’s S3 backend docs list the bucket and lock permissions. Paste them.

A capture, as reference

None of the commands below came from that build. They’re reference, written from the iamlive README, the names are placeholders, and they haven’t been run end to end. Run it in a sandbox account with broad rights: the point is to record what the code needs, not to enforce anything yet. Proxy mode intercepts TLS, which is another reason it stays out of anything shared.

# terminal A: the listener
iamlive --mode proxy --sort-alphabetical --output-file ./captured.json

# terminal B: terraform through the proxy, every phase of the lifecycle
export HTTP_PROXY=http://127.0.0.1:10080
export HTTPS_PROXY=http://127.0.0.1:10080
export AWS_CA_BUNDLE=~/.iamlive/ca.pem

terraform apply -auto-approve                          # create
terraform apply -auto-approve -var 'extra_tag=changed' # update: tag calls show up here
terraform destroy -auto-approve                        # destroy needs its own Delete* and Detach*

What comes out is a first draft. Add iam:PassRole and the conditions by hand, then run the residual failures through a loop where a policy edit is a direct API call on a role you own, not a push through someone else’s pipeline. That loop is the EC2 box, without the box.

The rule

Least privilege isn’t expensive because the policy is hard. It’s expensive when every correction costs an hour. Iterate the real policy document where an edit takes minutes, then ship that exact file through the vending process once. The tools above shorten the list of laps. They don’t replace the loop. And whoever publishes a module publishes its policy — that’s where the platform landed. Getting accounts organized enough to vend roles into at all is landing-zone work.

Underneath

Docs checked 2026-10-28. Access Analyzer’s window, data-event, PassRole, and denied-call limits: IAM Access Analyzer policy generation. Last-accessed scope: Refine permissions using last accessed information. PassRole: Grant a user permissions to pass a role to an AWS service. Modes and flags: the iamlive README. The tag-on-update failure: EKS Blueprints issue 902. The provider request: terraform-provider-aws issue 9154. Static scanners: Pike, IAM Policy Autopilot. Backend permissions: S3 backend.

Related system

More field notes

Start

Tell me what’s stuck

I’ll tell you in about a day whether I’m the right person. The first conversation is fit, not a free architecture review.