Are you still using keys?

Practical guide to deploy with GitHub Actions and Terraform to AWS.

by: niek  on 2022-12-02

This blog post is the first in a series of two in to explain how you can deploy keyless with GitHub Actions to AWS. This first part covers the basics of setting up OIDC integration between GitHub Actions and AWS. In the second more advanced practices are explained, such as deploying using GitHub Action shareable workflows with custom OIDC claims. This blog is practical and shows how to use OIDC with GitHub Actions to deploy to AWS. The same patterns apply to deploy to other OIDC enabled clouds like Google or Azure.

 Source code for this post

Deploy keyless with GitHub Actinns

Are you still using secrets to deploy from GitHub Actions to your cloud provider? Do you still define secrets in your workflow jobs and rotate them regularly? Do you know there is no need anymore to define secrets to connect to common cloud providers? You can avoid the need of using secrets when deploying with GitHub Actions to a provider supporting OpenID Connect (OIDC). New to OIDC, watch the talk “OAauth 2.0 and OpenID Connect in plain English”.

With OIDC you define a trust relation between GitHub Actions end your cloud, in our case AWS. You define a trusted relationship between your AWS account and the OIDC provider of GitHub Actions. And then define conditions under what circumstances an AWS IAM role can be assumed. New to assuming a role in AWS, watch this two minute tutorial.

Once the integration is set up, your workflows can obtain a short-lived token. This token is allowed to assume a role to access resources in AWS, based on the policies defined for the role. This post provides you with a practical guide on how to set up the required infrastructure resources with IaC, and shows a simple example to validate the setup.

Setup OIDC for GitHub Actions and AWS

GitHub provides detailed documentation for authenticating Actions to AWS that nicely describes all the required steps. It is highly recommended to read the article on GitHub to get a deep understanding. Since we love to automate everything we are not setting up the required resources manually. Instead we use Terraform to automate the creation of the resources.

The example in this blog is using the repository 040code/blog-oidc-github-actions-aws, you find a full example here. You can run the example by cloning or forking the repository and run the Terraform code by setting the variable repo to your repository, e.g owner/repo.

OIDC Provider

The first step is to define the OIDC provider. To define the OIDC provider we need to look-up the thumbprint for the GitHub Actions SSL certificate. The AWS web console can do this for you. The Terraform TLS provider has a data source that can do the look-up for us. Once we know the thumbprint, defining the OIDC provider is straightforward.

data "tls_certificate" "github_actions" {
  url = "https://token.actions.githubusercontent.com"
}

resource "aws_iam_openid_connect_provider" "github_actions" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = data.tls_certificate.github_actions.certificates.*.sha1_fingerprint
}

Now run terrafrom apply as a result the OIDC provider for GitHub is created in your AWS account, this is a global resource you only have to create it once per AWS account.

Next, define a role and trust relationship, this trust defines from which repository and under what condition the role can be assumed.

resource "aws_iam_role" "github_actions" {
  name               =  "blog"
  path               = "/github-actions/"
  assume_role_policy = data.aws_iam_policy_document.github_actions_trusted_identity.json
}

data "aws_iam_policy_document" "github_actions_trusted_identity" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]
    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.github_actions.arn]
    }

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

    condition {
      test     = "StringLike"
      variable = "token.actions.githubusercontent.com:sub"
      values   = ["repo:040code/blog-oidc-part-1:ref:refs/heads/main*"]
    }
  }
}

It is important that you take care defining the trust relation. One mistake, and you could open your AWS account to any repository on GitHub.

Assuming the role is allowed when all conditions are met. In general conditions are combined in a logical and and values in the test with a logical or. Ensure you are familiar with IAM conditions.

iam-condition-block

In the example above the iss, aud and sub attributes are checked in the policy. The first two are straightforward. Checking the sub requires care. When you accept any value in sub or even don’t check the field. You will allow anyone access to your AWS role via GitHub Actions. And likely you don’t want to open our AWS account to the whole universe!

For example, the condition below allows any Action running in the repository 040code/blog-oidc-github-actions-aws from any ref (branch / tag) to perform the assume role operation and access the resources granted via this role.

condition {
    test     = "StringLike"
    variable = "token.actions.githubusercontent.com:sub"
    values   = ["repo:040code/blog-oidc-github-actions-aws*"]
}

By adding a condition like the one below, you can deny any pull request to assume the role. Similar checks can be done or allow or deny the ref, or context.

condition {
    test     = "StringNotLike"
    variable = "token.actions.githubusercontent.com:sub"
    values   = ["repo:040code/blog-oidc-part-1::pull_request"]
}

It should be clear, that you need to take care of defining the conditions for which you define the trust. In part 2 you can read more about advanced subjects checks by customizing the sub field of the JWT token.

The final step is to allow access to some resources via the created role. This blog post only shows the pattern by example. Remember that it is good practice to write policies with the least privileged access needed. For simplicity we have created a quite coarse-grained role for the blog. Time to define the required Terraform resources.

  • An S3 bucket (no policies, no encryption in this example)
  • A policy that grants the role created before access to the bucket.
resource "aws_s3_bucket" "blog" {
  bucket = "040code-blog-oidc-github-actions-aws"

resource "aws_iam_role_policy" "s3" {
  name   = "s3-policy"
  role   = aws_iam_role.github_actions.name
  policy = data.aws_iam_policy_document.s3.json
}

data "aws_iam_policy_document" "s3" {
  statement {
    actions = [
      "s3:ListBucket",
      "s3:GetObject",
      "s3:PutOjbect"
    ]

    resources = [
      aws_s3_bucket.blog.arn, "${aws_s3_bucket.blog.arn}*"
    ]
  }
}

For convenience add the following outputs to your terraform script. Those are handy when you create the workflow later. To apply this configuration, just run terraform apply


output "role" {
    value = aws_iam_role.github_actions.arn
}

output "bucket" {
    value = aws_s3_bucket.blog.name
}

✨ Test locally ✨

The role created can only be assumed via GitHub Actions, sometimes it is convenient to test from your own environment. This can be done by adding the following statement to the trust condition. After applying you can assume the role locally. Replace your the user_id by your user id ARN.

statement {
  actions = ["sts:AssumeRole"]
  principals {
    type        = "AWS"
    identifiers = ["arn:aws:iam::<aws_account_id>:user/<user_id>"]
  }
}

Deploy without keys

Time to show this setup works, up to here no GitHub repository was needed. To test the setup, create a GitHub repository. Which can be a private or public one in any org or user space; it really does not matter. The easiest way is just to fork the example repository. Once you have created the repository, create a secret to hide you AWS Account ID. In this post we use a secret named AWS_ACCOUNT_ID. The ID should not be a secret but at least you make it one small step harder for an attacker.

Earlier you have defined the OIDC provider in your AWS account, this provider is used to provider AWS keys based on the JWT token provided by the GitHub OIDC provider. AWS provides an action: aws-actions/configure-aws-credentials to obtain an AWS STS token via OIDC. This action makes the whole process of obtaining short-lived secrets painless; you only need to add the action to your workflow and configure the role to assume. All secret handling is done by the action.

The workflow below brings it all together. The workflow shows how you can access an S3 bucket from GitHub Actions without configuring a secret.

name: S3
on:
  workflow_dispatch:
  push:

jobs:
  deploy:
    permissions:
      id-token: write
    runs-on: ubuntu-latest
    steps:
      - uses: actions/setup-node@v3
        with:
          node-version: 16

      - name: configure aws credentials
        uses: aws-actions/configure-aws-credentials@v1-node16
        with:
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions/blog
          role-session-name: gh-actions
          aws-region: eu-west-1

      - name: deploy
        run: |
          npx cowsay -f ghostbusters "Running ${{ github.workflow }}" > message.txt
          aws s3 cp message.txt s3://${{ github.repository_owner }}-${{ github.event.repository.name }}/${{ github.run_id }}.txt
          rm message.txt

      - name: check
        run: |
          aws s3 cp s3://${{ github.repository_owner }}-${{ github.event.repository.name }}/${{ github.run_id }}.txt result.txt
          cat result.txt  

Once you have pushed the workflow to your repository, a GitHub Action job starts running. The result shows some ascii art.

workflow

That was all, no keys needed anymore, no key rotation required. Time to stop using keys and start using OIDC. In part 2 we discuss a more advanced use-case in which you can use OIDC to deploy with a shared workflow.

Read next: