Setting up GCP's Workload Identity Federation with Terraform to Call APIs from AWS without creating Service Account Keys
awsgcpgolangterraformGCP’s Workload Identity Federation is a feature that allows temporary permissions to be granted to workloads outside of GCP without sharing service account keys.
By the way, from GCP to AWS, AssumeRoleWithWebIdentity can be performed using service account OIDC tokens. This enables instances to call AWS APIs with tokens from the metadata server, and some managed services like Cloud Storage Transfer Service to access AWS just by passing the Role arn.
/*
new iam.Role(this, 'GoogleWebIdentityRole', {
assumedBy: new iam.WebIdentityPrincipal("accounts.google.com", {
"StringEquals": {
"accounts.google.com:oaud": "sts.amazonaws.com",
"accounts.google.com:sub": "(service_account_id)"
}
}),
roleName: "*****",
})
*/
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/sts"
"cloud.google.com/go/compute/metadata"
)
token, err := metadata.Get("instance/service-accounts/default/identity?audience=sts.amazonaws.com&format=full")
if err != nil {
return fmt.Errorf("failed to get identity token: %v", err)
}
cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion("ap-northeast-1"))
if err != nil {
return fmt.Errorf("Failed to load AWS config: %v", err)
}
stsClient := sts.NewFromConfig(cfg)
assumeRoleInput := &sts.AssumeRoleWithWebIdentityInput{
RoleArn: aws.String(roleArn),
RoleSessionName: aws.String("gcp-assume-role-session"),
WebIdentityToken: aws.String(token),
DurationSeconds: aws.Int32(3600),
}
assumeRoleOutput, err := stsClient.AssumeRoleWithWebIdentity(ctx, assumeRoleInput)
if err != nil {
return fmt.Errorf("Failed to assume role: %v", err)
}
tempCfg := cfg.Copy()
tempCfg.Credentials = credentials.NewStaticCredentialsProvider(
*assumeRoleOutput.Credentials.AccessKeyId,
*assumeRoleOutput.Credentials.SecretAccessKey,
*assumeRoleOutput.Credentials.SessionToken,
)
tempStsClient := sts.NewFromConfig(tempCfg)
identity, err := tempStsClient.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{})
if err != nil {
return fmt.Errorf("Failed to get caller identity: %v", err)
}
fmt.Println("success!")
Workload Identity Federation also supports OIDC and SAML, and can perform authentication using signed GetCallerIdentity requests.
Create an AWS Role to grant permissions.
new iam.Role(this, 'WorkloadIdentityRole', {
assumedBy: new iam.CompositePrincipal(
new iam.AccountPrincipal(this.account),
})),
roleName: "*****",
})
/*
eval $(aws sts assume-role \
--role-arn "arn:aws:iam::*****:role/*****" \
--role-session-name "GoogleWebIdentityTest" \
--duration-seconds 3600 \
--query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' \
--output text | \
awk '{printf "export AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s\n", $1, $2, $3}')
*/
Create a google_iam_workload_identity_pool, connect it to the google_iam_workload_identity_pool_provider for AWS, and use google_project_iam_member to grant service account permissions when attribute.aws_role matches the arn of the role above.
provider "google" {
project = var.project_id
region = "asia-northeast1"
}
provider "google-beta" {
project = var.project_id
region = "asia-northeast1"
}
variable "project_id" {
type = string
}
data "google_project" "project" {
project_id = var.project_id
}
variable "aws_account_id" {
type = string
}
variable "aws_role_name" {
type = string
}
resource "google_iam_workload_identity_pool" "aws_pool" {
workload_identity_pool_id = "aws-pool"
display_name = "AWS Workload Identity Pool"
}
resource "google_iam_workload_identity_pool_provider" "aws_provider" {
workload_identity_pool_id = google_iam_workload_identity_pool.aws_pool.workload_identity_pool_id
workload_identity_pool_provider_id = "aws-provider"
display_name = "AWS Provider"
aws {
account_id = var.aws_account_id
}
attribute_mapping = {
"google.subject" = "assertion.arn"
"attribute.aws_role" = "assertion.arn.contains('assumed-role') ? 'arn:aws:iam::${var.aws_account_id}:role/' + assertion.arn.extract('assumed-role/{role_name}/') : assertion.arn"
}
}
resource "google_service_account" "gcs_service_account" {
account_id = "aws-gcs-service-account"
display_name = "AWS GCS Service Account"
}
resource "google_project_iam_member" "gcs_storage_admin" {
project = var.project_id
role = "roles/storage.admin"
member = "serviceAccount:${google_service_account.gcs_service_account.email}"
}
resource "google_service_account_iam_member" "workload_identity_user" {
service_account_id = google_service_account.gcs_service_account.name
role = "roles/iam.workloadIdentityUser"
member = "principalSet://iam.googleapis.com/projects/${data.google_project.project.number}/locations/global/workloadIdentityPools/${google_iam_workload_identity_pool.aws_pool.workload_identity_pool_id}/attribute.aws_role/arn:aws:iam::${var.aws_account_id}:role/${var.aws_role_name}"
}
Create a configuration file that includes the Provider name in the x-goog-cloud-target-resource header and sends a signed GetCallerIdentity request as a subjectToken to Google’s token API, then set its path in the GOOGLE_APPLICATION_CREDENTIALS environment variable.
$ gcloud iam workload-identity-pools create-cred-config \
projects/*****/locations/global/workloadIdentityPools/aws-pool/providers/aws-provider \
--service-account=aws-gcs-service-account@*****.iam.gserviceaccount.com \
--aws \
--enable-imdsv2 \
--output-file=aws-cred.json
$ cat aws-cred.json
{
"universe_domain": "googleapis.com",
"type": "external_account",
"audience": "//iam.googleapis.com/projects/*****/locations/global/workloadIdentityPools/aws-pool/providers/aws-provider",
"subject_token_type": "urn:ietf:params:aws:token-type:aws4_request",
"token_url": "https://sts.googleapis.com/v1/token",
"credential_source": {
"environment_id": "aws1",
"region_url": "http://169.254.169.254/latest/meta-data/placement/availability-zone",
"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials",
"regional_cred_verification_url": "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15",
"imdsv2_session_token_url": "http://169.254.169.254/latest/api/token"
},
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/aws-gcs-service-account@*****.iam.gserviceaccount.com:generateAccessToken"
}
$ export GOOGLE_APPLICATION_CREDENTIALS="./aws-cred.json"
After that, you can make API calls with the service account permissions by passing DefaultClient of golang.org/x/oauth2/google that authenticates by referencing this file to libraries. Although the configuration url is instance metadata’s one, it can be executed locally because the environment variables AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are referenced first.
package main
import (
"context"
"fmt"
"log"
"os"
"golang.org/x/oauth2/google"
"google.golang.org/api/option"
"google.golang.org/api/storage/v1"
)
func main() {
ctx := context.Background()
projectID := os.Getenv("GCP_PROJECT_ID")
client, err := google.DefaultClient(ctx, storage.DevstorageReadOnlyScope)
if err != nil {
log.Fatalf("Failed to create DefaultClient: %v", err)
}
storageService, err := storage.NewService(ctx, option.WithHTTPClient(client))
if err != nil {
log.Fatalf("Failed to create storage service: %v", err)
}
buckets, err := storageService.Buckets.List(projectID).Do()
if err != nil {
log.Fatalf("Failed to list buckets: %v", err)
}
fmt.Printf("Buckets in project %s:\n", projectID)
for _, b := range buckets.Items {
fmt.Println(b.Name)
}
}