GCP の Workload Identity Federation を Terraform で設定してサービスアカウントのキーを作成せずに AWS から API を呼ぶ

awsgcpgolangterraform

GCP の Workload Identity Federation は GCP 外のワークロードにサービスアカウントのキーを共有することなく一時的な権限を与えられる機能。

なお、GCP から AWS は サービスアカウントの OIDC トークンによる AssumeRoleWithWebIdentity行える。これによりインスタンスからメタデータサーバーにリクエストしてトークンを取得し AWS の API を呼んだりCloud Storage Transfer Service など一部のマネージドサービスは Role の Arn を渡すだけで AWS にアクセスできるようになっている。

AWSのAssumeRole - sambaiz-net

/*
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 も OIDC や SAML に対応しているほか、署名付きの GetCallerIdentity リクエストによる認証を行うこともできる。

権限を与える AWS の Role を作成する。

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}')
*/

google_iam_workload_identity_pool を作成し AWS 用の google_iam_workload_identity_pool_provider を紐付け、google_project_iam_member で attribute.aws_role が上の Role の Arn の場合にサービスアカウントの権限を与えるようにする。

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}"
}

Provider名を x-goog-cloud-target-resource ヘッダーに入れて署名した GetCallerIdentity リクエストを subjectToken として Google の token API送る設定ファイルを出力し、パスを環境変数 GOOGLE_APPLICATION_CREDENTIALS に入れる。

$ 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"

あとはこのファイルを参照する golang.org/x/oauth2/google の DefaultClient をライブラリに渡すと、サービスアカウントの権限で API を呼ぶことができる。構成ファイルの url がインスタンスメタデータのものになっているが、先に環境変数 AWS_ACCESS_KEY_ID や AWS_SECRET_ACCESS_KEY が参照されるためローカルからも実行できる。

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)
	}
}