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