IAM: Policy JSON Syntax and a Practical Approach to Terraform Execution Permissions
Chapter 1 positioned IAM as the service that defines who can do what to which resource, and noted that narrowing down permissions would be deferred to this chapter1. Over the two chapters since then, we've kept running Terraform while putting off that promise. The AWS credentials we've been using still carry strong, administrator-equivalent permissions2.
To narrow down permissions, we first need to understand how AWS expresses and evaluates them. After looking at the syntax and evaluation order of the JSON document called a policy, and the mechanism of a role — an identity that you "assume and use" temporarily — we'll settle on a practical landing point for how far to narrow this series' own Terraform credentials.
The way of reading policy JSON you learn in this chapter shows up in every chapter from here on.
3.1. Basic elements of IAM
A request to AWS first goes through authentication, which confirms who the principal is — the entity actually sending the request, such as an IAM user, a role, or an AWS service. Whether a request that passes authentication is actually permitted is then left to the subsequent authorization decision3. When Chapter 1 said IAM defines who can do what to which resource, it was referring to this two-stage structure of authentication (who) and authorization (what they can do)1.
What determines authorization is the policy attached to the principal. A policy is a JSON-format document that describes what (Action), against which resource (Resource), is allowed or denied (Effect).
3.2. Policy JSON syntax
Let's look at the elements of policy JSON using part of PowerUserAccess, one of the AWS managed policies, as an example4.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"NotAction": ["iam:*", "organizations:*", "account:*"],
"Resource": "*"
}
]
}
The actual PowerUserAccess policy continues with additional statements that add individual permissions after this, but this much is enough to explain the syntax. In section 3.7, we'll attach this policy to a group as-is and use it.
- Version: A fixed value indicating the version of the policy language. As in the example above, current official samples use
"2012-10-17"4 - Statement: An array listing one or more allow/deny rules
- Sid: An optional identifier you can attach to a statement. When multiple statements are listed, it shows which one is for what (can be omitted)
- Effect: Indicates whether the statement allows (
Allow) or denies (Deny) - Action: The target API operation. It takes the form
<service>:<operation>, as iniam:CreateUser - Resource: Specifies the AWS resource that
Actionapplies to, as an ARN (Amazon Resource Name). Use"*"to target everything - NotAction: The opposite of
Action. It appliesEffectto everything except the operations listed here.PowerUserAccessuses thisNotActionto exclude the three services iam, organizations, and account, and allows all other operations
Resource can't be freely narrowed down for every Action. Depending on the IAM action, the operation may not tie to any resource-level unit at all, leaving "*" as the only option for Resource5. We'll touch on this constraint when we actually write a policy in section 3.7.
3.3. Policy evaluation order
When multiple policies are attached to a single principal, in what order does AWS read them? As long as we're considering only policies directly attached to an IAM user or role (identity-based policies) within a single AWS account, the rules can be simplified to the following three6.
- The default is deny-all. If no policy is attached, the principal can do nothing (the only exception is the AWS account's root user, which is allow-all by default)
- An explicit
Allowoverrides the default deny - If any policy has an explicit
Deny, the operation is denied no matter how manyAllowstatements exist elsewhere
The third rule is what matters in practice for least privilege (section 3.5). If you want to reliably prohibit an operation, rather than simply not writing a permission for it, placing an explicit Deny statement lets you override any Allow that other policies might have.
These three stages are a simplification limited to identity-based policies within a single account. When resource-based policies such as S3 bucket policies, or AWS Organizations Service Control Policies, come into play, the evaluation order becomes more complex3. Because this series stays within a single AWS account, Service Control Policies never appear. Chapter 6, on the other hand, writes a single bucket policy for the S3 bucket that receives the ALB's access logs. What we'll see there is a different rule — the permissions of identity-based and resource-based policies are combined, and an explicit deny in either one overrides the result — which we'll treat as distinct from the three stages in this chapter.
3.4. Roles and trust policies
An IAM user is an identity permanently tied to a specific person or system, and it uses long-term credentials (a password or access key) for authentication. A role, by contrast, is an identity that a person or an AWS service temporarily assumes and uses. A role itself holds no long-term credentials; temporary credentials are issued to whoever assumes it7.
AWS services can also assume roles, just as we'll give an ECS task a role in Chapter 8. This raises a new question. If the policy attached to a role decides what that role can do, what decides who can assume that role?
That role is played by the trust policy. A trust policy is a special policy tied to exactly one role, and it describes who is allowed to perform the sts:AssumeRole operation8.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "<AWS service name>.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
Only the party specified in Principal can assume this role. What you can specify isn't limited to an AWS service — an IAM user or another AWS account can be the target too. Which service principal to use is determined by the AWS service that assumes the role, and we'll see the concrete value for ECS in Chapter 8.
Where an ordinary policy describes what a principal can do (authorization), a trust policy describes the entry point to the role itself — who can become this role. Confuse the two, and you can end up stuck: no matter how much power you grant a role, the trust policy lets nobody through.
The roles we'll actually create in this series anticipate the following two.
| Role | Purpose | Created | Permissions added |
|---|---|---|---|
| ECS task execution role | Infrastructure-side permissions for launching and operating containers, such as pulling container images and writing logs | Chapter 8 | Chapter 10 (reading from Secrets Manager) |
| ECS task role | Application-side permissions for the application code to call AWS services such as S3 | Chapter 8 | Chapter 10 (access to the S3 attachment bucket) |
The terraform-executors group and user we create in this chapter represent permissions assumed by a human (or CI). The two roles listed here are permissions assumed by the container itself, an AWS resource — the two are different things.
3.5. The concept of least privilege
Least privilege means allowing a principal only the minimum set of operations that its role requires9. Because an IAM user or role has no permissions at all by default, achieving least privilege is a matter of adding permissions one by one.
The guidance AWS gives Terraform users points the same way. Start from an empty policy and add the services and actions you need as they come up. Rather than granting broad permissions up front and narrowing them later, the order is to start strict and open up only what's needed10. Paring down from broad permissions later requires figuring out afterward which permissions are actually unused, which takes more effort and is more error-prone than adding to a narrow starting point.
That said, in a structure like this series, which adds new services one after another across 11 chapters, it isn't realistic to keep hand-extending a policy one action at a time as each chapter progresses. In practice, AWS CloudTrail logs fill this gap. IAM Access Analyzer can automatically generate a policy that gathers only the actions a principal actually used, based on the record of real API calls9. Rather than writing the minimum by hand from the start, the approach is to start broad and narrow down based on actual usage.
In the next section, we'll decide how far this series actually narrows things down.
3.6. A practical approach to Terraform execution permissions
Chapter 1's premise was that you already have an account that can operate AWS with administrator-equivalent permissions2. From here, we'll switch to narrowed-down credentials dedicated to Terraform.
AWS's current recommendation for human access is not an IAM user. When a person accesses AWS, centralized management through IAM Identity Center and the use of temporary credentials through it are recommended7. IAM Identity Center might look like a feature that presupposes AWS Organizations, but it can also be enabled on a standalone account that doesn't use Organizations11.
Even so, this series doesn't adopt IAM Identity Center. Setting up SSO is work done on the AWS console side, outside of Terraform, and devoting pages to that setup at the point of Chapter 3 — before there's even a network or a database yet — would pull the discussion away from Terraform implementation, which is this series' throughline. Still, even at the scale of Tasuku, which assumes solo development or an early-stage small launch, it's worth knowing that this option exists.
What we'll create in this chapter is one IAM user and the group it belongs to. We attach the policy to the group rather than writing it directly on the user so that, when the number of people involved grows, we don't have to repeat the same setup for every user.
The scope of permissions is built on top of the AWS managed policy PowerUserAccess. This policy allows almost every operation except in the three areas of IAM, AWS Organizations, and account management4. AWS itself, albeit in the context of configuring IAM Identity Center, recommends PowerUserAccess over AdministratorAccess for ordinary development work12. The content of the policy doesn't change whether you attach it directly to an IAM group or not.
The reason for choosing PowerUserAccess as the base is one of elimination. If we picked up the services this series touches (VPC, S3, ALB, ECS, RDS, Route 53, ACM, Budgets) one by one and wrote out the minimal actions for each, we'd end up continuously maintaining that policy through changes spanning 11 chapters. Here, we substitute the "start broad and narrow down from actual usage" approach from section 3.5 by borrowing the ready-made breadth of PowerUserAccess instead.
That said, PowerUserAccess has a blind spot: managing IAM itself is out of scope. Since we're having Terraform create the group and policy in this chapter, as well as the role in Chapter 8, we need to re-grant ourselves some IAM operations.
This supplement treats write operations and read operations differently. Operations that create or modify groups, policies, roles, and users can be narrowed down to just this series' own resources. Each IAM resource has a path argument, and giving it a prefix like /tasuku/ puts that same prefix into the ARN too. Using this prefix as a condition lets us narrow write permissions down to "only tasuku-related IAM resources."
There's a catch here. Because this supplementary policy is itself placed under /tasuku/, the write permissions reach the policy itself too. If we allowed iam:CreatePolicyVersion, which rewrites the contents of a policy, or iam:SetDefaultPolicyVersion, which activates the rewritten version, terraform-executors would be able to rewrite its own permissions. This chapter's supplementary policy blocks that path by not including these two13.
There's one more loophole of the same kind in iam:AttachGroupPolicy. Even if you narrow Resource down to just the group's ARN, that doesn't restrict which policy can be attached to that group. Left as is, simply attaching an AWS managed policy like AdministratorAccess directly to the terraform-executors group would put full permissions within reach after all. Preventing this requires the iam:PolicyARN condition key, which narrows down, by condition, which policy itself can be attached14. In the code in section 3.7, we split AttachGroupPolicy and AttachRolePolicy out into a separate statement and use this condition key to ensure only policies under /tasuku/ can be attached.
We've closed off two representative paths so far, but that doesn't mean we've covered every IAM self-escalation path. Several other escalation patterns are known in IAM, and as long as you hold permission to operate on your own IAM resources, blocking off paths one at a time has its limits. The proper, structural countermeasure is a permissions boundary, which fixes the upper bound of permissions an identity-based policy can grant using a separate managed policy15. Within the scope of this series' personal-development scale, we'll limit ourselves to addressing the paths we've found and leave introducing a permissions boundary out of scope.
Meanwhile, most Get* and List* actions, which only read state, don't have a mechanism for narrowing down to a specific resource in the first place, leaving "*" as the only option for Resource5. Since the real harm from being unable to narrow things down is smaller for read-only access than for write access, we'll allow "*" as is here.
Finally, there's one thing we deliberately won't create: the IAM user's access key. A resource called aws_iam_access_key does exist for issuing an access key through Terraform, but we won't use it. Terraform's state file is, by default, a local JSON file, and it can end up storing sensitive information in plaintext16. AWS itself states that many resources leave secrets in Terraform state as plaintext, and that this should be avoided wherever possible10. We'll issue the access key manually, from the AWS console or the AWS CLI.
3.7. Creating the terraform-executors group
Let's reflect the design so far in iam.tf. First, the group and attaching PowerUserAccess.
resource "aws_iam_group" "terraform_executors" {
name = "${local.name_prefix}-terraform-executors"
path = "/${var.project}/"
}
# PowerUserAccess allows every action except iam:* / organizations:* / account:*
# It's an AWS managed policy, and this one policy covers most of what this series touches
resource "aws_iam_group_policy_attachment" "terraform_executors_power_user" {
group = aws_iam_group.terraform_executors.name
policy_arn = "arn:aws:iam::aws:policy/PowerUserAccess"
}
path = "/${var.project}/" is the ARN prefix mentioned in section 3.6. The arn:aws:iam::aws:policy/PowerUserAccess specified for policy_arn follows the format common to AWS managed policy ARNs, where the account portion is the fixed string aws17.
Next is the policy that supplements the IAM operations PowerUserAccess excludes. We'll assemble the policy body with jsonencode(), a function that converts a Terraform value into a JSON string; the official aws_iam_policy documentation also recommends using it for this purpose18.
resource "aws_iam_policy" "terraform_iam_management" {
name = "${local.name_prefix}-terraform-iam-management"
path = "/${var.project}/"
description = "Supplements only the IAM operations excluded by PowerUserAccess that Terraform itself needs in this series"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "ManageProjectScopedIamWrite"
Effect = "Allow"
Action = [
"iam:CreateGroup",
"iam:DeleteGroup",
"iam:CreatePolicy",
"iam:DeletePolicy",
"iam:DetachGroupPolicy",
"iam:CreateUser",
"iam:DeleteUser",
"iam:AddUserToGroup",
"iam:RemoveUserFromGroup",
"iam:CreateRole",
"iam:DeleteRole",
"iam:UpdateAssumeRolePolicy",
"iam:DetachRolePolicy",
"iam:PutRolePolicy",
"iam:DeleteRolePolicy",
"iam:PassRole",
"iam:TagGroup",
"iam:TagPolicy",
"iam:TagRole",
"iam:TagUser",
]
# Restrict write permissions to only the group/policy/role/user created by tasuku-
Resource = [
"arn:aws:iam::*:group/${var.project}/*",
"arn:aws:iam::*:policy/${var.project}/*",
"arn:aws:iam::*:role/${var.project}/*",
"arn:aws:iam::*:user/${var.project}/*",
]
},
{
# Restricting Resource to just the group/role doesn't limit which policies can
# be attached (attaching AdministratorAccess etc. would still work), so the
# iam:PolicyARN condition also limits attachable policies to those under /tasuku/
Sid = "AttachProjectScopedPoliciesOnly"
Effect = "Allow"
Action = [
"iam:AttachGroupPolicy",
"iam:AttachRolePolicy",
]
Resource = [
"arn:aws:iam::*:group/${var.project}/*",
"arn:aws:iam::*:role/${var.project}/*",
]
Condition = {
ArnLike = {
"iam:PolicyARN" = "arn:aws:iam::*:policy/${var.project}/*"
}
}
},
{
# Most Get/List actions don't support resource-level permissions and require
# Resource "*"; read-only, so the blast radius is limited
Sid = "ReadIamForPlanning"
Effect = "Allow"
Action = [
"iam:Get*",
"iam:List*",
]
Resource = "*"
},
]
})
}
resource "aws_iam_group_policy_attachment" "terraform_executors_iam_management" {
group = aws_iam_group.terraform_executors.name
policy_arn = aws_iam_policy.terraform_iam_management.arn
}
The reason iam:PassRole is included in the first statement is that we also want to restrict the operation of passing a role to ECS from Chapter 8 onward to roles under that same /tasuku/ path. The reason AttachGroupPolicy and AttachRolePolicy are split into a separate statement is that only these two use the additional condition key iam:PolicyARN. The ArnLike in Condition only grants permission when iam:PolicyARN — the ARN of the policy you're trying to attach — matches the pattern under /tasuku/14.
Finally, here's the user who actually uses this whole set of permissions.
# The narrowed-down identity we'll switch to from root/administrator-equivalent credentials
resource "aws_iam_user" "terraform_executor" {
name = "${local.name_prefix}-terraform"
path = "/${var.project}/"
}
resource "aws_iam_user_group_membership" "terraform_executor" {
user = aws_iam_user.terraform_executor.name
groups = [
aws_iam_group.terraform_executors.name,
]
}
The reason we use aws_iam_user_group_membership instead of aws_iam_group_membership for adding a user to a group is that the latter manages a group's list of member users exclusively, whereas the former is an additive, user-driven addition19. Even though there's only one person right now, this one line won't drag in and break existing memberships when more users are added down the line.
Finally, let's add one mechanism that tells us, every time we run plan or apply, whether the person executing has switched over to the narrowed-down user.
data "aws_caller_identity" "current" {}
# Warn on every plan/apply if the executor hasn't switched to the scoped-down user
# (it's correct for this warning to appear on the first apply that creates this very resource, since the executor is still admin-equivalent then)
check "terraform_caller_is_scoped_user" {
assert {
condition = data.aws_caller_identity.current.arn == aws_iam_user.terraform_executor.arn
error_message = "The current executor is not ${aws_iam_user.terraform_executor.name}. Check whether you're still using administrator-equivalent credentials."
}
}
The check block doesn't stop plan or apply even when the condition isn't met — it only issues a warning20. On the apply that first creates this chapter's aws_iam_user, the executor is still using administrator-equivalent credentials, so it's correct for this warning to appear. From the next chapter on, the warning will keep appearing if the executor hasn't switched to this user, letting you notice if you forgot to switch.
3.8. Verification
Once you've placed the iam.tf file we've built so far in the same directory as the .tf files this series has written up to now, check it in the following order.
terraform fmt -check -recursive -diff
terraform init
terraform validate
terraform plan
Running plan should show the following 6 additions in the plan.
- one
aws_iam_group - one
aws_iam_policy - two
aws_iam_group_policy_attachment - one
aws_iam_user - one
aws_iam_user_group_membership
Separately from these 6 additions, a terraform_caller_is_scoped_user warning will be displayed. At this point the executor is still using administrator-equivalent credentials, so this warning is expected.
After apply, issue an access key for the created IAM user from the AWS console or the AWS CLI. Set the issued key on a new AWS CLI profile, confirm that the result of aws sts get-caller-identity matches the ARN of the IAM user created in this chapter, and then use this profile for apply from the next chapter onward. Once you've made this switch, retire the root or administrator-equivalent credentials from ordinary work. Also confirm that running terraform plan again after switching makes the terraform_caller_is_scoped_user warning disappear.
Summary
- IAM's request processing is a two-stage structure of authentication (who) and authorization (what they can do), and it's the policy — a JSON document — that decides authorization
- Policy evaluation, limited to identity-based policies within a single account, can be simplified to three stages: default deny → an explicit allow overrides it → an explicit deny always wins
- A role is an identity you temporarily assume; the trust policy handles who can assume it, while the ordinary policy handles what they can do once they've assumed it
- Least privilege is the idea of adding permissions little by little, but this series struck a balance with the maintenance cost across 11 chapters by building on
PowerUserAccessand supplementing only the IAM operations - We created an IAM user and group dedicated to running Terraform, and we issue the access key manually to avoid exposing it in the state file
Next steps
- Chapter 4: VPC: CIDR design and the 2-AZ subnet structure
- Chapter 2: Terraform Basics: Minimal HCL syntax and the budget alert we created in the first apply
- Series table of contents: The structure of all 12 chapters and how to work through them