1
0
Fork 0
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:
Moritz Johner 2021-12-17 01:25:54 +01:00
parent 93483832a1
commit 80fac0f697
12 changed files with 804 additions and 57 deletions

View file

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

View file

@ -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

View file

@ -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

View file

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

View file

@ -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

View file

@ -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

View file

@ -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
View file

@ -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
View file

@ -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=

View file

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

View file

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

View file

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