diff --git a/CHANGELOG.md b/CHANGELOG.md index 786b98b9efc..5a59e9c66fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ To learn more about active deprecations, we recommend checking [GitHub Discussio - **General**: Enable OpenSSF Scorecard to enhance security practices across the project ([#5913](https://github.com/kedacore/keda/issues/5913)) - **General**: Introduce new NSQ scaler ([#3281](https://github.com/kedacore/keda/issues/3281)) - **General**: Operator flag to control patching of webhook resources certificates ([#6184](https://github.com/kedacore/keda/issues/6184)) +- **General**: Vault authentication via cross-namespace service accounts ([#6153](https://github.com/kedacore/keda/issues/6153)) #### Experimental diff --git a/Makefile b/Makefile index b1d6e561d2f..884d77e49fc 100644 --- a/Makefile +++ b/Makefile @@ -182,7 +182,7 @@ pkg/mock/mock_scale/mock_interfaces.go: vendor/k8s.io/client-go/scale/interfaces $(MOCKGEN) k8s.io/client-go/scale ScalesGetter,ScaleInterface > $@ pkg/mock/mock_client/mock_interfaces.go: vendor/sigs.k8s.io/controller-runtime/pkg/client/interfaces.go mkdir -p pkg/mock/mock_client - $(MOCKGEN) sigs.k8s.io/controller-runtime/pkg/client Patch,Reader,Writer,StatusClient,StatusWriter,Client,WithWatch,FieldIndexer > $@ + $(MOCKGEN) sigs.k8s.io/controller-runtime/pkg/client Patch,Reader,Writer,StatusClient,StatusWriter,Client,WithWatch,FieldIndexer,SubResourceClient > $@ pkg/scalers/liiklus/mocks/mock_liiklus.go: $(MOCKGEN) -destination=$@ github.com/kedacore/keda/v2/pkg/scalers/liiklus LiiklusServiceClient diff --git a/apis/keda/v1alpha1/triggerauthentication_types.go b/apis/keda/v1alpha1/triggerauthentication_types.go index 0b0d9ffa315..05c801a23b7 100644 --- a/apis/keda/v1alpha1/triggerauthentication_types.go +++ b/apis/keda/v1alpha1/triggerauthentication_types.go @@ -236,6 +236,9 @@ type Credential struct { // +optional ServiceAccount string `json:"serviceAccount,omitempty"` + + // +optional + ServiceAccountName string `json:"serviceAccountName,omitempty"` } // VaultAuthentication contains the list of Hashicorp Vault authentication methods diff --git a/config/crd/bases/keda.sh_clustertriggerauthentications.yaml b/config/crd/bases/keda.sh_clustertriggerauthentications.yaml index d8a74647ffc..f7b2e9cd4cb 100644 --- a/config/crd/bases/keda.sh_clustertriggerauthentications.yaml +++ b/config/crd/bases/keda.sh_clustertriggerauthentications.yaml @@ -437,6 +437,8 @@ spec: properties: serviceAccount: type: string + serviceAccountName: + type: string token: type: string type: object diff --git a/config/crd/bases/keda.sh_triggerauthentications.yaml b/config/crd/bases/keda.sh_triggerauthentications.yaml index 9c38fa2ada4..c5a824bd146 100644 --- a/config/crd/bases/keda.sh_triggerauthentications.yaml +++ b/config/crd/bases/keda.sh_triggerauthentications.yaml @@ -436,6 +436,8 @@ spec: properties: serviceAccount: type: string + serviceAccountName: + type: string token: type: string type: object diff --git a/config/e2e/kustomization.yaml b/config/e2e/kustomization.yaml index 8ed4cd78173..12a7cef8398 100644 --- a/config/e2e/kustomization.yaml +++ b/config/e2e/kustomization.yaml @@ -9,9 +9,15 @@ resources: apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization patches: -- path: patch_operator.yml +- path: patch_operator.yaml target: group: apps kind: Deployment name: keda-operator version: v1 +- path: patch_rbac.yaml + target: + group: rbac.authorization.k8s.io + kind: ClusterRole + name: keda-operator + version: v1 diff --git a/config/e2e/patch_operator.yml b/config/e2e/patch_operator.yaml similarity index 100% rename from config/e2e/patch_operator.yml rename to config/e2e/patch_operator.yaml diff --git a/config/e2e/patch_rbac.yaml b/config/e2e/patch_rbac.yaml new file mode 100644 index 00000000000..3ea11919d92 --- /dev/null +++ b/config/e2e/patch_rbac.yaml @@ -0,0 +1,9 @@ + - op: add + path: /rules/- + value: + apiGroups: + - "" + resources: + - serviceaccounts/token + verbs: + - create diff --git a/pkg/mock/mock_client/mock_interfaces.go b/pkg/mock/mock_client/mock_interfaces.go index ee5d35578c1..e41cb327bba 100644 --- a/pkg/mock/mock_client/mock_interfaces.go +++ b/pkg/mock/mock_client/mock_interfaces.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: sigs.k8s.io/controller-runtime/pkg/client (interfaces: Patch,Reader,Writer,StatusClient,StatusWriter,Client,WithWatch,FieldIndexer) +// Source: sigs.k8s.io/controller-runtime/pkg/client (interfaces: Patch,Reader,Writer,StatusClient,StatusWriter,Client,WithWatch,FieldIndexer,SubResourceClient) // // Generated by this command: // -// mockgen sigs.k8s.io/controller-runtime/pkg/client Patch,Reader,Writer,StatusClient,StatusWriter,Client,WithWatch,FieldIndexer +// mockgen sigs.k8s.io/controller-runtime/pkg/client Patch,Reader,Writer,StatusClient,StatusWriter,Client,WithWatch,FieldIndexer,SubResourceClient // // Package mock_client is a generated GoMock package. @@ -918,3 +918,103 @@ func (mr *MockFieldIndexerMockRecorder) IndexField(ctx, obj, field, extractValue mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IndexField", reflect.TypeOf((*MockFieldIndexer)(nil).IndexField), ctx, obj, field, extractValue) } + +// MockSubResourceClient is a mock of SubResourceClient interface. +type MockSubResourceClient struct { + ctrl *gomock.Controller + recorder *MockSubResourceClientMockRecorder + isgomock struct{} +} + +// MockSubResourceClientMockRecorder is the mock recorder for MockSubResourceClient. +type MockSubResourceClientMockRecorder struct { + mock *MockSubResourceClient +} + +// NewMockSubResourceClient creates a new mock instance. +func NewMockSubResourceClient(ctrl *gomock.Controller) *MockSubResourceClient { + mock := &MockSubResourceClient{ctrl: ctrl} + mock.recorder = &MockSubResourceClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSubResourceClient) EXPECT() *MockSubResourceClientMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockSubResourceClient) Create(ctx context.Context, obj, subResource client.Object, opts ...client.SubResourceCreateOption) error { + m.ctrl.T.Helper() + varargs := []any{ctx, obj, subResource} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Create", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Create indicates an expected call of Create. +func (mr *MockSubResourceClientMockRecorder) Create(ctx, obj, subResource any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, obj, subResource}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockSubResourceClient)(nil).Create), varargs...) +} + +// Get mocks base method. +func (m *MockSubResourceClient) Get(ctx context.Context, obj, subResource client.Object, opts ...client.SubResourceGetOption) error { + m.ctrl.T.Helper() + varargs := []any{ctx, obj, subResource} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Get", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Get indicates an expected call of Get. +func (mr *MockSubResourceClientMockRecorder) Get(ctx, obj, subResource any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, obj, subResource}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockSubResourceClient)(nil).Get), varargs...) +} + +// Patch mocks base method. +func (m *MockSubResourceClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error { + m.ctrl.T.Helper() + varargs := []any{ctx, obj, patch} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Patch", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Patch indicates an expected call of Patch. +func (mr *MockSubResourceClientMockRecorder) Patch(ctx, obj, patch any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, obj, patch}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Patch", reflect.TypeOf((*MockSubResourceClient)(nil).Patch), varargs...) +} + +// Update mocks base method. +func (m *MockSubResourceClient) Update(ctx context.Context, obj client.Object, opts ...client.SubResourceUpdateOption) error { + m.ctrl.T.Helper() + varargs := []any{ctx, obj} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Update", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Update indicates an expected call of Update. +func (mr *MockSubResourceClientMockRecorder) Update(ctx, obj any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, obj}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockSubResourceClient)(nil).Update), varargs...) +} diff --git a/pkg/scaling/resolver/hashicorpvault_handler.go b/pkg/scaling/resolver/hashicorpvault_handler.go index e80b7302cda..f109c75ca9a 100644 --- a/pkg/scaling/resolver/hashicorpvault_handler.go +++ b/pkg/scaling/resolver/hashicorpvault_handler.go @@ -17,29 +17,38 @@ limitations under the License. package resolver import ( + "context" "encoding/json" - "errors" "fmt" "os" "strings" "github.com/go-logr/logr" vaultapi "github.com/hashicorp/vault/api" + "github.com/pkg/errors" + authenticationv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" kedav1alpha1 "github.com/kedacore/keda/v2/apis/keda/v1alpha1" ) // HashicorpVaultHandler is specification of Hashi Corp Vault type HashicorpVaultHandler struct { - vault *kedav1alpha1.HashiCorpVault - client *vaultapi.Client - stopCh chan struct{} + vault *kedav1alpha1.HashiCorpVault + client *vaultapi.Client + k8sClient client.Client + namespace string + stopCh chan struct{} } // NewHashicorpVaultHandler creates a HashicorpVaultHandler object -func NewHashicorpVaultHandler(v *kedav1alpha1.HashiCorpVault) *HashicorpVaultHandler { +func NewHashicorpVaultHandler(v *kedav1alpha1.HashiCorpVault, client client.Client, namespace string) *HashicorpVaultHandler { return &HashicorpVaultHandler{ - vault: v, + vault: v, + k8sClient: client, + namespace: namespace, } } @@ -88,6 +97,8 @@ func (vh *HashicorpVaultHandler) Initialize(logger logr.Logger) error { // token Extract a vault token from the Authentication method func (vh *HashicorpVaultHandler) token(client *vaultapi.Client) (string, error) { var token string + var jwt []byte + var err error switch vh.vault.Authentication { case kedav1alpha1.VaultAuthenticationToken: @@ -116,14 +127,54 @@ func (vh *HashicorpVaultHandler) token(client *vaultapi.Client) (string, error) vh.vault.Credential = &defaultCred } - if len(vh.vault.Credential.ServiceAccount) == 0 { - return token, errors.New("k8s SA file not in config") + if vh.vault.Credential.ServiceAccountName == "" && len(vh.vault.Credential.ServiceAccount) == 0 { + return token, errors.New("k8s SA file not in config or serviceAccountName not supplied") } - // Get the JWT from POD - jwt, err := os.ReadFile(vh.vault.Credential.ServiceAccount) - if err != nil { - return token, err + if vh.vault.Credential.ServiceAccountName != "" { + // generate token from namespace + saName := types.NamespacedName{Name: vh.vault.Credential.ServiceAccountName, Namespace: vh.namespace} + sa := &corev1.ServiceAccount{} + secret := &corev1.Secret{} + + if err = vh.k8sClient.Get(context.Background(), saName, sa); err != nil { + return token, errors.Wrap(err, fmt.Sprintf("Failed to retrieve service account name: %s namespace: %s", saName.Name, saName.Namespace)) + } + + if len(sa.Secrets) > 0 { + // using legacy service account secrets + secretName := types.NamespacedName{Name: sa.Secrets[0].Name, Namespace: vh.namespace} + + if err = vh.k8sClient.Get(context.Background(), secretName, secret); err != nil { + return token, errors.Wrap(err, fmt.Sprintf("Failed to retrieve secret for service account name: %s namespace: %s", secretName.Name, secretName.Namespace)) + } + + jwt = secret.Data["token"] + } + + if len(jwt) == 0 { + tokenTTL := int64(600) // min allowed duration is 10 mins + // this token is only used once for the initial authentication + // renewals happen independently on the vault token + tokenRequest := &authenticationv1.TokenRequest{ + Spec: authenticationv1.TokenRequestSpec{ + Audiences: []string{"https://kubernetes.default.svc"}, + ExpirationSeconds: &tokenTTL, + }, + } + + if err := vh.k8sClient.SubResource("token").Create(context.Background(), sa, tokenRequest); err != nil { + return token, errors.Wrap(err, fmt.Sprintf("Failed to create token for service account name: %s namespace: %s", saName.Name, saName.Namespace)) + } + + jwt = []byte(tokenRequest.Status.Token) + } + } else if len(vh.vault.Credential.ServiceAccount) != 0 { + // Get the JWT from POD + jwt, err = os.ReadFile(vh.vault.Credential.ServiceAccount) + if err != nil { + return token, err + } } data := map[string]interface{}{"jwt": string(jwt), "role": vh.vault.Role} @@ -131,8 +182,8 @@ func (vh *HashicorpVaultHandler) token(client *vaultapi.Client) (string, error) if err != nil { return token, err } - token = secret.Auth.ClientToken + default: return token, fmt.Errorf("vault auth method %s is not supported", vh.vault.Authentication) } diff --git a/pkg/scaling/resolver/hashicorpvault_handler_test.go b/pkg/scaling/resolver/hashicorpvault_handler_test.go index 5051faeb332..e5c7c930c2f 100644 --- a/pkg/scaling/resolver/hashicorpvault_handler_test.go +++ b/pkg/scaling/resolver/hashicorpvault_handler_test.go @@ -17,6 +17,7 @@ limitations under the License. package resolver import ( + "context" "encoding/base64" "encoding/json" "fmt" @@ -27,9 +28,15 @@ import ( vaultapi "github.com/hashicorp/vault/api" "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + authenticationv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" logf "sigs.k8s.io/controller-runtime/pkg/log" kedav1alpha1 "github.com/kedacore/keda/v2/apis/keda/v1alpha1" + "github.com/kedacore/keda/v2/pkg/mock/mock_client" ) const ( @@ -93,7 +100,10 @@ var pkiRequestTestDataset = []pkiRequestTestData{ } func TestGetPkiRequest(t *testing.T) { - vault := NewHashicorpVaultHandler(nil) + ctrl := gomock.NewController(t) + client := mock_client.NewMockClient(ctrl) + + vault := NewHashicorpVaultHandler(nil, client, "default") for _, testData := range pkiRequestTestDataset { var secret kedav1alpha1.VaultSecret @@ -119,6 +129,7 @@ func TestGetPkiRequest(t *testing.T) { func mockVault(t *testing.T, useRootToken bool) *httptest.Server { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var data map[string]interface{} + var auth *vaultapi.SecretAuth switch r.URL.Path { case "/v1/auth/token/lookup-self": data = vaultTokenSelf @@ -148,7 +159,10 @@ func mockVault(t *testing.T, useRootToken bool) *httptest.Server { "private_key_type": "rsa", "serial_number": "4c:79:c6:2c:23:65:77:73:c2:79:49:8c:c8:fe:ad:e3:78:68:0f:86", } - + case "/v1/auth/kubernetes/login": + auth = &vaultapi.SecretAuth{ + ClientToken: vaultTestToken, + } default: t.Logf("Got request at path %s", r.URL.Path) w.WriteHeader(404) @@ -161,7 +175,7 @@ func mockVault(t *testing.T, useRootToken bool) *httptest.Server { Data: data, Renewable: false, Warnings: nil, - Auth: nil, + Auth: auth, WrapInfo: nil, } var out, _ = json.Marshal(secret) @@ -173,6 +187,8 @@ func mockVault(t *testing.T, useRootToken bool) *httptest.Server { func TestHashicorpVaultHandler_getSecretValue_specify_secret_type(t *testing.T) { server := mockVault(t, false) defer server.Close() + ctrl := gomock.NewController(t) + client := mock_client.NewMockClient(ctrl) vault := kedav1alpha1.HashiCorpVault{ Address: server.URL, @@ -181,7 +197,7 @@ func TestHashicorpVaultHandler_getSecretValue_specify_secret_type(t *testing.T) Token: vaultTestToken, }, } - vaultHandler := NewHashicorpVaultHandler(&vault) + vaultHandler := NewHashicorpVaultHandler(&vault, client, "default") err := vaultHandler.Initialize(logf.Log.WithName("test")) defer vaultHandler.Stop() assert.Nil(t, err) @@ -313,6 +329,8 @@ var resolveRequestTestDataSet = []resolveRequestTestData{ func TestHashicorpVaultHandler_ResolveSecret(t *testing.T) { server := mockVault(t, false) defer server.Close() + ctrl := gomock.NewController(t) + client := mock_client.NewMockClient(ctrl) vault := kedav1alpha1.HashiCorpVault{ Address: server.URL, @@ -321,7 +339,7 @@ func TestHashicorpVaultHandler_ResolveSecret(t *testing.T) { Token: vaultTestToken, }, } - vaultHandler := NewHashicorpVaultHandler(&vault) + vaultHandler := NewHashicorpVaultHandler(&vault, client, "default") err := vaultHandler.Initialize(logf.Log.WithName("test")) defer vaultHandler.Stop() assert.Nil(t, err) @@ -357,7 +375,10 @@ func TestHashicorpVaultHandler_ResolveSecret_UsingRootToken(t *testing.T) { Token: vaultTestToken, }, } - vaultHandler := NewHashicorpVaultHandler(&vault) + ctrl := gomock.NewController(t) + client := mock_client.NewMockClient(ctrl) + + vaultHandler := NewHashicorpVaultHandler(&vault, client, "default") err := vaultHandler.Initialize(logf.Log.WithName("test")) defer vaultHandler.Stop() assert.Nil(t, err) @@ -386,6 +407,8 @@ func TestHashicorpVaultHandler_DefaultKubernetesVaultRole(t *testing.T) { defaultServiceAccountPath := "/var/run/secrets/kubernetes.io/serviceaccount/token" server := mockVault(t, false) defer server.Close() + ctrl := gomock.NewController(t) + client := mock_client.NewMockClient(ctrl) vault := kedav1alpha1.HashiCorpVault{ Address: server.URL, @@ -394,7 +417,7 @@ func TestHashicorpVaultHandler_DefaultKubernetesVaultRole(t *testing.T) { Role: "my-role", } - vaultHandler := NewHashicorpVaultHandler(&vault) + vaultHandler := NewHashicorpVaultHandler(&vault, client, "default") err := vaultHandler.Initialize(logf.Log.WithName("test")) defer vaultHandler.Stop() assert.Errorf(t, err, "open %s : no such file or directory", defaultServiceAccountPath) @@ -404,6 +427,8 @@ func TestHashicorpVaultHandler_DefaultKubernetesVaultRole(t *testing.T) { func TestHashicorpVaultHandler_ResolveSecrets_SameCertAndKey(t *testing.T) { server := mockVault(t, false) defer server.Close() + ctrl := gomock.NewController(t) + client := mock_client.NewMockClient(ctrl) vault := kedav1alpha1.HashiCorpVault{ Address: server.URL, @@ -412,7 +437,7 @@ func TestHashicorpVaultHandler_ResolveSecrets_SameCertAndKey(t *testing.T) { Token: vaultTestToken, }, } - vaultHandler := NewHashicorpVaultHandler(&vault) + vaultHandler := NewHashicorpVaultHandler(&vault, client, "default") err := vaultHandler.Initialize(logf.Log.WithName("test")) defer vaultHandler.Stop() assert.Nil(t, err) @@ -473,6 +498,9 @@ func TestHashicorpVaultHandler_fetchSecret(t *testing.T) { server := mockVault(t, false) defer server.Close() + ctrl := gomock.NewController(t) + client := mock_client.NewMockClient(ctrl) + vault := kedav1alpha1.HashiCorpVault{ Address: server.URL, Authentication: kedav1alpha1.VaultAuthenticationToken, @@ -480,7 +508,8 @@ func TestHashicorpVaultHandler_fetchSecret(t *testing.T) { Token: vaultTestToken, }, } - vaultHandler := NewHashicorpVaultHandler(&vault) + + vaultHandler := NewHashicorpVaultHandler(&vault, client, "default") err := vaultHandler.Initialize(logf.Log.WithName("test")) defer vaultHandler.Stop() assert.Nil(t, err) @@ -526,6 +555,9 @@ func TestHashicorpVaultHandler_Initialize(t *testing.T) { server := mockVault(t, false) defer server.Close() + ctrl := gomock.NewController(t) + client := mock_client.NewMockClient(ctrl) + for _, testData := range initialiseTestDataSet { func() { vault := kedav1alpha1.HashiCorpVault{ @@ -536,7 +568,7 @@ func TestHashicorpVaultHandler_Initialize(t *testing.T) { }, Namespace: testData.namespace, } - vaultHandler := NewHashicorpVaultHandler(&vault) + vaultHandler := NewHashicorpVaultHandler(&vault, client, testData.namespace) err := vaultHandler.Initialize(logf.Log.WithName("test")) defer vaultHandler.Stop() assert.Nil(t, err) @@ -601,6 +633,9 @@ func TestHashicorpVaultHandler_Token_VaultTokenAuth(t *testing.T) { server := mockVault(t, false) defer server.Close() + ctrl := gomock.NewController(t) + client := mock_client.NewMockClient(ctrl) + for _, testData := range tokenTestDataSet { func() { vault := kedav1alpha1.HashiCorpVault{ @@ -610,7 +645,7 @@ func TestHashicorpVaultHandler_Token_VaultTokenAuth(t *testing.T) { Role: testData.role, Mount: testData.mount, } - vaultHandler := NewHashicorpVaultHandler(&vault) + vaultHandler := NewHashicorpVaultHandler(&vault, client, "default") defer vaultHandler.Stop() config := vaultapi.DefaultConfig() @@ -627,3 +662,55 @@ func TestHashicorpVaultHandler_Token_VaultTokenAuth(t *testing.T) { }() } } + +func TestHashicorpVaultHandler_Token_ServiceAccountAuth(t *testing.T) { + server := mockVault(t, false) + defer server.Close() + + ctrl := gomock.NewController(t) + mockK8sClient := mock_client.NewMockClient(ctrl) + mockSubresourceClient := mock_client.NewMockSubResourceClient(ctrl) + + defer ctrl.Finish() + + // Mock getting the service account + mockK8sClient.EXPECT(). + Get(gomock.Any(), types.NamespacedName{Name: "test-sa", Namespace: "default"}, gomock.AssignableToTypeOf(&corev1.ServiceAccount{})). + DoAndReturn(func(_ context.Context, _ types.NamespacedName, sa *corev1.ServiceAccount, _ ...client.GetOption) error { + sa.Name = "test-sa" + sa.Namespace = "default" + return nil + }) + + mockSubresourceClient.EXPECT().Create(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&authenticationv1.TokenRequest{})). + DoAndReturn(func(ctx context.Context, obj client.Object, subResource *authenticationv1.TokenRequest, opts ...client.SubResourceCreateOption) error { + subResource.Status.Token = "test" + return nil + }) + // Mock creating the token request + mockK8sClient.EXPECT().SubResource("token").AnyTimes().Return( + mockSubresourceClient, + ) + + vault := kedav1alpha1.HashiCorpVault{ + Address: server.URL, + Authentication: kedav1alpha1.VaultAuthenticationKubernetes, + Mount: "kubernetes", + Role: "keda-role", + Credential: &kedav1alpha1.Credential{ + ServiceAccountName: "test-sa", + }, + } + + vaultHandler := NewHashicorpVaultHandler(&vault, mockK8sClient, "default") + defer vaultHandler.Stop() + + config := vaultapi.DefaultConfig() + config.Address = server.URL + client, err := vaultapi.NewClient(config) + assert.NoError(t, err) + + token, err := vaultHandler.token(client) + assert.NoError(t, err) + assert.NotEmpty(t, token) +} diff --git a/pkg/scaling/resolver/scale_resolvers.go b/pkg/scaling/resolver/scale_resolvers.go index 6ca40672fc6..a97722f15af 100644 --- a/pkg/scaling/resolver/scale_resolvers.go +++ b/pkg/scaling/resolver/scale_resolvers.go @@ -265,7 +265,7 @@ func resolveAuthRef(ctx context.Context, client client.Client, logger logr.Logge } } if triggerAuthSpec.HashiCorpVault != nil && len(triggerAuthSpec.HashiCorpVault.Secrets) > 0 { - vault := NewHashicorpVaultHandler(triggerAuthSpec.HashiCorpVault) + vault := NewHashicorpVaultHandler(triggerAuthSpec.HashiCorpVault, client, namespace) err := vault.Initialize(logger) defer vault.Stop() if err != nil { diff --git a/tests/secret-providers/hashicorp_vault/hashicorp_vault_test.go b/tests/secret-providers/hashicorp_vault/hashicorp_vault_test.go index cd135e50d4a..a81fe987eb3 100644 --- a/tests/secret-providers/hashicorp_vault/hashicorp_vault_test.go +++ b/tests/secret-providers/hashicorp_vault/hashicorp_vault_test.go @@ -70,6 +70,8 @@ type templateData struct { MonitoredAppName string PrometheusServerName string VaultPkiCommonName string + VaultRole string + VaultServiceAccountName string } const ( @@ -128,9 +130,12 @@ metadata: spec: hashiCorpVault: address: http://vault.{{.VaultNamespace}}:8200 - authentication: token + authentication: {{.HashiCorpAuthentication}} + role: {{.VaultRole}} + mount: kubernetes credential: token: {{.HashiCorpToken}} + serviceAccountName: {{.VaultServiceAccountName}} secrets: - parameter: connection key: connectionString @@ -413,6 +418,13 @@ spec: pkiPolicyTemplate = `path "pki*" { capabilities = [ "create", "read", "update", "delete", "list", "sudo" ] }` + + secretReadPolicyTemplate = `path "secret/data/keda" { + capabilities = ["read"] +} +path "secret/metadata/keda" { + capabilities = ["read", "list"] +}` ) func TestPkiSecretsEngine(t *testing.T) { @@ -432,7 +444,7 @@ func TestPkiSecretsEngine(t *testing.T) { // Create kubernetes resources kc := GetKubernetesClient(t) useKubernetesAuth := test.authentication == "kubernetes" - hashiCorpToken, promPkiData := setupHashiCorpVault(t, kc, 2, useKubernetesAuth, true) + hashiCorpToken, promPkiData := setupHashiCorpVault(t, kc, 2, useKubernetesAuth, true, false) prometheus.Install(t, kc, prometheusServerName, testNamespace, promPkiData) // Create kubernetes resources for testing @@ -460,16 +472,29 @@ func TestSecretsEngine(t *testing.T) { name string vaultEngineVersion uint vaultSecretPath string + useKubernetesAuth bool + useDelegatesSAAuth bool }{ { name: "vault kv engine v1", vaultEngineVersion: 1, vaultSecretPath: "secret/keda", + useKubernetesAuth: false, + useDelegatesSAAuth: false, + }, + { + name: "vault kv engine v2", + vaultEngineVersion: 2, + vaultSecretPath: "secret/data/keda", + useKubernetesAuth: false, + useDelegatesSAAuth: false, }, { name: "vault kv engine v2", vaultEngineVersion: 2, vaultSecretPath: "secret/data/keda", + useKubernetesAuth: true, + useDelegatesSAAuth: true, }, } @@ -480,7 +505,7 @@ func TestSecretsEngine(t *testing.T) { data, postgreSQLtemplates := getPostgreSQLTemplateData() CreateKubernetesResources(t, kc, testNamespace, data, postgreSQLtemplates) - hashiCorpToken, _ := setupHashiCorpVault(t, kc, test.vaultEngineVersion, false, false) + hashiCorpToken, _ := setupHashiCorpVault(t, kc, test.vaultEngineVersion, test.useKubernetesAuth, false, test.useDelegatesSAAuth) assert.True(t, WaitForStatefulsetReplicaReadyCount(t, kc, postgreSQLStatefulSetName, testNamespace, 1, 60, 3), "replica count should be %d after 3 minutes", 1) @@ -493,8 +518,19 @@ func TestSecretsEngine(t *testing.T) { // Create kubernetes resources for testing data, templates := getTemplateData() - data.HashiCorpToken = RemoveANSI(hashiCorpToken) data.VaultSecretPath = test.vaultSecretPath + data.VaultRole = "keda" + if test.useKubernetesAuth { + data.HashiCorpAuthentication = "kubernetes" + } else { + data.HashiCorpAuthentication = "token" + data.HashiCorpToken = RemoveANSI(hashiCorpToken) + } + + if test.useDelegatesSAAuth { + data.VaultRole = "vault-delegated-sa" + data.VaultServiceAccountName = "default" + } KubectlApplyMultipleWithTemplate(t, data, templates) assert.True(t, WaitForDeploymentReplicaReadyCount(t, kc, deploymentName, testNamespace, minReplicaCount, 60, 3), @@ -548,7 +584,7 @@ func setupHashiCorpVaultPki(t *testing.T, podName string, nameSpace string) *pro return &pkiData } -func setupHashiCorpVault(t *testing.T, kc *kubernetes.Clientset, kvVersion uint, useKubernetesAuth, pki bool) (string, *prometheus.VaultPkiData) { +func setupHashiCorpVault(t *testing.T, kc *kubernetes.Clientset, kvVersion uint, useKubernetesAuth, pki, delegatedAuth bool) (string, *prometheus.VaultPkiData) { CreateNamespace(t, kc, vaultNamespace) _, err := ExecuteCommand("helm repo add hashicorp https://helm.releases.hashicorp.com") @@ -572,7 +608,7 @@ func setupHashiCorpVault(t *testing.T, kc *kubernetes.Clientset, kvVersion uint, // Enable Kubernetes auth if useKubernetesAuth { if pki { - remoteFile := "/tmp/policy.hcl" + remoteFile := "/tmp/pki_policy.hcl" KubectlCopyToPod(t, pkiPolicyTemplate, remoteFile, podName, vaultNamespace) assert.NoErrorf(t, err, "cannot create policy file in hashicorp vault - %s", err) _, _, err = ExecCommandOnSpecificPod(t, podName, vaultNamespace, fmt.Sprintf("vault policy write pkiPolicy %s", remoteFile)) @@ -584,7 +620,18 @@ func setupHashiCorpVault(t *testing.T, kc *kubernetes.Clientset, kvVersion uint, assert.NoErrorf(t, err, "cannot set kubernetes host in hashicorp vault - %s", err) _, _, err = ExecCommandOnSpecificPod(t, podName, vaultNamespace, "vault write auth/kubernetes/role/keda bound_service_account_names=keda-operator bound_service_account_namespaces=keda policies=pkiPolicy ttl=1h") assert.NoErrorf(t, err, "cannot cerate keda role in hashicorp vault - %s", err) + if delegatedAuth { + remoteFile := "/tmp/secret_read_policy.hcl" + KubectlCopyToPod(t, secretReadPolicyTemplate, remoteFile, podName, vaultNamespace) + assert.NoErrorf(t, err, "cannot create policy file in hashicorp vault - %s", err) + _, _, err = ExecCommandOnSpecificPod(t, podName, vaultNamespace, fmt.Sprintf("vault policy write secretReadPolicy %s", remoteFile)) + assert.NoErrorf(t, err, "cannot create policy in hashicorp vault - %s", err) + + _, _, err = ExecCommandOnSpecificPod(t, podName, vaultNamespace, fmt.Sprintf("vault write auth/kubernetes/role/vault-delegated-sa bound_service_account_names=default bound_service_account_namespaces=%s policies=secretReadPolicy ttl=1h", testNamespace)) + assert.NoErrorf(t, err, "cannot cerate keda role in hashicorp vault - %s", err) + } } + // Create kv secret if !pki { _, _, err = ExecCommandOnSpecificPod(t, podName, vaultNamespace, fmt.Sprintf("vault kv put secret/keda connectionString=%s", postgreSQLConnectionString))