Terraform in Practice #7 IAM as Code: Instance Roles, Least Privilege, and GitHub Actions OIDC

5 min read

Time to settle the permissions debt we have been deferring for six parts. The instance needs to read SSM parameters (#6), accept connections through Session Manager (#3), and eventually CI needs to deploy to AWS. All of it is IAM. In the console, IAM means wrestling with blobs of JSON — but move it into Terraform and policies become reviewable code, resource references eliminate ARN typos, and it turns out to be the area where the payoff of IaC is felt most strongly.

The trust policy: who can assume this role #

An IAM role is the combination of two policies: who can assume it (the trust policy) and what they can do once they have (the permission policy). We saw how to build JSON with jsonencode in Basics #4, but IAM has a better tool: the aws_iam_policy_document data source, which lets you write policies in HCL syntax and converts them to JSON. Structural errors get caught at the validate stage and references slot in naturally, which is why it has become the standard tool for IAM code.

iam.tf
# iam.tf (new file)
data "aws_iam_policy_document" "ec2_assume" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "web" {
  name_prefix        = "myapp-${var.env}-web-"
  assume_role_policy = data.aws_iam_policy_document.ec2_assume.json
}

The permission policy: only what is needed, per resource #

The instance needs two sets of permissions: Session Manager operation (attaching an AWS managed policy) and reading our secret (written by hand).

iam.tf
resource "aws_iam_role_policy_attachment" "ssm_core" {
  role       = aws_iam_role.web.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

data "aws_iam_policy_document" "web_permissions" {
  statement {
    actions   = ["ssm:GetParameter"]
    resources = [aws_ssm_parameter.db_password.arn]
  }

  statement {
    actions   = ["s3:GetObject"]
    resources = ["${aws_s3_bucket.assets.arn}/*"]
  }
}

resource "aws_iam_role_policy" "web" {
  name_prefix = "web-"
  role        = aws_iam_role.web.id
  policy      = data.aws_iam_policy_document.web_permissions.json
}

The heart of least privilege is in the resources lines. Instead of opening ssm:GetParameter to "*", we narrowed it to an ARN reference to the parameter resource created last time. If the parameter name changes, the policy follows automatically, and this role cannot read any other secret. The typos and omissions of the copy-ARN-from-console era disappear behind a single reference — this is the signature scene of IAM as code.

The instance profile: the adapter that attaches a role to EC2 #

EC2 cannot take a role directly; it requires a wrapper called an instance profile. It is a layer that exists for historical reasons, so just remember it as an idiom.

iam.tf
resource "aws_iam_instance_profile" "web" {
  name_prefix = "myapp-${var.env}-web-"
  role        = aws_iam_role.web.name
}
compute.tf
# add to the launch template in compute.tf
resource "aws_launch_template" "web" {
  # ... existing content ...

  iam_instance_profile {
    name = aws_iam_instance_profile.web.name
  }
}

After apply, once the ASG replaces the instances (new launch template version), the connect button in the console’s Session Manager becomes active. The “access without an SSH port” we promised in #3 is now complete. Connect and run last part’s aws ssm get-parameter command, and you can confirm the secret-read permission works as well.

GitHub Actions OIDC: CI without access keys #

Finally, we prepare authentication for something that is not a person. For CI (GitHub Actions) to deploy to AWS it needs credentials, and the approach of issuing an access key and stashing it in GitHub secrets carries leak and rotation problems. The current standard is OIDC federation: make AWS trust the short-lived token GitHub issues per workflow run, so a role can be assumed with no stored keys at all.

iam.tf
resource "aws_iam_openid_connect_provider" "github" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
}

data "aws_iam_policy_document" "github_assume" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]

    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.github.arn]
    }

    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:aud"
      values   = ["sts.amazonaws.com"]
    }

    condition {
      test     = "StringLike"
      variable = "token.actions.githubusercontent.com:sub"
      values   = ["repo:my-org/myapp:*"]
    }
  }
}

resource "aws_iam_role" "github_actions" {
  name_prefix        = "myapp-${var.env}-gha-"
  assume_role_policy = data.aws_iam_policy_document.github_assume.json
}

The key is the second condition. Restricting the sub claim to repo:my-org/myapp:* means only workflows from our repository can assume this role. Without this restriction, any GitHub repository could assume the role — a gaping hole — which makes this the single most important line in an OIDC setup. What deployment permissions the role should get, and the workflow-side configuration, will be completed in Operations series #1, where we run plan and apply from CI.

Recap #

What we covered in this part:

  • An IAM role is the combination of a trust policy and a permission policy, and the aws_iam_policy_document data source is the standard tool for IAM code
  • Permissions are narrowed with resource ARN references. Least privilege that stays typo-free is the biggest win of IAM as code
  • EC2 takes a role through an adapter called an instance profile, wired into the launch template. With this, Session Manager access and secret reads are complete
  • CI assumes a role via OIDC instead of access keys. The condition restricting the sub claim to the repository is the safety pin of this setup
  • The CI role’s deployment permissions and the workflow itself continue in Operations series #1

In the next part (#8 Deploying on ECS Fargate), we give the compute layer a generational upgrade. We examine the limits of AMI and user_data, then run EC2 and containers side by side behind the same ALB while cutting over.

X