AWS role assumption in GitHub Actions

GitHub Actions along with repository/organization-level secrets cover most scenarios where we want CI to perform actions against infrastructure or APIs.

Simply, place our secrets under a repository's Settings > Secrets > Actions and utilize them in a workflow like so:

- name: Build Packer image
run: packer build ubuntu.pkr.hcl
env:
PKR_VAR_do_token: ${{ secrets.DO_TOKEN }}

But this may not always be the best or cleanest way of workflow authentication.

To friends and colleagues I often try to make it known that one of my favorite AWS products is IAM, or Identity and Access Management. Frankly, it's one of the most powerful APIs in the cloud computing space for controlling finely-grained access to managed resources across one's infrastructure.

One of the many allowances of IAM is the ability to use external OIDC providers to generate short-lived credentials for specific roles in IAM. This sidesteps the need to create an IAM user and static access keys and secret access keys—and the eventual need to revoke or rotate said keys.

AWS maintains the action configure-aws-credentials which enables role assumption using GitHub's existing OIDC provider.

Configure IAM

All example configuration will be illustrated using Terraform resources from the AWS provider.

First we'll need to create an identity provider in your AWS account pointing to GitHub's OIDC provider (GitHub docs):

resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
# Hex-encoded SHA-1 hash of the X.509 domain certificate
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}

This identity provider resource only needs to be configured once within a given AWS account.

Now let's configure a policy, assume role policy, and role for use by our GitHub Actions workflow. For this example it'll be a not-so-strict role for building AMIs using Packer:

variable "repositories" {
type = list(string)
description = "repository names in the `organization/repository` format"
}

data "aws_caller_identity" "current" {}

resource "aws_iam_role" "packer" {
name = "packer"
description = "Utilized by Packer running in CI environments"
assume_role_policy = data.aws_iam_policy_document.packer_github.json
force_detach_policies = true
}

data "aws_iam_policy_document" "packer_github" {
statement {
principals {
type = "Federated"
identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:oidc-provider/token.actions.githubusercontent.com"]
}

actions = ["sts:AssumeRoleWithWebIdentity"]

condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values = [for repo in var.repositories : "repo:${repo}:*"]
}
}
}

resource "aws_iam_role_policy_attachment" "packer" {
role = aws_iam_role.packer.name
policy_arn = aws_iam_policy.packer.arn
}

resource "aws_iam_policy" "packer" {
name = "packer"
path = "/"
description = "Utilized by Packer running in CI environments"

policy = data.aws_iam_policy_document.packer.json
}

data "aws_iam_policy_document" "packer" {
statement {
effect = "Allow"
actions = [
"ec2:AttachVolume",
"ec2:AuthorizeSecurityGroupIngress",
"ec2:CopyImage",
"ec2:CreateImage",
"ec2:CreateKeypair",
"ec2:CreateSecurityGroup",
"ec2:CreateSnapshot",
"ec2:CreateTags",
"ec2:CreateVolume",
"ec2:DeleteKeyPair",
"ec2:DeleteSecurityGroup",
"ec2:DeleteSnapshot",
"ec2:DeleteVolume",
"ec2:DeregisterImage",
"ec2:DescribeImageAttribute",
"ec2:DescribeImages",
"ec2:DescribeInstances",
"ec2:DescribeInstanceStatus",
"ec2:DescribeRegions",
"ec2:DescribeSecurityGroups",
"ec2:DescribeSnapshots",
"ec2:DescribeSubnets",
"ec2:DescribeTags",
"ec2:DescribeVolumes",
"ec2:DetachVolume",
"ec2:GetPasswordData",
"ec2:ModifyImageAttribute",
"ec2:ModifyInstanceAttribute",
"ec2:ModifySnapshotAttribute",
"ec2:RegisterImage",
"ec2:RunInstances",
"ec2:StopInstances",
"ec2:TerminateInstances"
]
resources = ["*"]
}
}

The variable repositories should be provided as a list of repositories like so:

repositories = [
"raylas/yeah",
"raylas/sbc-reservoirs-history"
]

GitHub Actions workflow

Now to the fun bits. Below is an example workflow for building an EC2 AMI via Packer:

name: Main

on:
push:
branches:
- main
pull_request:
branches:
- main

env:
AWS_ACCOUNT_ID: <aws_account_id>

jobs:
build:
name: Build image
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v3

- name: Install Packer
run: |-
curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo apt-key add -
sudo apt-add-repository "deb [arch=amd64] https://apt.releases.hashicorp.com $(lsb_release -cs) main"
sudo apt-get update && sudo apt-get install packer

- name: Retrieve AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
role-to-assume: arn:aws:iam::$AWS_ACCOUNT_ID:role/packer
aws-region: us-west-2

- name: Build Packer image
run: packer build ubuntu.pkr.hcl

There are two important sections to this workflow.

This allows the JWT to be requested from GitHub's OIDC provider (docs):

permissions:
id-token: write
contents: read

And this is the actual action in use. You simply need to specify your role ARN and default region. Any further steps in the job using the AWS SDK will be able to retrieve these credentials from the environment:

- name: Retrieve AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
role-to-assume: arn:aws:iam::$AWS_ACCOUNT_ID:role/packer
aws-region: us-west-2

Most importantly, this token is short-lived and requires no rotation.

In the role's assume role policy we are explicit about which GitHub repositories are allowed to successfully request tokens:

condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values = [for repo in var.repositories : "repo:${repo}:*"]
}

Pretty rad, huh?