From 80fac0f6974879a23b5e996711dbecead5e1b27a Mon Sep 17 00:00:00 2001 From: Moritz Johner Date: Fri, 17 Dec 2021 01:25:54 +0100 Subject: [PATCH] feat: add gcp workload identity via SA Signed-off-by: Moritz Johner --- .golangci.yaml | 1 - .../v1alpha1/secretstore_gcpsm_types.go | 11 +- .../v1alpha1/zz_generated.deepcopy.go | 28 +- apis/meta/v1/zz_generated.deepcopy.go | 1 + ...ternal-secrets.io_clustersecretstores.yaml | 29 +- .../external-secrets.io_secretstores.yaml | 29 +- e2e/suite/gcp/provider.go | 2 +- go.mod | 3 +- go.sum | 2 + .../gcp/secretmanager/secretsmanager.go | 109 ++--- .../secretsmanager_workload_identity.go | 254 ++++++++++++ .../secretsmanager_workload_identity_test.go | 392 ++++++++++++++++++ 12 files changed, 804 insertions(+), 57 deletions(-) create mode 100644 pkg/provider/gcp/secretmanager/secretsmanager_workload_identity.go create mode 100644 pkg/provider/gcp/secretmanager/secretsmanager_workload_identity_test.go diff --git a/.golangci.yaml b/.golangci.yaml index 7aa326bd4..b18b492a3 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -63,7 +63,6 @@ linters: - gosimple - govet - ineffassign - - interfacer - lll - misspell - nakedret diff --git a/apis/externalsecrets/v1alpha1/secretstore_gcpsm_types.go b/apis/externalsecrets/v1alpha1/secretstore_gcpsm_types.go index 709914880..6adc5326a 100644 --- a/apis/externalsecrets/v1alpha1/secretstore_gcpsm_types.go +++ b/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 diff --git a/apis/externalsecrets/v1alpha1/zz_generated.deepcopy.go b/apis/externalsecrets/v1alpha1/zz_generated.deepcopy.go index 660b2cd76..aa3f25dea 100644 --- a/apis/externalsecrets/v1alpha1/zz_generated.deepcopy.go +++ b/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. @@ -646,6 +656,22 @@ func (in *GCPSMProvider) DeepCopy() *GCPSMProvider { return out } +// 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 diff --git a/apis/meta/v1/zz_generated.deepcopy.go b/apis/meta/v1/zz_generated.deepcopy.go index 92d808bb6..047f94858 100644 --- a/apis/meta/v1/zz_generated.deepcopy.go +++ b/apis/meta/v1/zz_generated.deepcopy.go @@ -1,3 +1,4 @@ +//go:build !ignore_autogenerated // +build !ignore_autogenerated /* diff --git a/deploy/crds/external-secrets.io_clustersecretstores.yaml b/deploy/crds/external-secrets.io_clustersecretstores.yaml index 8219a0aeb..758d55522 100644 --- a/deploy/crds/external-secrets.io_clustersecretstores.yaml +++ b/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 diff --git a/deploy/crds/external-secrets.io_secretstores.yaml b/deploy/crds/external-secrets.io_secretstores.yaml index 6ff7ce2a8..864d7c706 100644 --- a/deploy/crds/external-secrets.io_secretstores.yaml +++ b/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 diff --git a/e2e/suite/gcp/provider.go b/e2e/suite/gcp/provider.go index 48c310610..c6df4a159 100644 --- a/e2e/suite/gcp/provider.go +++ b/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", diff --git a/go.mod b/go.mod index 34b424093..094850458 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 5c28d4f17..cae7a2d29 100644 --- a/go.sum +++ b/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= diff --git a/pkg/provider/gcp/secretmanager/secretsmanager.go b/pkg/provider/gcp/secretmanager/secretsmanager.go index 6ccb329ae..2bd2d14e9 100644 --- a/pkg/provider/gcp/secretmanager/secretsmanager.go +++ b/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) diff --git a/pkg/provider/gcp/secretmanager/secretsmanager_workload_identity.go b/pkg/provider/gcp/secretmanager/secretsmanager_workload_identity.go new file mode 100644 index 000000000..4aa92bc30 --- /dev/null +++ b/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:.svc.id.goog[/]". + 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 +} diff --git a/pkg/provider/gcp/secretmanager/secretsmanager_workload_identity_test.go b/pkg/provider/gcp/secretmanager/secretsmanager_workload_identity_test.go new file mode 100644 index 000000000..d3f5f32bc --- /dev/null +++ b/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 +}