mirror of
https://github.com/external-secrets/external-secrets.git
synced 2024-12-14 11:57:59 +00:00
feat: add gcp workload identity via SA
Signed-off-by: Moritz Johner <beller.moritz@googlemail.com>
This commit is contained in:
parent
93483832a1
commit
80fac0f697
12 changed files with 804 additions and 57 deletions
|
@ -63,7 +63,6 @@ linters:
|
|||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- interfacer
|
||||
- lll
|
||||
- misspell
|
||||
- nakedret
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build !ignore_autogenerated
|
||||
// +build !ignore_autogenerated
|
||||
|
||||
/*
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
3
go.mod
3
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
go.sum
2
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=
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
Loading…
Reference in a new issue