Parcourir la source

Merge pull request #538 from external-secrets/feat/gcp-workload-identity

GCP workload identity via Service Account Tokens
paul-the-alien[bot] il y a 4 ans
Parent
commit
ac4d5525c8

+ 2 - 2
.github/workflows/ci.yml

@@ -10,7 +10,7 @@ on:
 
 env:
   # Common versions
-  GO_VERSION: '1.16'
+  GO_VERSION: '1.17'
   GOLANGCI_VERSION: 'v1.42.1'
   # list of available versions: https://storage.googleapis.com/kubebuilder-tools
   # TODO: 1.21.2 does not shut down properly with controller-runtime 0.9.2
@@ -21,7 +21,7 @@ env:
   # a step 'if env.GHCR_USERNAME' != ""', so we copy these to succinctly test whether
   # credentials have been provided before trying to run steps that need them.
   GHCR_USERNAME: ${{ secrets.GHCR_USERNAME }}
-  
+
   # Sonar
   SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
 

+ 1 - 1
.github/workflows/e2e.yml

@@ -6,7 +6,7 @@ on:
 
 env:
   # Common versions
-  GO_VERSION: '1.16'
+  GO_VERSION: '1.17'
   GOLANGCI_VERSION: 'v1.33'
   DOCKER_BUILDX_VERSION: 'v0.4.2'
 

+ 0 - 1
.golangci.yaml

@@ -63,7 +63,6 @@ linters:
     - gosimple
     - govet
     - ineffassign
-    - interfacer
     - lll
     - misspell
     - nakedret

+ 10 - 1
apis/externalsecrets/v1alpha1/secretstore_gcpsm_types.go

@@ -19,7 +19,10 @@ import (
 )
 
 type GCPSMAuth struct {
-	SecretRef GCPSMAuthSecretRef `json:"secretRef"`
+	// +optional
+	SecretRef *GCPSMAuthSecretRef `json:"secretRef,omitempty"`
+	// +optional
+	WorkloadIdentity *GCPWorkloadIdentity `json:"workloadIdentity,omitempty"`
 }
 
 type GCPSMAuthSecretRef struct {
@@ -28,6 +31,12 @@ type GCPSMAuthSecretRef struct {
 	SecretAccessKey esmeta.SecretKeySelector `json:"secretAccessKeySecretRef,omitempty"`
 }
 
+type GCPWorkloadIdentity struct {
+	ServiceAccountRef esmeta.ServiceAccountSelector `json:"serviceAccountRef"`
+	ClusterLocation   string                        `json:"clusterLocation"`
+	ClusterName       string                        `json:"clusterName"`
+}
+
 // GCPSMProvider Configures a store to sync secrets using the GCP Secret Manager provider.
 type GCPSMProvider struct {
 	// Auth defines the information necessary to authenticate against GCP

+ 27 - 1
apis/externalsecrets/v1alpha1/zz_generated.deepcopy.go

@@ -1,3 +1,4 @@
+//go:build !ignore_autogenerated
 // +build !ignore_autogenerated
 
 /*
@@ -601,7 +602,16 @@ func (in *ExternalSecretTemplateMetadata) DeepCopy() *ExternalSecretTemplateMeta
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *GCPSMAuth) DeepCopyInto(out *GCPSMAuth) {
 	*out = *in
-	in.SecretRef.DeepCopyInto(&out.SecretRef)
+	if in.SecretRef != nil {
+		in, out := &in.SecretRef, &out.SecretRef
+		*out = new(GCPSMAuthSecretRef)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.WorkloadIdentity != nil {
+		in, out := &in.WorkloadIdentity, &out.WorkloadIdentity
+		*out = new(GCPWorkloadIdentity)
+		(*in).DeepCopyInto(*out)
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GCPSMAuth.
@@ -647,6 +657,22 @@ func (in *GCPSMProvider) DeepCopy() *GCPSMProvider {
 }
 
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *GCPWorkloadIdentity) DeepCopyInto(out *GCPWorkloadIdentity) {
+	*out = *in
+	in.ServiceAccountRef.DeepCopyInto(&out.ServiceAccountRef)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GCPWorkloadIdentity.
+func (in *GCPWorkloadIdentity) DeepCopy() *GCPWorkloadIdentity {
+	if in == nil {
+		return nil
+	}
+	out := new(GCPWorkloadIdentity)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *GitlabAuth) DeepCopyInto(out *GitlabAuth) {
 	*out = *in
 	in.SecretRef.DeepCopyInto(&out.SecretRef)

+ 1 - 0
apis/meta/v1/zz_generated.deepcopy.go

@@ -1,3 +1,4 @@
+//go:build !ignore_autogenerated
 // +build !ignore_autogenerated
 
 /*

+ 27 - 2
deploy/crds/external-secrets.io_clustersecretstores.yaml

@@ -411,8 +411,33 @@ spec:
                                     type: string
                                 type: object
                             type: object
-                        required:
-                        - secretRef
+                          workloadIdentity:
+                            properties:
+                              clusterLocation:
+                                type: string
+                              clusterName:
+                                type: string
+                              serviceAccountRef:
+                                description: A reference to a ServiceAccount resource.
+                                properties:
+                                  name:
+                                    description: The name of the ServiceAccount resource
+                                      being referred to.
+                                    type: string
+                                  namespace:
+                                    description: Namespace of the resource being referred
+                                      to. Ignored if referent is not cluster-scoped.
+                                      cluster-scoped defaults to the namespace of
+                                      the referent.
+                                    type: string
+                                required:
+                                - name
+                                type: object
+                            required:
+                            - clusterLocation
+                            - clusterName
+                            - serviceAccountRef
+                            type: object
                         type: object
                       projectID:
                         description: ProjectID project where secret is located

+ 27 - 2
deploy/crds/external-secrets.io_secretstores.yaml

@@ -411,8 +411,33 @@ spec:
                                     type: string
                                 type: object
                             type: object
-                        required:
-                        - secretRef
+                          workloadIdentity:
+                            properties:
+                              clusterLocation:
+                                type: string
+                              clusterName:
+                                type: string
+                              serviceAccountRef:
+                                description: A reference to a ServiceAccount resource.
+                                properties:
+                                  name:
+                                    description: The name of the ServiceAccount resource
+                                      being referred to.
+                                    type: string
+                                  namespace:
+                                    description: Namespace of the resource being referred
+                                      to. Ignored if referent is not cluster-scoped.
+                                      cluster-scoped defaults to the namespace of
+                                      the referent.
+                                    type: string
+                                required:
+                                - name
+                                type: object
+                            required:
+                            - clusterLocation
+                            - clusterName
+                            - serviceAccountRef
+                            type: object
                         type: object
                       projectID:
                         description: ProjectID project where secret is located

+ 80 - 43
docs/provider-google-secrets-manager.md

@@ -2,39 +2,67 @@
 
 External Secrets Operator integrates with [GCP Secret Manager](https://cloud.google.com/secret-manager) for secret management.
 
-### Service account key authentication
+## Authentication
 
-A service account key is created and the JSON keyfile is stored in a `Kind=Secret`. The `project_id` and `private_key` should be configured for the project.
+### Workload Identity
 
-```yaml
-{% include 'gcpsm-credentials-secret.yaml' %}
-```
+Your Google Kubernetes Engine (GKE) applications can consume GCP services like Secrets Manager without using static, long-lived authentication tokens. This is our recommended approach of handling credentials in GCP. ESO offers two options for integrating with GKE workload identity: **pod-based workload identity** and **using service accounts directly**. Before using either way you need to create a service account - this is covered below.
 
-### Update secret store
-Be sure the `gcpsm` provider is listed in the `Kind=SecretStore`
+#### Creating Workload Identity Service Accounts
 
-```yaml
-{% include 'gcpsm-secret-store.yaml' %}
-```
+You can find the documentation for Workload Identity [here](https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity). We will walk you through how to navigate it here.
 
-### Creating external secret
+Search [the documment](https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity) for this editable values and change them to your values:
 
-To create a kubernetes secret from the GCP Secret Manager secret a `Kind=ExternalSecret` is needed.
+- `CLUSTER_NAME`: The name of your cluster
+- `PROJECT_ID`: Your project ID (not your Project number nor your Project name)
+- `K8S_NAMESPACE`: For us folowing these steps here it will be `es`, but this will be the namespace where you deployed the external-secrets operator
+- `KSA_NAME`: external-secrets (if you are not creating a new one to attach to the deployemnt)
+- `GSA_NAME`: external-secrets for simplicity, or something else if you have to follow different naming convetions for cloud resources
+- `ROLE_NAME`: should be `roles/secretmanager.secretAccessor` - so you make the pod only be able to access secrets on Secret Manager
+
+#### Using Service Accounts directly
+
+Let's assume you have created a service account correctly and attached a appropriate workload identity. It should roughly look like this:
 
 ```yaml
-{% include 'gcpsm-external-secret.yaml' %}
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  name: team-a
+  namespace: team-a
+  annotations:
+    iam.gke.io/gcp-service-account: example-team-a@my-project.iam.gserviceaccount.com
 ```
 
-The operator will fetch the GCP Secret Manager secret and inject it as a `Kind=Secret`
-```
-kubectl get secret secret-to-be-created -n <namespace> | -o jsonpath='{.data.dev-secret-test}' | base64 -d
+You can reference this particular ServiceAccount in a `SecretStore` or `ClusterSecretStore`. It's important that you also set the `projectID`, `clusterLocation` and `clusterName`. The Namespace on the `serviceAccountRef` is ignored when using a `SecretStore` resource. This is needed to isolate the namespaces properly.
+
+```yaml
+apiVersion: external-secrets.io/v1alpha1
+kind: ClusterSecretStore
+metadata:
+  name: gcp-wi
+spec:
+  provider:
+    gcpsm:
+      projectID: my-project
+      auth:
+        workloadIdentity:
+          # name of the cluster region
+          clusterLocation: europe-central2
+          # name of the GKE cluster
+          clusterName: example-workload-identity
+          # reference the sa from above
+          serviceAccountRef:
+            name: team-a
+            namespace: team-a
 ```
 
-## Authentication with Workload Identity
+#### Using Pod-based Workload Identity
 
-This makes it possible for your Google Kubernetes Engine (GKE) applications to consume services provided by Google APIs, namely Secrets Manager service in this case.
+You can attach a Workload Identity directly to the ESO pod. ESO then has access to all the APIs defined in the attached service account policy. You attach the workload identity by (1) creating a service account with a attached workload identity (described above) and (2) using this particular service account in the pod's `serviceAccountName` field.
 
-Here we will assume that you installed ESO using helm and that you named the chart installation `external-secrets` and the namespace where it lives `es` like:
+For this example we will assume that you installed ESO using helm and that you named the chart installation `external-secrets` and the namespace where it lives `es` like:
 
 ```sh
 helm install external-secrets external-secrets/external-secrets --namespace es
@@ -42,7 +70,7 @@ helm install external-secrets external-secrets/external-secrets --namespace es
 
 Then most of the resources would have this name, the important one here being the k8s service account attached to the external-secrets operator deployment:
 
-```
+```yaml
 # ...
       containers:
       - image: ghcr.io/external-secrets/external-secrets:vVERSION
@@ -56,36 +84,45 @@ Then most of the resources would have this name, the important one here being th
       serviceAccountName: external-secrets # <--- here
 ```
 
-### Following the documentation
+The pod now has the identity. Now you need to configure the `SecretStore`.
+You just need to set the `projectID`, all other fields can be omitted.
+
+```yaml
+apiVersion: external-secrets.io/v1alpha1
+kind: SecretStore
+metadata:
+  name: example
+spec:
+  provider:
+    gcpsm:
+      projectID: pid
+```
 
-You can find the documentation for Workload Identity under [this url](https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity). We will walk you through how to navigate it here.
+### GCP Service Account authentication
 
-#### Changing Values
+You can use [GCP Service Account](https://cloud.google.com/iam/docs/service-accounts) to authenticate with GCP. These are static, long-lived credentials. A GCP Service Account is a JSON file that needs to be stored in a `Kind=Secret`. ESO will use that Secret to authenticate with GCP. See here how you [manage GCP Service Accounts](https://cloud.google.com/iam/docs/creating-managing-service-accounts).
 
-Search [the documment](https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity) for this editable values and change them to your values:
+```yaml
+{% include 'gcpsm-credentials-secret.yaml' %}
+```
 
-- CLUSTER_NAME: The name of your cluster
-- PROJECT_ID: Your project ID (not your Project number nor your Project name)
-- K8S_NAMESPACE: For us folowing these steps here it will be `es`, but this will be the namespace where you deployed the external-secrets operator
-- KSA_NAME: external-secrets (if you are not creating a new one to attach to the deployemnt)
-- GSA_NAME: external-secrets for simplicity, or something else if you have to follow different naming convetions for cloud resources
-- ROLE_NAME: roles/secretmanager.secretAccessor so you make the pod only be able to access secrets on Secret Manager
+#### Update secret store
+Be sure the `gcpsm` provider is listed in the `Kind=SecretStore`
 
-#### Following through
+```yaml
+{% include 'gcpsm-secret-store.yaml' %}
+```
 
-You can follow through the documentation and adapt it to your specific use case. If you want to just use the serviceaccount that we deployed with the helm chart, for example, you don't need to create a new service account on 2 of [Authenticating to Google Cloud](https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity#authenticating_to).
+#### Creating external secret
 
-#### SecretStore with WorkloadIdentity
+To create a kubernetes secret from the GCP Secret Manager secret a `Kind=ExternalSecret` is needed.
 
-To use workload identity you can just omit the auth field of the secret store and let the operator client fall back to defaults using the roles attached to your service account.
+```yaml
+{% include 'gcpsm-external-secret.yaml' %}
+```
 
+The operator will fetch the GCP Secret Manager secret and inject it as a `Kind=Secret`
 ```
-apiVersion: external-secrets.io/v1alpha1
-kind: SecretStore
-metadata:
-  name: example
-spec:
-  provider:
-    gcpsm:
-      projectID: pid
-```
+kubectl get secret secret-to-be-created -n <namespace> | -o jsonpath='{.data.dev-secret-test}' | base64 -d
+```
+

+ 1 - 1
e2e/Dockerfile

@@ -1,4 +1,4 @@
-ARG GO_VERSION=1.16
+ARG GO_VERSION=1.17
 FROM golang:$GO_VERSION-buster as builder
 
 ENV KUBECTL_VERSION="v1.21.2"

+ 1 - 1
e2e/suite/gcp/provider.go

@@ -123,7 +123,7 @@ func (s *gcpProvider) BeforeEach() {
 				GCPSM: &esv1alpha1.GCPSMProvider{
 					ProjectID: s.projectID,
 					Auth: esv1alpha1.GCPSMAuth{
-						SecretRef: esv1alpha1.GCPSMAuthSecretRef{
+						SecretRef: &esv1alpha1.GCPSMAuthSecretRef{
 							SecretAccessKey: esmeta.SecretKeySelector{
 								Name: "provider-secret",
 								Key:  "secret-access-credentials",

+ 2 - 1
go.mod

@@ -81,7 +81,8 @@ require (
 	golang.org/x/tools v0.1.7 // indirect
 	google.golang.org/api v0.45.0
 	google.golang.org/genproto v0.0.0-20210413151531-c14fb6ef47c3
-	google.golang.org/grpc v1.37.0
+	google.golang.org/grpc v1.43.0
+	grpc.go4.org v0.0.0-20170609214715-11d0a25b4919
 	honnef.co/go/tools v0.1.4 // indirect
 	k8s.io/api v0.21.3
 	k8s.io/apimachinery v0.21.3

+ 2 - 0
go.sum

@@ -1163,6 +1163,8 @@ gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v
 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
 gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
+grpc.go4.org v0.0.0-20170609214715-11d0a25b4919 h1:tmXTu+dfa+d9Evp8NpJdgOy6+rt8/x4yG7qPBrtNfLY=
+grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

+ 61 - 48
pkg/provider/gcp/secretmanager/secretsmanager.go

@@ -21,10 +21,11 @@ import (
 	secretmanager "cloud.google.com/go/secretmanager/apiv1"
 	"github.com/googleapis/gax-go"
 	"github.com/tidwall/gjson"
+	"golang.org/x/oauth2"
 	"golang.org/x/oauth2/google"
 	"google.golang.org/api/option"
 	secretmanagerpb "google.golang.org/genproto/googleapis/cloud/secretmanager/v1"
-	corev1 "k8s.io/api/core/v1"
+	v1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/types"
 	kclient "sigs.k8s.io/controller-runtime/pkg/client"
 
@@ -40,11 +41,12 @@ const (
 
 	errGCPSMStore                             = "received invalid GCPSM SecretStore resource"
 	errClientClose                            = "unable to close SecretManager client: %w"
+	errMissingStoreSpec                       = "invalid: missing store spec"
 	errInvalidClusterStoreMissingSAKNamespace = "invalid ClusterSecretStore: missing GCP SecretAccessKey Namespace"
+	errInvalidClusterStoreMissingSANamespace  = "invalid ClusterSecretStore: missing GCP Service Account Namespace"
 	errFetchSAKSecret                         = "could not fetch SecretAccessKey secret: %w"
 	errMissingSAK                             = "missing SecretAccessKey"
 	errUnableProcessJSONCredentials           = "failed to process the provided JSON credentials: %w"
-	errUnableProcessDefaultCredentials        = "failed to process the default credentials: %w"
 	errUnableCreateGCPSMClient                = "failed to create GCP secretmanager client: %w"
 	errUninitalizedGCPProvider                = "provider GCP is not initialized"
 	errClientGetSecretAccess                  = "unable to access Secret from SecretManager Client: %w"
@@ -63,43 +65,64 @@ type ProviderGCP struct {
 }
 
 type gClient struct {
-	kube        kclient.Client
-	store       *esv1alpha1.GCPSMProvider
-	namespace   string
-	storeKind   string
-	credentials []byte
+	kube             kclient.Client
+	store            *esv1alpha1.GCPSMProvider
+	namespace        string
+	storeKind        string
+	workloadIdentity *workloadIdentity
 }
 
-func (c *gClient) setAuth(ctx context.Context) error {
-	credentialsSecret := &corev1.Secret{}
-	credentialsSecretName := c.store.Auth.SecretRef.SecretAccessKey.Name
+func (c *gClient) getTokenSource(ctx context.Context, store esv1alpha1.GenericStore, kube kclient.Client, namespace string) (oauth2.TokenSource, error) {
+	ts, err := serviceAccountTokenSource(ctx, store, kube, namespace)
+	if ts != nil || err != nil {
+		return ts, err
+	}
+	ts, err = c.workloadIdentity.TokenSource(ctx, store, kube, namespace)
+	if ts != nil || err != nil {
+		return ts, err
+	}
+
+	return google.DefaultTokenSource(ctx, CloudPlatformRole)
+}
+
+func serviceAccountTokenSource(ctx context.Context, store esv1alpha1.GenericStore, kube kclient.Client, namespace string) (oauth2.TokenSource, error) {
+	spec := store.GetSpec()
+	if spec == nil || spec.Provider.GCPSM == nil {
+		return nil, fmt.Errorf(errMissingStoreSpec)
+	}
+	sr := spec.Provider.GCPSM.Auth.SecretRef
+	if sr == nil {
+		return nil, nil
+	}
+	storeKind := store.GetObjectKind().GroupVersionKind().Kind
+	credentialsSecret := &v1.Secret{}
+	credentialsSecretName := sr.SecretAccessKey.Name
 	objectKey := types.NamespacedName{
 		Name:      credentialsSecretName,
-		Namespace: c.namespace,
+		Namespace: namespace,
 	}
 
 	// only ClusterStore is allowed to set namespace (and then it's required)
-	if c.storeKind == esv1alpha1.ClusterSecretStoreKind {
-		if credentialsSecretName != "" && c.store.Auth.SecretRef.SecretAccessKey.Namespace == nil {
-			return fmt.Errorf(errInvalidClusterStoreMissingSAKNamespace)
+	if storeKind == esv1alpha1.ClusterSecretStoreKind {
+		if credentialsSecretName != "" && sr.SecretAccessKey.Namespace == nil {
+			return nil, fmt.Errorf(errInvalidClusterStoreMissingSAKNamespace)
 		} else if credentialsSecretName != "" {
-			objectKey.Namespace = *c.store.Auth.SecretRef.SecretAccessKey.Namespace
+			objectKey.Namespace = *sr.SecretAccessKey.Namespace
 		}
 	}
-	if credentialsSecretName == "" {
-		c.credentials = nil
-		return nil
-	}
-	err := c.kube.Get(ctx, objectKey, credentialsSecret)
+	err := kube.Get(ctx, objectKey, credentialsSecret)
 	if err != nil {
-		return fmt.Errorf(errFetchSAKSecret, err)
+		return nil, fmt.Errorf(errFetchSAKSecret, err)
 	}
-
-	c.credentials = credentialsSecret.Data[c.store.Auth.SecretRef.SecretAccessKey.Key]
-	if (c.credentials == nil) || (len(c.credentials) == 0) {
-		return fmt.Errorf(errMissingSAK)
+	credentials := credentialsSecret.Data[sr.SecretAccessKey.Key]
+	if (credentials == nil) || (len(credentials) == 0) {
+		return nil, fmt.Errorf(errMissingSAK)
 	}
-	return nil
+	config, err := google.JWTConfigFromJSON(credentials, CloudPlatformRole)
+	if err != nil {
+		return nil, fmt.Errorf(errUnableProcessJSONCredentials, err)
+	}
+	return config.TokenSource(ctx), nil
 }
 
 // NewClient constructs a GCP Provider.
@@ -110,36 +133,26 @@ func (sm *ProviderGCP) NewClient(ctx context.Context, store esv1alpha1.GenericSt
 	}
 	storeSpecGCPSM := storeSpec.Provider.GCPSM
 
-	cliStore := gClient{
-		kube:      kube,
-		store:     storeSpecGCPSM,
-		namespace: namespace,
-		storeKind: store.GetObjectKind().GroupVersionKind().Kind,
+	wi, err := newWorkloadIdentity(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("unable to initialize workload identity")
 	}
 
-	if err := cliStore.setAuth(ctx); err != nil {
-		return nil, err
+	cliStore := gClient{
+		kube:             kube,
+		store:            storeSpecGCPSM,
+		namespace:        namespace,
+		storeKind:        store.GetObjectKind().GroupVersionKind().Kind,
+		workloadIdentity: wi,
 	}
 
 	sm.projectID = cliStore.store.ProjectID
 
-	if cliStore.credentials != nil {
-		config, err := google.JWTConfigFromJSON(cliStore.credentials, CloudPlatformRole)
-		if err != nil {
-			return nil, fmt.Errorf(errUnableProcessJSONCredentials, err)
-		}
-		ts := config.TokenSource(ctx)
-		clientGCPSM, err := secretmanager.NewClient(ctx, option.WithTokenSource(ts))
-		if err != nil {
-			return nil, fmt.Errorf(errUnableCreateGCPSMClient, err)
-		}
-		sm.SecretManagerClient = clientGCPSM
-		return sm, nil
-	}
-	ts, err := google.DefaultTokenSource(ctx, CloudPlatformRole)
+	ts, err := cliStore.getTokenSource(ctx, store, kube, namespace)
 	if err != nil {
-		return nil, fmt.Errorf(errUnableProcessDefaultCredentials, err)
+		return nil, fmt.Errorf(errUnableCreateGCPSMClient, err)
 	}
+
 	clientGCPSM, err := secretmanager.NewClient(ctx, option.WithTokenSource(ts))
 	if err != nil {
 		return nil, fmt.Errorf(errUnableCreateGCPSMClient, err)

+ 254 - 0
pkg/provider/gcp/secretmanager/secretsmanager_workload_identity.go

@@ -0,0 +1,254 @@
+/*
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+package secretmanager
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"time"
+
+	iam "cloud.google.com/go/iam/credentials/apiv1"
+	secretmanager "cloud.google.com/go/secretmanager/apiv1"
+	"github.com/googleapis/gax-go"
+	"golang.org/x/oauth2"
+	"google.golang.org/api/option"
+	credentialspb "google.golang.org/genproto/googleapis/iam/credentials/v1"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/credentials"
+	"grpc.go4.org/credentials/oauth"
+	authenticationv1 "k8s.io/api/authentication/v1"
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/client-go/kubernetes"
+	clientcorev1 "k8s.io/client-go/kubernetes/typed/core/v1"
+	kclient "sigs.k8s.io/controller-runtime/pkg/client"
+	ctrlcfg "sigs.k8s.io/controller-runtime/pkg/client/config"
+
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+)
+
+const (
+	gcpSAAnnotation = "iam.gke.io/gcp-service-account"
+
+	errFetchPodToken  = "unable to fetch pod token: %w"
+	errFetchIBToken   = "unable to fetch identitybindingtoken: %w"
+	errGenAccessToken = "unable to generate gcp access token: %w"
+)
+
+// workloadIdentity holds all clients and generators needed
+// to create a gcp oauth token.
+type workloadIdentity struct {
+	iamClient            IamClient
+	idBindTokenGenerator idBindTokenGenerator
+	saTokenGenerator     saTokenGenerator
+}
+
+// interface to GCP IAM API.
+type IamClient interface {
+	GenerateAccessToken(ctx context.Context, req *credentialspb.GenerateAccessTokenRequest, opts ...gax.CallOption) (*credentialspb.GenerateAccessTokenResponse, error)
+}
+
+// interface to securetoken/identitybindingtoken API.
+type idBindTokenGenerator interface {
+	Generate(context.Context, *http.Client, string, string, string) (*oauth2.Token, error)
+}
+
+// interface to kubernetes serviceaccount token request API.
+type saTokenGenerator interface {
+	Generate(context.Context, string, string, string) (*authenticationv1.TokenRequest, error)
+}
+
+func newWorkloadIdentity(ctx context.Context) (*workloadIdentity, error) {
+	iamc, err := newIAMClient(ctx)
+	if err != nil {
+		return nil, err
+	}
+	satg, err := newSATokenGenerator()
+	if err != nil {
+		return nil, err
+	}
+	return &workloadIdentity{
+		iamClient:            iamc,
+		idBindTokenGenerator: newIDBindTokenGenerator(),
+		saTokenGenerator:     satg,
+	}, nil
+}
+
+func (w *workloadIdentity) TokenSource(ctx context.Context, store esv1alpha1.GenericStore, kube kclient.Client, namespace string) (oauth2.TokenSource, error) {
+	spec := store.GetSpec()
+	if spec == nil || spec.Provider == nil || spec.Provider.GCPSM == nil {
+		return nil, fmt.Errorf(errMissingStoreSpec)
+	}
+	wi := spec.Provider.GCPSM.Auth.WorkloadIdentity
+	if wi == nil {
+		return nil, nil
+	}
+	storeKind := store.GetObjectKind().GroupVersionKind().Kind
+	saKey := types.NamespacedName{
+		Name:      wi.ServiceAccountRef.Name,
+		Namespace: namespace,
+	}
+
+	// only ClusterStore is allowed to set namespace (and then it's required)
+	if storeKind == esv1alpha1.ClusterSecretStoreKind {
+		if wi.ServiceAccountRef.Namespace == nil {
+			return nil, fmt.Errorf(errInvalidClusterStoreMissingSANamespace)
+		}
+		saKey.Namespace = *wi.ServiceAccountRef.Namespace
+	}
+
+	sa := &v1.ServiceAccount{}
+	err := kube.Get(ctx, saKey, sa)
+	if err != nil {
+		return nil, err
+	}
+
+	idProvider := fmt.Sprintf("https://container.googleapis.com/v1/projects/%s/locations/%s/clusters/%s",
+		spec.Provider.GCPSM.ProjectID,
+		wi.ClusterLocation,
+		wi.ClusterName)
+	idPool := fmt.Sprintf("%s.svc.id.goog", spec.Provider.GCPSM.ProjectID)
+	gcpSA := sa.Annotations[gcpSAAnnotation]
+
+	resp, err := w.saTokenGenerator.Generate(ctx, idPool, saKey.Name, saKey.Namespace)
+	if err != nil {
+		return nil, fmt.Errorf(errFetchPodToken, err)
+	}
+
+	idBindToken, err := w.idBindTokenGenerator.Generate(ctx, http.DefaultClient, resp.Status.Token, idPool, idProvider)
+	if err != nil {
+		return nil, fmt.Errorf(errFetchIBToken, err)
+	}
+
+	// If no `iam.gke.io/gcp-service-account` annotation is present the
+	// identitybindingtoken will be used directly, allowing bindings on secrets
+	// of the form "serviceAccount:<project>.svc.id.goog[<namespace>/<sa>]".
+	if gcpSA == "" {
+		return oauth2.StaticTokenSource(idBindToken), nil
+	}
+	gcpSAResp, err := w.iamClient.GenerateAccessToken(ctx, &credentialspb.GenerateAccessTokenRequest{
+		Name:  fmt.Sprintf("projects/-/serviceAccounts/%s", gcpSA),
+		Scope: secretmanager.DefaultAuthScopes(),
+	}, gax.WithGRPCOptions(grpc.PerRPCCredentials(oauth.TokenSource{TokenSource: oauth2.StaticTokenSource(idBindToken)})))
+	if err != nil {
+		return nil, fmt.Errorf(errGenAccessToken, err)
+	}
+	return oauth2.StaticTokenSource(&oauth2.Token{
+		AccessToken: gcpSAResp.GetAccessToken(),
+	}), nil
+}
+
+func newIAMClient(ctx context.Context) (IamClient, error) {
+	iamOpts := []option.ClientOption{
+		option.WithUserAgent("external-secrets-operator"),
+		// tell the secretmanager library to not add transport-level ADC since
+		// we need to override on a per call basis
+		option.WithoutAuthentication(),
+		// grpc oauth TokenSource credentials require transport security, so
+		// this must be set explicitly even though TLS is used
+		option.WithGRPCDialOption(grpc.WithTransportCredentials(credentials.NewTLS(nil))),
+		option.WithGRPCConnectionPool(5),
+	}
+	return iam.NewIamCredentialsClient(ctx, iamOpts...)
+}
+
+type k8sSATokenGenerator struct {
+	corev1 clientcorev1.CoreV1Interface
+}
+
+func (g *k8sSATokenGenerator) Generate(ctx context.Context, idPool, name, namespace string) (*authenticationv1.TokenRequest, error) {
+	// Request a serviceaccount token for the pod
+	ttl := int64((15 * time.Minute).Seconds())
+	return g.corev1.
+		ServiceAccounts(namespace).
+		CreateToken(ctx, name,
+			&authenticationv1.TokenRequest{
+				Spec: authenticationv1.TokenRequestSpec{
+					ExpirationSeconds: &ttl,
+					Audiences:         []string{idPool},
+				},
+			},
+			metav1.CreateOptions{},
+		)
+}
+
+func newSATokenGenerator() (saTokenGenerator, error) {
+	cfg, err := ctrlcfg.GetConfig()
+	if err != nil {
+		return nil, err
+	}
+	clientset, err := kubernetes.NewForConfig(cfg)
+	if err != nil {
+		return nil, err
+	}
+	return &k8sSATokenGenerator{
+		corev1: clientset.CoreV1(),
+	}, nil
+}
+
+// Trades the kubernetes token for an identitybindingtoken token.
+type gcpIDBindTokenGenerator struct {
+	targetURL string
+}
+
+func newIDBindTokenGenerator() idBindTokenGenerator {
+	return &gcpIDBindTokenGenerator{
+		targetURL: "https://securetoken.googleapis.com/v1/identitybindingtoken",
+	}
+}
+
+func (g *gcpIDBindTokenGenerator) Generate(ctx context.Context, client *http.Client, k8sToken, idPool, idProvider string) (*oauth2.Token, error) {
+	body, err := json.Marshal(map[string]string{
+		"grant_type":           "urn:ietf:params:oauth:grant-type:token-exchange",
+		"subject_token_type":   "urn:ietf:params:oauth:token-type:jwt",
+		"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
+		"subject_token":        k8sToken,
+		"audience":             fmt.Sprintf("identitynamespace:%s:%s", idPool, idProvider),
+		"scope":                "https://www.googleapis.com/auth/cloud-platform",
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequestWithContext(ctx, "POST", g.targetURL, bytes.NewBuffer(body))
+	if err != nil {
+		return nil, err
+	}
+
+	req.Header.Set("Content-Type", "application/json")
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("could not get idbindtoken token, status: %v", resp.StatusCode)
+	}
+
+	defer resp.Body.Close()
+	respBody, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		return nil, err
+	}
+
+	idBindToken := &oauth2.Token{}
+	if err := json.Unmarshal(respBody, idBindToken); err != nil {
+		return nil, err
+	}
+	return idBindToken, nil
+}

+ 392 - 0
pkg/provider/gcp/secretmanager/secretsmanager_workload_identity_test.go

@@ -0,0 +1,392 @@
+/*
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+package secretmanager
+
+import (
+	"context"
+	"encoding/json"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/googleapis/gax-go"
+	"github.com/stretchr/testify/assert"
+	"golang.org/x/oauth2"
+	credentialspb "google.golang.org/genproto/googleapis/iam/credentials/v1"
+	authv1 "k8s.io/api/authentication/v1"
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	k8sv1 "k8s.io/client-go/kubernetes/typed/core/v1"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	clientfake "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+	esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
+)
+
+type workloadIdentityTest struct {
+	name           string
+	expTS          bool
+	expToken       *oauth2.Token
+	expErr         string
+	genAccessToken func(context.Context, *credentialspb.GenerateAccessTokenRequest, ...gax.CallOption) (*credentialspb.GenerateAccessTokenResponse, error)
+	genIDBindToken func(ctx context.Context, client *http.Client, k8sToken, idPool, idProvider string) (*oauth2.Token, error)
+	genSAToken     func(c context.Context, s1, s2, s3 string) (*authv1.TokenRequest, error)
+	store          esv1alpha1.GenericStore
+	kubeObjects    []client.Object
+}
+
+func TestWorkloadIdentity(t *testing.T) {
+	clusterSANamespace := "foobar"
+	tbl := []*workloadIdentityTest{
+		composeTestcase(
+			defaultTestCase("missing store spec should result in error"),
+			withErr("invalid: missing store spec"),
+			withStore(&esv1alpha1.SecretStore{}),
+		),
+		composeTestcase(
+			defaultTestCase("should skip when no workload identity is configured: TokenSource and error must be nil"),
+			withStore(&esv1alpha1.SecretStore{
+				Spec: esv1alpha1.SecretStoreSpec{
+					Provider: &esv1alpha1.SecretStoreProvider{
+						GCPSM: &esv1alpha1.GCPSMProvider{},
+					},
+				},
+			}),
+		),
+		composeTestcase(
+			defaultTestCase("return access token from GenerateAccessTokenRequest with SecretStore"),
+			withStore(defaultStore()),
+			expTokenSource(),
+			expectToken(defaultGenAccessToken),
+		),
+		composeTestcase(
+			defaultTestCase("return idBindToken when no annotation is set with SecretStore"),
+			expTokenSource(),
+			expectToken(defaultIDBindToken),
+			withStore(defaultStore()),
+			withK8sResources([]client.Object{
+				&v1.ServiceAccount{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:        "example",
+						Namespace:   "default",
+						Annotations: map[string]string{},
+					},
+				},
+			}),
+		),
+		composeTestcase(
+			defaultTestCase("invalid ClusterSecretStore: missing service account namespace"),
+			expErr("invalid ClusterSecretStore: missing GCP Service Account Namespace"),
+			withStore(
+				composeStore(defaultClusterStore()),
+			),
+			withK8sResources([]client.Object{
+				&v1.ServiceAccount{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:        "example",
+						Namespace:   "default",
+						Annotations: map[string]string{},
+					},
+				},
+			}),
+		),
+		composeTestcase(
+			defaultTestCase("return access token from GenerateAccessTokenRequest with ClusterSecretStore"),
+			expTokenSource(),
+			expectToken(defaultGenAccessToken),
+			withStore(
+				composeStore(defaultClusterStore(), withSANamespace(clusterSANamespace)),
+			),
+			withK8sResources([]client.Object{
+				&v1.ServiceAccount{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "example",
+						Namespace: clusterSANamespace,
+						Annotations: map[string]string{
+							gcpSAAnnotation: "example",
+						},
+					},
+				},
+			}),
+		),
+	}
+
+	for _, row := range tbl {
+		t.Run(row.name, func(t *testing.T) {
+			fakeIam := &fakeIAMClient{generateAccessTokenFunc: row.genAccessToken}
+			fakeIDBGen := &fakeIDBindTokenGen{generateFunc: row.genIDBindToken}
+			fakeSATG := &fakeSATokenGen{GenerateFunc: row.genSAToken}
+			w := &workloadIdentity{
+				iamClient:            fakeIam,
+				idBindTokenGenerator: fakeIDBGen,
+				saTokenGenerator:     fakeSATG,
+			}
+			cb := clientfake.NewClientBuilder()
+			cb.WithObjects(row.kubeObjects...)
+			client := cb.Build()
+			ts, err := w.TokenSource(context.Background(), row.store, client, "default")
+			// assert err
+			if row.expErr == "" {
+				assert.NoError(t, err)
+			} else {
+				assert.Error(t, err, row.expErr)
+			}
+			// assert ts
+			if row.expTS {
+				assert.NotNil(t, ts)
+				if row.expToken != nil {
+					tk, err := ts.Token()
+					assert.NoError(t, err)
+					assert.EqualValues(t, tk, row.expToken)
+				}
+			} else {
+				assert.Nil(t, ts)
+			}
+		})
+	}
+}
+
+func TestSATokenGen(t *testing.T) {
+	corev1 := &fakeK8sV1{}
+	g := &k8sSATokenGenerator{
+		corev1: corev1,
+	}
+	token, err := g.Generate(context.Background(), "my-fake-audience", "bar", "default")
+	assert.Nil(t, err)
+	assert.Equal(t, token.Status.Token, defaultSAToken)
+	assert.Equal(t, token.Spec.Audiences[0], "my-fake-audience")
+}
+
+func TestIDBTokenGen(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+		payload := make(map[string]string)
+		rb, err := ioutil.ReadAll(r.Body)
+		assert.Nil(t, err)
+		err = json.Unmarshal(rb, &payload)
+		assert.Nil(t, err)
+		assert.Equal(t, payload["audience"], "identitynamespace:some-idpool:some-id-provider")
+
+		bt, err := json.Marshal(&oauth2.Token{
+			AccessToken: "12345",
+		})
+		assert.Nil(t, err)
+		rw.WriteHeader(http.StatusOK)
+		rw.Write(bt)
+	}))
+	defer srv.Close()
+	gen := &gcpIDBindTokenGenerator{
+		targetURL: srv.URL,
+	}
+	token, err := gen.Generate(context.Background(), http.DefaultClient, "some-token", "some-idpool", "some-id-provider")
+	assert.Nil(t, err)
+	assert.Equal(t, token.AccessToken, "12345")
+}
+
+type testCaseMutator func(tc *workloadIdentityTest)
+
+func composeTestcase(tc *workloadIdentityTest, mutators ...testCaseMutator) *workloadIdentityTest {
+	for _, m := range mutators {
+		m(tc)
+	}
+	return tc
+}
+
+func withErr(err string) testCaseMutator {
+	return func(tc *workloadIdentityTest) {
+		tc.expErr = err
+	}
+}
+
+func withStore(store esv1alpha1.GenericStore) testCaseMutator {
+	return func(tc *workloadIdentityTest) {
+		tc.store = store
+	}
+}
+
+func expTokenSource() testCaseMutator {
+	return func(tc *workloadIdentityTest) {
+		tc.expTS = true
+	}
+}
+
+func expectToken(token string) testCaseMutator {
+	return func(tc *workloadIdentityTest) {
+		tc.expToken = &oauth2.Token{
+			AccessToken: token,
+		}
+	}
+}
+
+func expErr(err string) testCaseMutator {
+	return func(tc *workloadIdentityTest) {
+		tc.expErr = err
+	}
+}
+
+func withK8sResources(objs []client.Object) testCaseMutator {
+	return func(tc *workloadIdentityTest) {
+		tc.kubeObjects = objs
+	}
+}
+
+var (
+	defaultGenAccessToken = "default-gen-access-token"
+	defaultIDBindToken    = "default-id-bind-token"
+	defaultSAToken        = "default-k8s-sa-token"
+)
+
+func defaultTestCase(name string) *workloadIdentityTest {
+	return &workloadIdentityTest{
+		name: name,
+		genAccessToken: func(c context.Context, gatr *credentialspb.GenerateAccessTokenRequest, co ...gax.CallOption) (*credentialspb.GenerateAccessTokenResponse, error) {
+			return &credentialspb.GenerateAccessTokenResponse{
+				AccessToken: defaultGenAccessToken,
+			}, nil
+		},
+		genIDBindToken: func(ctx context.Context, client *http.Client, k8sToken, idPool, idProvider string) (*oauth2.Token, error) {
+			return &oauth2.Token{
+				AccessToken: defaultIDBindToken,
+			}, nil
+		},
+		genSAToken: func(c context.Context, s1, s2, s3 string) (*authv1.TokenRequest, error) {
+			return &authv1.TokenRequest{
+				Status: authv1.TokenRequestStatus{
+					Token: defaultSAToken,
+				},
+			}, nil
+		},
+		kubeObjects: []client.Object{
+			&v1.ServiceAccount{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "example",
+					Namespace: "default",
+					Annotations: map[string]string{
+						gcpSAAnnotation: "example",
+					},
+				},
+			},
+		},
+	}
+}
+
+func defaultStore() *esv1alpha1.SecretStore {
+	return &esv1alpha1.SecretStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "foobar",
+			Namespace: "default",
+		},
+		Spec: defaultStoreSpec(),
+	}
+}
+
+func defaultClusterStore() *esv1alpha1.ClusterSecretStore {
+	return &esv1alpha1.ClusterSecretStore{
+		TypeMeta: metav1.TypeMeta{
+			Kind: esv1alpha1.ClusterSecretStoreKind,
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "foobar",
+		},
+		Spec: defaultStoreSpec(),
+	}
+}
+
+func defaultStoreSpec() esv1alpha1.SecretStoreSpec {
+	return esv1alpha1.SecretStoreSpec{
+		Provider: &esv1alpha1.SecretStoreProvider{
+			GCPSM: &esv1alpha1.GCPSMProvider{
+				Auth: esv1alpha1.GCPSMAuth{
+					WorkloadIdentity: &esv1alpha1.GCPWorkloadIdentity{
+						ServiceAccountRef: esmeta.ServiceAccountSelector{
+							Name: "example",
+						},
+						ClusterLocation: "example",
+						ClusterName:     "foobar",
+					},
+				},
+				ProjectID: "1234",
+			},
+		},
+	}
+}
+
+type storeMutator func(spc esv1alpha1.GenericStore)
+
+func composeStore(store esv1alpha1.GenericStore, mutators ...storeMutator) esv1alpha1.GenericStore {
+	for _, m := range mutators {
+		m(store)
+	}
+	return store
+}
+
+func withSANamespace(namespace string) storeMutator {
+	return func(store esv1alpha1.GenericStore) {
+		spc := store.GetSpec()
+		spc.Provider.GCPSM.Auth.WorkloadIdentity.ServiceAccountRef.Namespace = &namespace
+	}
+}
+
+// fake IDBindToken Generator.
+type fakeIDBindTokenGen struct {
+	generateFunc func(ctx context.Context, client *http.Client, k8sToken, idPool, idProvider string) (*oauth2.Token, error)
+}
+
+func (g *fakeIDBindTokenGen) Generate(ctx context.Context, client *http.Client, k8sToken, idPool, idProvider string) (*oauth2.Token, error) {
+	return g.generateFunc(ctx, client, k8sToken, idPool, idProvider)
+}
+
+// fake IAM Client.
+type fakeIAMClient struct {
+	generateAccessTokenFunc func(context.Context, *credentialspb.GenerateAccessTokenRequest, ...gax.CallOption) (*credentialspb.GenerateAccessTokenResponse, error)
+}
+
+func (f *fakeIAMClient) GenerateAccessToken(ctx context.Context, req *credentialspb.GenerateAccessTokenRequest, opts ...gax.CallOption) (*credentialspb.GenerateAccessTokenResponse, error) {
+	return f.generateAccessTokenFunc(ctx, req, opts...)
+}
+
+// fake SA Token Generator.
+type fakeSATokenGen struct {
+	GenerateFunc func(context.Context, string, string, string) (*authv1.TokenRequest, error)
+}
+
+func (f *fakeSATokenGen) Generate(ctx context.Context, idPool, namespace, name string) (*authv1.TokenRequest, error) {
+	return f.GenerateFunc(ctx, idPool, namespace, name)
+}
+
+// fake k8s client for creating tokens.
+type fakeK8sV1 struct {
+	k8sv1.CoreV1Interface
+}
+
+func (m *fakeK8sV1) ServiceAccounts(namespace string) k8sv1.ServiceAccountInterface {
+	return &fakeK8sV1SA{v1mock: m}
+}
+
+// Mock the K8s service account client.
+type fakeK8sV1SA struct {
+	k8sv1.ServiceAccountInterface
+	v1mock *fakeK8sV1
+}
+
+func (ma *fakeK8sV1SA) CreateToken(
+	ctx context.Context,
+	serviceAccountName string,
+	tokenRequest *authv1.TokenRequest,
+	opts metav1.CreateOptions,
+) (*authv1.TokenRequest, error) {
+	tokenRequest.Status.Token = defaultSAToken
+	return tokenRequest, nil
+}