Mastering GitHub Actions in a Secure Way


Explore the Basics of Workload Identity, Terraform, and Deploying on Cloud Run

/images/2023/12-22/header-c.jpg

Fortress ruins on Sarushima, an uninhabited island in Yokosuka.

This article is the 33rd entry for the #AcompanyChristmasAdventCalendar2023. I’m Yamauchi Katsuyoshi, a backend engineer from the Data Clean Room team of Acompany’s Product Division. Founded in 2018 at Nagoya University, Acompany is a pioneering startup in the field of privacy tech. We’ve developed QuickMPC, a robust engine for secure multiparty computation, and have made it available as OSS.

Let me cut to the chase. We’ll outline the steps for deploying a Docker image to Cloud Run via GitHub Actions using Workload Identity Federation, which eliminates the need to manage a long-lived JSON service account key and enhances security accordingly.

Prerequisites

Before diving into the setup, the following prerequisites are assumed:

  • Google Cloud Platform: A GCP project should be set up with you assigned as the owner.
  • Google Cloud CLI: Install the Google Cloud SDK 457.0.0 to interact with Google Cloud services from your command line. (Install the gcloud CLI)
  • Terraform: Install the Terraform CLI v1.6.2 or a later version installed on your local machine. (Install Terraform)
  • Artifact Registry: Ensure that your Docker images are stored and managed in it.
  • Cloud Run: A running instance of Cloud Run is needed.
  • GitHub Repository: Confirm that you have write access to the GitHub repository.

Less is More

The server side is often invisible. It can be complex to grasp. For development teams, CI/CD is an essential part of any product, and a simpler deployment flow is typically more effective.

If you’re new to Terraform, consider this. While you can use just the gcloud commands for setup components, Terraform has significant benefits.

  1. It manages and stores all your resource states in the cloud, facilitating collaborative teamwork. This feature ensures team members can track the current status of resources without the need for manually cross-checking.

  2. It simplifies the process of adding, removing, or recreating them as you go. Such adaptability is crucial in environments where infrastructure needs to be frequently changed. However, be aware that vital resources can sometimes vanish unexpectedly, which requires vigilance in every operation to avoid making errors. More details on this will be provided later.

I digress, but Terraform, since version 1.6, has faced criticism from some open-source supporters due to its license change to BSL (Business Source License) v1.1 from MPL v2.0. In my view, there are no issues for individual developers using it for regular purposes. However, I recommend doing your own research on this matter.

Keyless Authentication

We previously had to store credential JSON files in the repositories for GCP authentication, once downloaded from GCP, which remained valid by default, typically until 9999-12-31T23:59:59Z, unless explicitly deleted by the user. However, this approach is no longer necessary with the introduction of OpenID Connect (OIDC). OIDC utilises short-lived tokens instead of the nearly indefinite lifespan of the credential file. This marks a significant improvement. Terrific!

Google’s post offers an easily understandable explanation of this matter, and I absolutely recommend checking it out.

Unlike JSON service account keys, Workload Identity Federation generates short-lived OAuth 2.0 or JWT credentials. By default, these credentials automatically expire one hour after they are created.

Managing service account keys is a risk. The risk is due to the potential for security breaches if these keys are mishandled or exposed. At the same time, managing these keys may incur management costs that include the resources and time spent on ensuring the keys are securely stored.

Anyway, what is the OIDC? It’s an extension of the OAuth 2.0 protocol. The specification describes it as follows:

OpenID Connect 1.0 is a simple identity layer on top of the OAuth 2.0 [RFC6749] protocol.

Our goal here is not to gain a complete understanding of OIDC to the extent of being able to implement it. However, it’s important to understand its flow to some extent to grasp an overview.

The process is represented in the following sequence diagram. Although it may seem complex, the underlying mechanism is simple.

sequenceDiagram participant User as User participant GitHub as GitHub Actions participant GCP_WIPP as WIP Provider participant GCP_WIP as WIP participant GCP_IAM as IAM participant GCP_S as GCP Services User->>GCP_WIP: 1. Create Workload Identity Pool (WIP) by Terraform User->>GCP_WIPP: 2. Create WIP Provider by Terraform User->>GitHub: 3. Push codes GitHub->>GCP_WIPP: 4. Request OIDC token GCP_WIPP-->>GitHub: 5. Return OIDC token GitHub->>GCP_WIP: 6. Exchange OIDC token for GCP access token GCP_WIP->>GCP_IAM: 7. Validate OIDC token GCP_IAM-->>GCP_WIP: 8. Issue access token GCP_WIP-->>GitHub: 9. Return access token GitHub->>GCP_S: 10. Access GCP services with token GCP_S-->>GitHub: 11. Return GCP services response

To put it briefly, the OIDC token is issued by GCP’s WIP Provider (Step 4), which initiates the process when GitHub Actions requests access to GCP resources. Next, GitHub Actions submits the obtained OIDC token to GCP’s WIP (Step 6). Once the WIP verifies the authenticity of the OIDC token (Step 7), it proceeds to make internal requests to Google IAM for the generation of an access token (Step 8). Finally, the IAM issues the access token and returns it to the GitHub Actions via the WIP (Step 9).

Structure of OIDC Tokens

The OIDC token is typically formatted as a JSON Web Token (JWT) and is Base64URL encoded, as shown in the following example:

  GET /authorize?
    response_type=id_token
    &client_id=s6BhdRkqt3
    &redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb
    &scope=openid%20profile%20email
    &nonce=n-0S6_WzA2Mj
    &state=af0ifjsldkj HTTP/1.1
  Host: server.example.com

  HTTP/1.1 302 Found
  Location: https://client.example.org/cb#
    id_token=eyJraWQiOiIxZTlnZGs3IiwiYWxnIjoiUlMyNTYifQ.ewogImlz
    cyI6ICJodHRwOi8vc2VydmVyLmV4YW1wbGUuY29tIiwKICJzdWIiOiAiMjQ4
    Mjg5NzYxMDAxIiwKICJhdWQiOiAiczZCaGRSa3F0MyIsCiAibm9uY2UiOiAi
    bi0wUzZfV3pBMk1qIiwKICJleHAiOiAxMzExMjgxOTcwLAogImlhdCI6IDEz
    MTEyODA5NzAsCiAibmFtZSI6ICJKYW5lIERvZSIsCiAiZ2l2ZW5fbmFtZSI6
    ICJKYW5lIiwKICJmYW1pbHlfbmFtZSI6ICJEb2UiLAogImdlbmRlciI6ICJm
    ZW1hbGUiLAogImJpcnRoZGF0ZSI6ICIwMDAwLTEwLTMxIiwKICJlbWFpbCI6
    ICJqYW5lZG9lQGV4YW1wbGUuY29tIiwKICJwaWN0dXJlIjogImh0dHA6Ly9l
    eGFtcGxlLmNvbS9qYW5lZG9lL21lLmpwZyIKfQ.rHQjEmBqn9Jre0OLykYNn
    spA10Qql2rvx4FsD00jwlB0Sym4NzpgvPKsDjn_wMkHxcp6CilPcoKrWHcip
    R2iAjzLvDNAReF97zoJqq880ZD1bwY82JDauCXELVR9O6_B0w3K-E7yM2mac
    AAgNCUwtik6SjoSUZRcf-O5lygIyLENx882p6MtmwaL1hd6qn5RZOQ0TLrOY
    u0532g9Exxcm-ChymrB4xLykpDj3lUivJt63eEGGN6DH5K6o33TcxkIjNrCD
    4XB1CKKumZvCedgHHF3IAK4dVEDSUoGlH9z4pP_eWYNXvqQOjGs-rDaQzUHl
    6cQQWNiDpWOl_lxXjQEvQ
    &state=af0ifjsldkj

As you can see, id_token parameters in the response is Base64URL-encoded string. The id_token contains the authenticated user’s information. As a side note, id_token does not typically contain the newline codes.

When Base64URL-decoded, the header of the id_token includes the algorithm used to sign the JWT, as shown below:

  {"kid":"1e9gdk7","alg":"RS256"}

In this context, RS256 denotes the RSASSA-PKCS-v1_5 algorithm using SHA-256. The kid (key identifier) corresponds to a public key provided by the issuer, used to validate the signed JWT. Authentication servers typically publish JSON Web Key Set (JWKS), from which we can retrieve the keys matching kid.

To verify a JWT, first, extract the header and payload from the id_token. These elements are originally combined with the signature and separated by periods, conforming to the JSON Web Signature (JWS) standard. In essence, a JWT is a token format that utilizes JWS for security, and an OIDC token is a kind of JWT specifically designed for authentication in OpenID Connect.

Next, use the corresponding public key to verify whether the received signature was correctly generated by the OIDC token issuer for the string concatenated with the header and payload.

On another note, in 2014, the specification of JWT and JWx (aka JOSE) received the Special European Identity Award for Best Innovation in Security in the API Economy. This award was given to the IETF for their efforts in developing JSON-based, cryptographically secure, and user-friendly data formats.

In order to gain a better understanding of this verification to detect tampering, commands for performing this process could be as follows:

# These commands simulate the verification.
echo -n "Base64URL-encoded the header and payload (plain text)" | openssl base64 -d > message.txt
echo -n "Base64URL-encoded the signature (binary)" | openssl base64 -d > signature.bin
openssl dgst -sha256 -verify public_key.pem -signature signature.bin message.txt

Decoding the id_token payload typically includes claims such as iss (issuer), sub (subject), aud (audience), exp (expiration time), iat (issued at), among others. Here’s an example of what the decoded payload of the id_token might include:

  {
   "iss": "http://server.example.com",
   "sub": "248289761001",
   "aud": "s6BhdRkqt3",
   "nonce": "n-0S6_WzA2Mj",
   "exp": 1311281970,
   "iat": 1311280970,
   "name": "Jane Doe",
   "given_name": "Jane",
   "family_name": "Doe",
   "gender": "female",
   "birthdate": "0000-10-31",
   "email": "janedoe@example.com",
   "picture": "http://example.com/janedoe/me.jpg"
  }

Additional claims like at_hash (hash of the access token) and auth_time (the time when the user was last authenticated), among others, may also be included in the case of OIDC.

It’s important to note that this approach to identity management is not exclusive to GCP or GitHub; similar concepts exist in other cloud platforms, such as Azure Workload Identity, and IAM Roles Anywhere from AWS, among others.

Workload Identity Setup

Now let’s focus on how to configure Workload Identity Federation using Terraform. This involves creating various resources in GCP to connect GitHub Actions with GCP services. First of all, create a main.tf file with the following content:

# Define a Workload Identity Pool
resource "google_iam_workload_identity_pool" "pool" {
  workload_identity_pool_id = var.pool_id
  display_name              = var.pool_id
  description               = "GitHub Actions Pool"
  timeouts {}
}

output "workload_identity_pool_name" {
  value = google_iam_workload_identity_pool.pool.name
}

# Define a Workload Identity Pool Provider
resource "google_iam_workload_identity_pool_provider" "gh_provider" {
  workload_identity_pool_id          = google_iam_workload_identity_pool.pool.workload_identity_pool_id
  workload_identity_pool_provider_id = var.provider_id
  display_name                       = "Provider for deployments"
  disabled                           = false

  oidc {
    issuer_uri        = "https://token.actions.githubusercontent.com"
    allowed_audiences = []
  }

  # Mapping of attributes for identity federation
  attribute_mapping = {
    "google.subject"       = "assertion.sub",
    "attribute.actor"      = "assertion.actor",
    "attribute.repository" = "assertion.repository",
  }
  timeouts {}
}

output "workload_identity_pool_provider_name" {
  value = google_iam_workload_identity_pool_provider.gh_provider.name
}

resource "google_service_account" "account" {
  account_id   = var.iam_id
  display_name = var.iam_name
  description  = "Access from GitHub Actions for deployments"
  disabled     = false
  timeouts {}
}

output "service_account_email" {
  value = google_service_account.account.email
}

# Apply custom IAM policy to Service Account
resource "google_service_account_iam_policy" "policy" {
  service_account_id = google_service_account.account.name
  policy_data        = data.google_iam_policy.policy.policy_data
}

# Assigning Workload Identity User role to GitHub Repository
data "google_iam_policy" "policy" {
  binding {
    members = [
      "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.pool.name}/attribute.repository/${var.github_repository}"
    ]
    role = "roles/iam.workloadIdentityUser"
  }
}

# Grant role for uploading Docker images to Artifact Registry
resource "google_project_iam_member" "artifactregistry_writer" {
  project = var.project_name
  role    = "roles/artifactregistry.writer"
  member  = "serviceAccount:${google_service_account.account.email}"
}

# Grant role for deploying Docker images to Cloud Run
resource "google_project_iam_member" "cloudrun_admin" {
  project = var.project_name
  role    = "roles/run.admin"
  member  = "serviceAccount:${google_service_account.account.email}"
}

# Necessary for allowing access to GCP resources when deploying Cloud Run
resource "google_project_iam_member" "service_account_user" {
  project = var.project_name
  role    = "roles/iam.serviceAccountUser"
  member  = "serviceAccount:${google_service_account.account.email}"
}

# Setting up secrets required for GCP authentication within GitHub Actions
resource "github_actions_secret" "secret_service_acount" {
  repository      = var.repo_name
  secret_name     = var.secrets_service_account
  plaintext_value = google_service_account.account.email
}

resource "github_actions_secret" "secret_provider" {
  repository      = var.repo_name
  secret_name     = var.secrets_identity_provider
  plaintext_value = google_iam_workload_identity_pool_provider.gh_provider.name
}

terraform.tfvars should include your specific project settings. These values set in secrets_service_account and secrets_identity_provider are key names for storing in Secrets of GitHub.

project_name     = "my-project"
region           = "asia-northeast1"

pool_id     = "pool-my-project"
provider_id = "provider-my-project"

iam_id   = "gsa-my-project"
iam_name = "GitHub Deployment Service Account"

# [REPO_OWNER_NAME]/[REPO_NAME]
github_repository = ""

# [REPO_OWNER_NAME]
repo_owner = ""
# [REPO_NAME]
repo_name  = ""

secrets_service_account   = "GCP_SERVICE_ACCOUNT"
secrets_identity_provider = "GCP_IDENTITY_PROVIDER"

provider.tf should include the required providers.

provider "google" {
  project = var.project_name
  region  = var.region
}

provider "github" {
  token = var.github_token
  owner = var.repo_owner
}

variables.tf defines the necessary variables, ensuring modularity.

variable "project_name" {
  type     = string
  nullable = false
}

variable "region" {
  type     = string
  nullable = false
}

variable "pool_id" {
  type        = string
  nullable    = false
}

variable "provider_id" {
  type        = string
  nullable    = false
}

variable "iam_id" {
  type        = string
  nullable    = false
}

variable "iam_name" {
  type        = string
  nullable    = false
}

variable "github_repository" {
  type     = string
  nullable = false
}

variable "github_token" {
  description = "GitHub PAT (repo, workflow)"
  type        = string
}

variable "repo_owner" {
  type     = string
  nullable = false
}

variable "repo_name" {
  type     = string
  nullable = false
}

variable "secrets_service_account" {
  type      = string
  sensitive = true
  nullable  = false
}

variable "secrets_identity_provider" {
  type      = string
  sensitive = true
  nullable  = false
}

version.tf specifies the versions of the providers.

terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 5"
    }
    github = {
      source  = "integrations/github"
      version = "~> 5.0"
    }
  }
}

With these files, your directory should look as follows:

% tree
.
├── main.tf
├── provider.tf
├── terraform.tfvars
├── variables.tf
└── version.tf

1 directory, 5 files

For Terraform code quality, consider using a linter like tflint.

Incidentally, remember to log in to your Google Cloud account using the gcloud command:

gcloud auth login
gcloud config set project [YOUR_PROJECT_ID]

You run the following commands initialize the Terraform working directory, check for any errors in the configuration, format the codes, and plan the deployment to show what actions Terraform will perform on applying configuration:

terraform init
terraform validate && terraform fmt && terraform plan

Then you will get the following result (some results are omitted):

Plan: 9 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + service_account_email                = (known after apply)
  + workload_identity_pool_name          = (known after apply)
  + workload_identity_pool_provider_name = (known after apply)

─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run "terraform apply" now.

It provides information that Terraform will change or create resources when you actually run terraform apply. If everything seems fine. Let’s go ahead and execute it.

terraform apply

During the terraform apply process, you will be prompted to input the Personal Access Token issued by GitHub. The token is necessary for Terraform to authenticate with GitHub and make changes to secrets in your repository.

With this, most of the preparation is complete. The workflow for GitHub Actions is as follows:

name: workflow-deploy-cloudrun

on:
  push:
    branches:
      - main
  pull_request:
    types: 
      - opened
      - reopened
      - ready_for_review
      - synchronize

env:
  PROJECT_NAME: "[YOUR_GCP_PROJECT_NAME]"
  # ex. asia-northeast1-docker.pkg.dev
  REPOSITORY_DOMAIN: "[YOUR_ARTIFACT_REGISTRY_DOMAIN]" 
  REPOSITORY_NAME: "[YOUR_ARTIFACT_REGISTRY_NAME]"
  IMAGE_NAME: "[YOUR_DOCKER_IMAGE_NAME]"
  TAG: "latest"

jobs:
  build-and-push:
    if: github.ref == 'refs/heads/main'
    name: 'Build and Push Docker Image'
    runs-on: ubuntu-latest

    permissions:
      id-token: write
      contents: read

    steps:
      - uses: actions/checkout@v4
      - id: 'auth'
        name: 'Authenticate to Google Cloud'
        uses: google-github-actions/auth@v1
        with:
          service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
          workload_identity_provider: ${{ secrets.GCP_IDENTITY_PROVIDER }}

      - id: 'gcloud' 
        name: 'Configure Docker for Artifact Registry'
        run: |
          gcloud auth configure-docker ${{ env.REPOSITORY_DOMAIN }}          

      - id: 'docker'
        name: 'Build and Push Docker Image'
        run: |
          docker build --platform linux/amd64 . -t "${{ env.REPOSITORY_DOMAIN }}/${{ env.PROJECT_NAME }}/${{ env.REPOSITORY_NAME }}/${{ env.IMAGE_NAME }}:${{ env.TAG }}"
          docker push "${{ env.REPOSITORY_DOMAIN }}/${{ env.PROJECT_NAME }}/${{ env.REPOSITORY_NAME }}/${{ env.IMAGE_NAME }}:${{ env.TAG }}"          

      - id: 'deploy'
        name: 'Deploy Image to Cloud Run'
        uses: google-github-actions/deploy-cloudrun@v1
        with:
          service: [YOUR_CLOUD_RUN_SERVICE_NAME]
          image: "${{ env.REPOSITORY_DOMAIN }}/${{ env.PROJECT_NAME }}/${{ env.REPOSITORY_NAME }}/${{ env.IMAGE_NAME }}:${{ env.TAG }}"
          region: asia-northeast1

      - id: 'health-check'
        name: 'Verify Cloud Run Deployment with Health Check'
        run: 'curl "${{ steps.deploy.outputs.url }}"'

In this way, this approach could be an exceptionally effective method for you. Try it out.


Announcement regarding recruitment of engineers. We’re looking for talents eager to innovate and grow with us. Be part of our pioneering team privacy tech sector. Apply for an informal chat with us. Click here to connect and explore possibilities together! We’re always open to engaging in conversations.