aws to gcp workload identity federation

config is king?

SEAN K.H. LIAO

aws to gcp workload identity federation

config is king?

AWS workload, GCP resources

More workload identity, this time in the oppsoite direction. Specifically, running the workload in Amazon Web Services (AWS) Elastic Kubernetes Service (EKS), accessing Google Cloud Platform (GCP) services.

There have been many blog posts on this.

GCP cloud setup

On GCP, we need a workload identity pool associated with the AWS account. Additionally, a service account with permissions allowing an AWS Role to impersonate it. Permissions to other GCP resources should be granted to the service account.

 1terraform {
 2  required_providers {
 3    google = {
 4      source  = "hashicorp/google"
 5      version = "4.43.0"
 6    }
 7  }
 8}
 9
10locals {
11  project        = "project-id"
12  project_number = "000000000"
13  aws_account_id = "000000000"
14}
15
16resource "google_iam_workload_identity_pool" "p" {
17  project                   = local.project
18  workload_identity_pool_id = "workload-pool-id"
19}
20
21#
22# provider within the pool
23# maps to individual AWS accounts
24#
25resource "google_iam_workload_identity_pool_provider" "p" {
26  project                            = local.project
27  workload_identity_pool_id          = google_iam_workload_identity_pool.p.workload_identity_pool_id
28  workload_identity_pool_provider_id = "workload-provider-id"
29  attribute_mapping = {
30    "google.subject"        = "assertion.arn"
31    "attribute.aws_account" = "assertion.account"
32    # an attribute with the full arn of the assumed role
33    # source attribute is arn:aws:sts::account:assumed-role/role-to-be-assumed/id
34    "attribute.aws_role" = "assertion.arn.contains('assumed-role') ? assertion.arn.extract('{account_arn}assumed-role/') + 'assumed-role/' + assertion.arn.extract('assumed-role/{role_name}/') : assertion.arn"
35  }
36  aws {
37    account_id = local.aws_account_id
38  }
39}
40
41resource "google_service_account" "s" {
42  project    = local.project
43  account_id = "gcp-service-account-name"
44}
45
46resource "google_service_account_iam_member" "s" {
47  service_account_id = google_service_account.a.id
48  role               = "roles/iam.workloadIdentityUser"
49  # principalSet matches anything with the given attribute
50  # here we're matching on assumed role arn
51  member             = "principalSet://iam.googleapis.com/projects/${local.project_number}/locations/global/workloadIdentityPools/workload-pool-id/attribute.aws_role/arn:aws:sts::account:assumed-role/role-for-irsa"
52}

AWS cloud setup

We'll need an AWS Role and with a trust policy to allow the Kubernetes service account to assume the role. The OIDC provider is also necessary but out of scope.

 1terraform {
 2  required_providers {
 3    aws = {
 4      source = "hashicorp/aws"
 5      version = "4.39.0"
 6    }
 7  }
 8}
 9
10locals {
11  oidc_provider = "..."
12  oidc_provider_arn = "arn:aws:iam::000000000:oidc-provider/${local.oidc_provider}"
13}
14
15resource "aws_iam_role" "r" {
16  name = "role-for-irsa"
17
18  assume_role_policy = jsonencode({
19    Version = "2012-10-17"
20    Statement = [{
21      Action = "sts:AssumeRoleWithWebIdentity"
22      Effect = "Allow"
23      Sid    = "r1"
24      Principal = {
25        # built in federated identity provider
26        Federated = local.oidc_provider_arn
27      }
28      Condition = {
29        StringEquals = {
30          "${local.oidc_provider}:sub" = "system:serviceaccount:k8s-namespace:serviceaccount-name"
31        }
32      }
33    }]
34  })
35}

AWS workload setup

We'll need a kubernetes service account, annotated with the AWS Role it can use as an identity.

1apiVersion: v1
2kind: ServiceAccount
3metadata:
4  name: serviceaccoount-name
5  annotations:
6    eks.amazonaws.com/role-arn: arn:aws:iam::account:role/role-for-irsa

The GCP SDK can use a config file to handle the token exchange. It needs an audience referencing the workload identity pool provider, and the intended service account to impersonate.

 1apiVersion: v1
 2kind: ConfigMap
 3metadata:
 4  name: configmap-name
 5data:
 6  creds.json: |
 7    {
 8      "type": "external_account",
 9      "audience": "//iam.googleapis.com/projects/project-number/locations/global/workloadIdentityPools/workload-pool-id/providers/workload-provider-id",
10      "subject_token_type": "urn:ietf:params:aws:token-type:aws4_request",
11      "token_url": "https://sts.googleapis.com/v1/token",
12      "credential_source": {
13        "environment_id": "aws1",
14        "region_url": "http://169.254.169.254/latest/meta-data/placement/availability-zone",
15        "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials",
16        "regional_cred_verification_url": "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15",
17        "imdsv2_session_token_url": "http://169.254.169.254/latest/api/token"
18      },
19      "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/gcp-service-account-name@project-id.iam.gserviceaccount.com:generateAccessToken"
20    }    

The pod needs to use the service account with the role annotation. Additionally, it needs to mount the above config file, and point GOOGLE_APPLICATION_CREDENTIALS to it.

 1apiVersion: v1
 2kind: Pod
 3metadata:
 4  name: pod-name
 5spec:
 6  serviceAccountName: serviceaccount-name
 7  containers:
 8    - name: container-name
 9      image: image-ref
10      env:
11        - name: GOOGLE_APPLICATION_CREDENTIALS
12          value: /var/run/secrets/google/creds.json
13      volumeMounts:
14        - name: google
15          mountPath: /var/run/secrets/google
16  volumes:
17    - name: google
18      configMap:
19        name: configmap-name

AWS code setup - normal

The GCP Go SDK has built in support to do the necessary token exchanges with AWS STS. All it needs is for GOOGLE_APPLICATION_CREDENTIALS to point to a config file (see above).

 1package main
 2
 3import (
 4        "context"
 5        "fmt"
 6        "os"
 7
 8        "cloud.google.com/go/storage"
 9        "golang.org/x/exp/slog"
10)
11
12func main() {
13        lgh := slog.HandlerOptions{
14                Level: slog.DebugLevel,
15        }.NewJSONHandler(os.Stderr)
16        lg := slog.New(lgh)
17
18        err := run(lg)
19        if err != nil {
20                lg.Error("run", err)
21                os.Exit(1)
22        }
23}
24
25func run(lg *slog.Logger) error {
26        ctx := context.Background()
27        // Example: GCP storage client
28        client, err := storage.NewClient(ctx)
29        if err != nil {
30                return fmt.Errorf("create GCP Cloud Storage client: %w", err)
31        }
32
33        _ = client
34        return nil
35}

AWS code setup - manual token exchange

If for whatever reason, IMDSv2 is broken on your cluster (maybe someone forgot to set ec2:MetadataHttpPutResponseHopLimit to 2), you can do a manual token exchange to generate the access tokens for sdk use.

Note: you'll want to remove the GOOGLE_APPLICATION_CREDENTIALS env and the file it points to.

  1package main
  2
  3import (
  4        "context"
  5        "encoding/json"
  6        "fmt"
  7        "net/http"
  8        "net/url"
  9        "os"
 10        "sort"
 11        "strconv"
 12        "strings"
 13        "time"
 14
 15        credentials "cloud.google.com/go/iam/credentials/apiv1"
 16        "cloud.google.com/go/iam/credentials/apiv1/credentialspb"
 17        "cloud.google.com/go/storage"
 18        "github.com/aws/aws-sdk-go-v2/aws"
 19        signer "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
 20        "github.com/aws/aws-sdk-go-v2/service/sts"
 21        "golang.org/x/exp/slog"
 22        "golang.org/x/oauth2"
 23        "google.golang.org/api/option"
 24        gcpsts "google.golang.org/api/sts/v1"
 25)
 26
 27func main() {
 28        lgh := slog.HandlerOptions{
 29                Level: slog.DebugLevel,
 30        }.NewJSONHandler(os.Stderr)
 31        lg := slog.New(lgh)
 32
 33        err := run(lg)
 34        if err != nil {
 35                lg.Error("run", err)
 36                os.Exit(1)
 37        }
 38}
 39
 40func run(lg *slog.Logger) error {
 41        ctx := context.Background()
 42
 43        stsTS, err := NewAWSToGCPTokenSource(ctx)
 44        if err != nil {
 45                return fmt.Errorf("create gcp sts token source: %w", err)
 46        }
 47        ts, err := NewGCPServiceAccountTokenSource(ctx, stsTS)
 48        if err != nil {
 49                return fmt.Errorf("create gcp service account token source: %w", err)
 50        }
 51
 52        // Example: GCP storage client
 53        client, _ := storage.NewClient(ctx,
 54                // use our access tokens
 55                option.WithTokenSource(ts),
 56        )
 57        _ = client
 58        return nil
 59}
 60
 61type AWSToGCPTokenSource struct {
 62        s *gcpsts.Service
 63}
 64
 65func NewAWSToGCPTokenSource(ctx context.Context) (*AWSToGCPTokenSource, error) {
 66        client, err := gcpsts.NewService(ctx, option.WithoutAuthentication())
 67        if err != nil {
 68                return nil, fmt.Errorf("create gcp sts service client: %w", err)
 69        }
 70
 71        return &AWSToGCPTokenSource{
 72                s: client,
 73        }, nil
 74}
 75
 76// Token implements oauth2.TokenSource by taking a local filesystem mounted EKS IRSA token,
 77// assuming the associated AWS Role, and exchanging that for a GCP STS token.
 78func (g *AWSToGCPTokenSource) Token() (*oauth2.Token, error) {
 79        // potential input values
 80        region := os.Getenv("AWS_REGION")    // injected by IRSA
 81        roleARN := os.Getenv("AWS_ROLE_ARN") // injected by IRSA
 82        gcpProjectNumber := "000000000"
 83        gcpWorkloadPool := "workload-pool-id"
 84        gcpProvider := "workload-provider-id"
 85        gcpTargetResource := "//iam.googleapis.com/projects/" + gcpProjectNumber + "/locations/global/workloadIdentityPools/" + gcpWorkloadPool + "/providers/" + gcpProvider
 86
 87        // get the projected OIDC token within EKS
 88        // this has an audience of STS and is updated over time
 89        b, err := os.ReadFile("/var/run/secrets/eks.amazonaws.com/serviceaccount/token")
 90        if err != nil {
 91                return nil, fmt.Errorf("read mounted eks token: %w", err)
 92        }
 93        webToken := string(b)
 94
 95        // assume the role mapped by IRSA using the OIDC token
 96        ctx := context.Background()
 97        tsNow := strconv.FormatInt(time.Now().Unix(), 10)
 98        assumed, err := sts.New(sts.Options{
 99                Region: region,
100        }).AssumeRoleWithWebIdentity(ctx, &sts.AssumeRoleWithWebIdentityInput{
101                RoleArn:          &roleARN,
102                RoleSessionName:  &tsNow,
103                WebIdentityToken: &webToken,
104        })
105        if err != nil {
106                return nil, fmt.Errorf("assume role: %w", err)
107        }
108
109        // prepare and sign an HTTP request equivalent to a call to AWS sts.GetCallerIdentity.
110        // hand the resulting URL / Method / Headers to GCP STS, which will use it to confirm our identity,
111        // and give us a token.
112        gciReq, err := http.NewRequest(
113                http.MethodPost,
114                "https://sts."+region+".amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15",
115                nil,
116        )
117        if err != nil {
118                return nil, fmt.Errorf("create fake request: %w", err)
119        }
120        // equivalent to setting an audience in oauth2
121        gciReq.Header.Set(
122                "x-goog-cloud-target-resource",
123                gcpTargetResource,
124        )
125        err = signer.NewSigner().SignHTTP(ctx,
126                aws.Credentials{
127                        AccessKeyID:     *assumed.Credentials.AccessKeyId,
128                        SecretAccessKey: *assumed.Credentials.SecretAccessKey,
129                        SessionToken:    *assumed.Credentials.SessionToken,
130                        CanExpire:       true,
131                        Expires:         *assumed.Credentials.Expiration,
132                },
133                gciReq,
134                // empty string representing the body
135                "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
136                "sts",
137                os.Getenv("AWS_DEFAULT_REGION"),
138                time.Now(),
139        )
140        if err != nil {
141                return nil, fmt.Errorf("sign fake GetCallerIdentity request: %w", err)
142        }
143
144        var headers []map[string]string
145        // host header is implicit in Go's net/http.Request
146        headers = append(headers, map[string]string{
147                "key":   "host",
148                "value": gciReq.Host,
149        })
150        for k, vs := range gciReq.Header {
151                headers = append(headers, map[string]string{
152                        "key":   strings.ToLower(k),
153                        "value": vs[0],
154                })
155        }
156        // header fields should be sorted?
157        sort.Slice(headers, func(i, j int) bool {
158                return headers[i]["key"] < headers[j]["key"]
159        })
160
161        // GCP STS expects a url encoded json object containing the request data
162        b, err = json.Marshal(map[string]any{
163                "url":     gciReq.URL.String(),
164                "method":  gciReq.Method,
165                "headers": headers,
166        })
167        if err != nil {
168                return nil, fmt.Errorf("json encode sts req: %w", err)
169        }
170        escaped := url.QueryEscape(string(b))
171
172        // exchange for a GCP STS token associated with our pool
173        res, err := g.s.V1.Token(&gcpsts.GoogleIdentityStsV1ExchangeTokenRequest{
174                Audience:           gcpTargetResource,
175                GrantType:          "urn:ietf:params:oauth:grant-type:token-exchange",
176                RequestedTokenType: "urn:ietf:params:oauth:token-type:access_token",
177                Scope:              "https://www.googleapis.com/auth/cloud-platform",
178                SubjectToken:       escaped,
179                SubjectTokenType:   "urn:ietf:params:aws:token-type:aws4_request",
180        }).Do()
181        if err != nil {
182                return nil, fmt.Errorf("gcp sts exchange: %w", err)
183        }
184
185        exp := time.Now().Add(time.Duration(res.ExpiresIn) * time.Second)
186        return &oauth2.Token{
187                AccessToken: res.AccessToken,
188                Expiry:      exp,
189        }, nil
190}
191
192type GCPServiceAccountTokenSource struct {
193        g *credentials.IamCredentialsClient
194}
195
196func NewGCPServiceAccountTokenSource(ctx context.Context, ts oauth2.TokenSource) (*GCPServiceAccountTokenSource, error) {
197        client, err := credentials.NewIamCredentialsClient(ctx, option.WithTokenSource(ts))
198        if err != nil {
199                return nil, fmt.Errorf("create gcp credentials client: %w", err)
200        }
201
202        return &GCPServiceAccountTokenSource{
203                g: client,
204        }, nil
205}
206
207// Token implements oauth2.TokenSource by exchanging a GCP STS token
208// for an GCP access token from the impersonated service account
209func (g *GCPServiceAccountTokenSource) Token() (*oauth2.Token, error) {
210        // potential input values
211        gcpEmail := "gcp-service-account-name@project-id.iam.gserviceaccount.com"
212
213        ctx := context.Background()
214        // impersonate the service accoutn and generate an access token
215        res, err := g.g.GenerateAccessToken(ctx, &credentialspb.GenerateAccessTokenRequest{
216                Name:  "projects/-/serviceAccounts/" + gcpEmail,
217                Scope: []string{"https://www.googleapis.com/auth/cloud-platform"},
218        })
219        if err != nil {
220                return nil, fmt.Errorf("generate GCP access token: %w", err)
221        }
222
223        return &oauth2.Token{
224                AccessToken: res.AccessToken,
225                Expiry:      res.ExpireTime.AsTime(),
226        }, nil
227}