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.
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.
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.
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.