Browse Source

feat: polish kubernetes v2 provider path

Signed-off-by: Moritz Johner <beller.moritz@googlemail.com>
Moritz Johner 2 tháng trước cách đây
mục cha
commit
51f1c7ed3f

+ 9 - 3
e2e/Makefile

@@ -7,9 +7,11 @@ DOCKER_BUILD_ARGS     ?=
 
 export E2E_IMAGE_NAME ?= ghcr.io/external-secrets/external-secrets-e2e
 export GINKGO_LABELS ?= !managed && !v2
+export V2_GINKGO_LABELS ?= v2
 export TEST_SUITES ?= provider generator flux argocd
 
 export OCI_IMAGE_NAME = ghcr.io/external-secrets/external-secrets
+export IMAGE_NAME ?= $(OCI_IMAGE_NAME)
 
 start-kind: ## Start kind cluster
 	kind create cluster \
@@ -39,12 +41,16 @@ test: e2e-image ## Run e2e tests against current kube context
 	./run.sh
 
 test.v2: e2e-image ## Run v2 e2e tests against current kube context
-	$(MAKE) -C ../ docker.build \
+	$(MAKE) -C ../ docker.build.controller.e2e \
 		IMAGE_NAME=$(IMAGE_NAME) \
 		VERSION=$(VERSION) \
 		ARCH=amd64 \
 		DOCKER_BUILD_ARGS="${DOCKER_BUILD_ARGS} --build-arg TARGETARCH=amd64 --build-arg TARGETOS=linux"
-	$(MAKE) -C ../ docker.build \
+	$(MAKE) -C ../ docker.build.provider.kubernetes \
+		VERSION=$(VERSION) \
+		ARCH=amd64 \
+		DOCKER_BUILD_ARGS="${DOCKER_BUILD_ARGS} --build-arg TARGETARCH=amd64 --build-arg TARGETOS=linux"
+	$(MAKE) -C ../ docker.build.controller.e2e \
 		IMAGE_NAME=$(OCI_IMAGE_NAME) \
 		VERSION=$(VERSION) \
 		ARCH=amd64 \
@@ -53,7 +59,7 @@ test.v2: e2e-image ## Run v2 e2e tests against current kube context
 	kind load docker-image --name="external-secrets" $(OCI_IMAGE_NAME):$(VERSION)
 	kind load docker-image --name="external-secrets" $(E2E_IMAGE_NAME):$(VERSION)
 	kind load docker-image --name="external-secrets" ghcr.io/external-secrets/provider-kubernetes:$(VERSION)
-	GINKGO_LABELS="v2" E2E_PROVIDER_MODE="v2" TEST_SUITES="provider" ./run.sh
+	GINKGO_LABELS="$(V2_GINKGO_LABELS)" E2E_PROVIDER_MODE="v2" TEST_SUITES="provider" ./run.sh
 
 test.managed: e2e-image ## Run e2e tests against current kube context
 	$(MAKE) -C ../ docker.build \

+ 14 - 5
e2e/framework/v2/helpers.go

@@ -27,6 +27,7 @@ import (
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/wait"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
 	"github.com/external-secrets/external-secrets-e2e/framework"
@@ -48,12 +49,20 @@ func ProviderAddress(providerName string) string {
 func GetClusterCABundle(f *framework.Framework, namespace string) []byte {
 	var caBundle []byte
 	krc := &corev1.ConfigMap{}
-	err := f.CRClient.Get(context.Background(),
-		types.NamespacedName{Name: "kube-root-ca.crt", Namespace: namespace},
-		krc)
-	if err == nil {
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancel()
+
+	err := wait.PollUntilContextTimeout(ctx, 250*time.Millisecond, 30*time.Second, true, func(ctx context.Context) (bool, error) {
+		if err := f.CRClient.Get(ctx, types.NamespacedName{Name: "kube-root-ca.crt", Namespace: namespace}, krc); err != nil {
+			if apierrors.IsNotFound(err) {
+				return false, nil
+			}
+			return false, err
+		}
 		caBundle = []byte(krc.Data["ca.crt"])
-	}
+		return len(caBundle) > 0, nil
+	})
+	Expect(err).NotTo(HaveOccurred())
 	return caBundle
 }
 

+ 77 - 0
e2e/framework/v2/helpers_test.go

@@ -0,0 +1,77 @@
+package v2
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	. "github.com/onsi/gomega"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
+	k8sv2alpha1 "github.com/external-secrets/external-secrets/apis/provider/kubernetes/v2alpha1"
+)
+
+func TestCreateKubernetesProviderUsesProvidedCABundle(t *testing.T) {
+	t.Helper()
+	RegisterTestingT(t)
+
+	scheme := runtime.NewScheme()
+	Expect(corev1.AddToScheme(scheme)).To(Succeed())
+	Expect(k8sv2alpha1.AddToScheme(scheme)).To(Succeed())
+
+	cl := fake.NewClientBuilder().WithScheme(scheme).Build()
+	f := &framework.Framework{
+		CRClient: cl,
+	}
+
+	CreateKubernetesProvider(f, "provider-ns", "example", "remote-ns", "eso-auth", nil, []byte("inline-ca"))
+
+	var provider k8sv2alpha1.Kubernetes
+	err := cl.Get(context.Background(), client.ObjectKey{
+		Namespace: "provider-ns",
+		Name:      "example",
+	}, &provider)
+	Expect(err).NotTo(HaveOccurred())
+
+	Expect(provider.Spec.Server.CABundle).To(Equal([]byte("inline-ca")))
+	Expect(provider.Spec.Server.CAProvider).To(BeNil())
+	Expect(provider.Spec.Auth).NotTo(BeNil())
+	Expect(provider.Spec.Auth.ServiceAccount).To(Equal(&esmeta.ServiceAccountSelector{
+		Name:      "eso-auth",
+		Namespace: nil,
+	}))
+}
+
+func TestGetClusterCABundleWaitsForRootCAConfigMap(t *testing.T) {
+	t.Helper()
+	RegisterTestingT(t)
+
+	scheme := runtime.NewScheme()
+	Expect(corev1.AddToScheme(scheme)).To(Succeed())
+
+	cl := fake.NewClientBuilder().WithScheme(scheme).Build()
+	f := &framework.Framework{
+		CRClient: cl,
+	}
+
+	go func() {
+		time.Sleep(25 * time.Millisecond)
+		Expect(cl.Create(context.Background(), &corev1.ConfigMap{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "kube-root-ca.crt",
+				Namespace: "test",
+			},
+			Data: map[string]string{
+				"ca.crt": "root-ca-data",
+			},
+		})).To(Succeed())
+	}()
+
+	Expect(GetClusterCABundle(f, "test")).To(Equal([]byte("root-ca-data")))
+}

+ 0 - 0
e2e/suites/provider/cases/kubernetes/capabilities_v2_test.go → e2e/suites/provider/cases/kubernetes/capabilities_v2.go


+ 346 - 0
e2e/suites/provider/cases/kubernetes/clusterprovider_v2.go

@@ -0,0 +1,346 @@
+/*
+Copyright © 2026 ESO Maintainer Team
+
+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
+
+    https://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 kubernetes
+
+import (
+	"context"
+	"fmt"
+	"strings"
+	"time"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	corev1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/wait"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	frameworkv2 "github.com/external-secrets/external-secrets-e2e/framework/v2"
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+)
+
+var _ = Describe("[kubernetes] v2 cluster provider", Label("kubernetes", "v2", "cluster-provider"), func() {
+	f := framework.New("eso-kubernetes-v2-clusterprovider")
+
+	BeforeEach(func() {
+		if !framework.IsV2ProviderMode() {
+			Skip("v2 mode only")
+		}
+	})
+
+	It("uses the manifest namespace for auth when authenticationScope=ManifestNamespace", func() {
+		s := newClusterProviderV2Scenario(f, "manifest")
+		s.allowRemoteAccessFrom(s.workloadNamespace, "manifest")
+
+		remoteSecretName := fmt.Sprintf("%s-source", s.namePrefix)
+		targetSecretName := fmt.Sprintf("%s-target", s.namePrefix)
+		s.createRemoteSecret(remoteSecretName, "manifest-value")
+
+		clusterProviderName := s.createClusterProvider("manifest", esv1.AuthenticationScopeManifestNamespace, nil)
+		frameworkv2.WaitForClusterProviderReady(f, clusterProviderName, defaultV2WaitTimeout)
+
+		externalSecretName := s.createExternalSecret(clusterProviderName, targetSecretName, remoteSecretName)
+		s.waitForExternalSecretValue(externalSecretName, targetSecretName, "manifest-value")
+	})
+
+	It("uses the providerRef namespace for auth when authenticationScope=ProviderNamespace", func() {
+		s := newClusterProviderV2Scenario(f, "provider")
+		s.allowRemoteAccessFrom(s.providerNamespace, "provider")
+
+		remoteSecretName := fmt.Sprintf("%s-source", s.namePrefix)
+		targetSecretName := fmt.Sprintf("%s-target", s.namePrefix)
+		s.createRemoteSecret(remoteSecretName, "provider-value")
+
+		clusterProviderName := s.createClusterProvider("provider", esv1.AuthenticationScopeProviderNamespace, nil)
+		frameworkv2.WaitForClusterProviderReady(f, clusterProviderName, defaultV2WaitTimeout)
+
+		externalSecretName := s.createExternalSecret(clusterProviderName, targetSecretName, remoteSecretName)
+		s.waitForExternalSecretValue(externalSecretName, targetSecretName, "provider-value")
+	})
+
+	It("denies workload namespaces that do not match ClusterProvider conditions", func() {
+		s := newClusterProviderV2Scenario(f, "deny")
+		s.allowRemoteAccessFrom(s.workloadNamespace, "deny")
+
+		remoteSecretName := fmt.Sprintf("%s-source", s.namePrefix)
+		targetSecretName := fmt.Sprintf("%s-target", s.namePrefix)
+		s.createRemoteSecret(remoteSecretName, "should-not-sync")
+
+		clusterProviderName := s.createClusterProvider("deny", esv1.AuthenticationScopeManifestNamespace, []esv1.ClusterSecretStoreCondition{{
+			Namespaces: []string{"not-" + s.workloadNamespace},
+		}})
+		frameworkv2.WaitForClusterProviderReady(f, clusterProviderName, defaultV2WaitTimeout)
+
+		externalSecretName := s.createExternalSecret(clusterProviderName, targetSecretName, remoteSecretName)
+		s.waitForExternalSecretFailure(externalSecretName)
+		s.expectNoTargetSecret(targetSecretName)
+		s.expectExternalSecretEvent(externalSecretName, fmt.Sprintf("using ClusterProvider %q is not allowed from namespace %q: denied by spec.conditions", clusterProviderName, s.workloadNamespace))
+	})
+})
+
+type clusterProviderV2Scenario struct {
+	f                 *framework.Framework
+	namePrefix        string
+	workloadNamespace string
+	providerNamespace string
+	remoteNamespace   string
+	serviceAccount    string
+	caBundle          []byte
+}
+
+func newClusterProviderV2Scenario(f *framework.Framework, prefix string) *clusterProviderV2Scenario {
+	s := &clusterProviderV2Scenario{
+		f:                 f,
+		namePrefix:        fmt.Sprintf("%s-%s", f.Namespace.Name, prefix),
+		workloadNamespace: f.Namespace.Name,
+		serviceAccount:    "eso-auth",
+		caBundle:          frameworkv2.GetClusterCABundle(f, f.Namespace.Name),
+	}
+
+	s.providerNamespace = createE2ENamespace(f, prefix+"-provider")
+	s.remoteNamespace = createE2ENamespace(f, prefix+"-remote")
+
+	s.createServiceAccount(s.workloadNamespace)
+	s.createServiceAccount(s.providerNamespace)
+
+	return s
+}
+
+func (s *clusterProviderV2Scenario) createServiceAccount(namespace string) {
+	Expect(s.f.CRClient.Create(context.Background(), &corev1.ServiceAccount{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      s.serviceAccount,
+			Namespace: namespace,
+		},
+	})).To(Succeed())
+}
+
+func (s *clusterProviderV2Scenario) allowRemoteAccessFrom(serviceAccountNamespace, suffix string) {
+	frameworkv2.CreateKubernetesAccessRole(
+		s.f,
+		fmt.Sprintf("%s-access-%s", s.namePrefix, suffix),
+		s.serviceAccount,
+		serviceAccountNamespace,
+		s.remoteNamespace,
+	)
+}
+
+func (s *clusterProviderV2Scenario) createClusterProvider(suffix string, authScope esv1.AuthenticationScope, conditions []esv1.ClusterSecretStoreCondition) string {
+	providerConfigName := fmt.Sprintf("%s-config-%s", s.namePrefix, suffix)
+	frameworkv2.CreateKubernetesProvider(
+		s.f,
+		s.providerNamespace,
+		providerConfigName,
+		s.remoteNamespace,
+		s.serviceAccount,
+		nil,
+		s.caBundle,
+	)
+
+	clusterProviderName := fmt.Sprintf("%s-cluster-provider-%s", s.namePrefix, suffix)
+	frameworkv2.CreateClusterProviderConnection(
+		s.f,
+		clusterProviderName,
+		frameworkv2.ProviderAddress("kubernetes"),
+		kubernetesProviderAPIVersion,
+		"Kubernetes",
+		providerConfigName,
+		s.providerNamespace,
+		authScope,
+		conditions,
+	)
+	return clusterProviderName
+}
+
+func (s *clusterProviderV2Scenario) createRemoteSecret(name, value string) {
+	Expect(s.f.CRClient.Create(context.Background(), &corev1.Secret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      name,
+			Namespace: s.remoteNamespace,
+		},
+		Data: map[string][]byte{
+			"value": []byte(value),
+		},
+	})).To(Succeed())
+}
+
+func (s *clusterProviderV2Scenario) createExternalSecret(clusterProviderName, targetSecretName, remoteSecretName string) string {
+	externalSecretName := fmt.Sprintf("%s-external-secret", s.namePrefix)
+	Expect(s.f.CRClient.Create(context.Background(), &esv1.ExternalSecret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      externalSecretName,
+			Namespace: s.workloadNamespace,
+		},
+		Spec: esv1.ExternalSecretSpec{
+			RefreshInterval: &metav1.Duration{Duration: defaultV2RefreshInterval},
+			SecretStoreRef: esv1.SecretStoreRef{
+				Name: clusterProviderName,
+				Kind: esv1.ClusterProviderKindStr,
+			},
+			Target: esv1.ExternalSecretTarget{
+				Name: targetSecretName,
+			},
+			Data: []esv1.ExternalSecretData{{
+				SecretKey: "value",
+				RemoteRef: esv1.ExternalSecretDataRemoteRef{
+					Key:      remoteSecretName,
+					Property: "value",
+				},
+			}},
+		},
+	})).To(Succeed())
+
+	DeferCleanup(func() {
+		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+		defer cancel()
+
+		err := deleteExternalSecretAndWait(ctx, s.f.CRClient, types.NamespacedName{
+			Name:      externalSecretName,
+			Namespace: s.workloadNamespace,
+		})
+		Expect(err).NotTo(HaveOccurred())
+	})
+
+	return externalSecretName
+}
+
+func (s *clusterProviderV2Scenario) waitForExternalSecretValue(externalSecretName, targetSecretName, expectedValue string) {
+	Eventually(func(g Gomega) {
+		var externalSecret esv1.ExternalSecret
+		g.Expect(s.f.CRClient.Get(context.Background(), types.NamespacedName{
+			Name:      externalSecretName,
+			Namespace: s.workloadNamespace,
+		}, &externalSecret)).To(Succeed())
+		condition := esv1.GetExternalSecretCondition(externalSecret.Status, esv1.ExternalSecretReady)
+		g.Expect(condition).NotTo(BeNil())
+		g.Expect(externalSecretConditionHasStatus(condition, corev1.ConditionTrue)).To(BeTrue())
+
+		var syncedSecret corev1.Secret
+		g.Expect(s.f.CRClient.Get(context.Background(), types.NamespacedName{
+			Name:      targetSecretName,
+			Namespace: s.workloadNamespace,
+		}, &syncedSecret)).To(Succeed())
+		g.Expect(syncedSecret.Data["value"]).To(Equal([]byte(expectedValue)))
+	}, defaultV2WaitTimeout, defaultV2PollInterval).Should(Succeed())
+}
+
+func (s *clusterProviderV2Scenario) waitForExternalSecretFailure(externalSecretName string) {
+	Eventually(func(g Gomega) {
+		var externalSecret esv1.ExternalSecret
+		g.Expect(s.f.CRClient.Get(context.Background(), types.NamespacedName{
+			Name:      externalSecretName,
+			Namespace: s.workloadNamespace,
+		}, &externalSecret)).To(Succeed())
+		condition := esv1.GetExternalSecretCondition(externalSecret.Status, esv1.ExternalSecretReady)
+		g.Expect(condition).NotTo(BeNil())
+		g.Expect(externalSecretConditionHasStatus(condition, corev1.ConditionFalse)).To(BeTrue())
+		g.Expect(condition.Reason).To(Equal(esv1.ConditionReasonSecretSyncedError))
+	}, defaultV2WaitTimeout, defaultV2PollInterval).Should(Succeed())
+}
+
+func externalSecretConditionHasStatus(condition *esv1.ExternalSecretStatusCondition, want corev1.ConditionStatus) bool {
+	return condition != nil && condition.Status == want
+}
+
+func deleteExternalSecretAndWait(ctx context.Context, kubeClient client.Client, key types.NamespacedName) error {
+	externalSecret := &esv1.ExternalSecret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      key.Name,
+			Namespace: key.Namespace,
+		},
+	}
+
+	err := kubeClient.Delete(ctx, externalSecret)
+	if err != nil && !apierrors.IsNotFound(err) {
+		return err
+	}
+
+	return wait.PollUntilContextTimeout(ctx, defaultV2PollInterval, 2*time.Minute, true, func(ctx context.Context) (bool, error) {
+		var existing esv1.ExternalSecret
+		err := kubeClient.Get(ctx, key, &existing)
+		if apierrors.IsNotFound(err) {
+			return true, nil
+		}
+		if err != nil {
+			return false, err
+		}
+		return false, nil
+	})
+}
+
+func (s *clusterProviderV2Scenario) expectNoTargetSecret(targetSecretName string) {
+	Consistently(func() bool {
+		var syncedSecret corev1.Secret
+		err := s.f.CRClient.Get(context.Background(), types.NamespacedName{
+			Name:      targetSecretName,
+			Namespace: s.workloadNamespace,
+		}, &syncedSecret)
+		return apierrors.IsNotFound(err)
+	}, 10*time.Second, defaultV2PollInterval).Should(BeTrue())
+}
+
+func (s *clusterProviderV2Scenario) expectExternalSecretEvent(externalSecretName, expectedMessage string) {
+	Eventually(func() string {
+		events, err := s.f.KubeClientSet.CoreV1().Events(s.workloadNamespace).List(context.Background(), metav1.ListOptions{
+			FieldSelector: "involvedObject.name=" + externalSecretName + ",involvedObject.kind=ExternalSecret",
+		})
+		Expect(err).NotTo(HaveOccurred())
+		messages := make([]string, 0, len(events.Items))
+		for _, event := range events.Items {
+			if event.Message != "" {
+				messages = append(messages, event.Message)
+			}
+		}
+		return strings.Join(messages, "\n")
+	}, defaultV2WaitTimeout, defaultV2PollInterval).Should(ContainSubstring(expectedMessage))
+}
+
+func createE2ENamespace(f *framework.Framework, prefix string) string {
+	namespace := &corev1.Namespace{
+		ObjectMeta: metav1.ObjectMeta{
+			GenerateName: fmt.Sprintf("e2e-tests-%s-", prefix),
+		},
+	}
+	Expect(f.CRClient.Create(context.Background(), namespace)).To(Succeed())
+
+	DeferCleanup(func() {
+		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+		defer cancel()
+
+		err := f.CRClient.Delete(ctx, namespace)
+		if err != nil && !apierrors.IsNotFound(err) {
+			Expect(err).ToNot(HaveOccurred())
+		}
+
+		err = wait.PollUntilContextTimeout(ctx, defaultV2PollInterval, 2*time.Minute, true, func(ctx context.Context) (bool, error) {
+			_, err := f.KubeClientSet.CoreV1().Namespaces().Get(ctx, namespace.Name, metav1.GetOptions{})
+			if apierrors.IsNotFound(err) {
+				return true, nil
+			}
+			if err != nil {
+				return false, err
+			}
+			return false, nil
+		})
+		Expect(err).To(Succeed())
+	})
+
+	return namespace.Name
+}

+ 81 - 0
e2e/suites/provider/cases/kubernetes/clusterprovider_v2_test.go

@@ -0,0 +1,81 @@
+package kubernetes
+
+import (
+	"context"
+	"testing"
+
+	. "github.com/onsi/gomega"
+	corev1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/types"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+)
+
+func TestExternalSecretConditionHasStatus(t *testing.T) {
+	t.Helper()
+	RegisterTestingT(t)
+
+	condition := &esv1.ExternalSecretStatusCondition{Status: corev1.ConditionTrue}
+
+	if !externalSecretConditionHasStatus(condition, corev1.ConditionTrue) {
+		t.Fatalf("expected helper to match ExternalSecret corev1 condition status")
+	}
+
+	if externalSecretConditionHasStatus(condition, corev1.ConditionFalse) {
+		t.Fatalf("expected helper not to match a different ExternalSecret corev1 condition status")
+	}
+}
+
+func TestDeleteExternalSecretAndWaitDeletesExternalSecret(t *testing.T) {
+	t.Helper()
+	RegisterTestingT(t)
+
+	cl := newClusterProviderScenarioTestClient(t, &esv1.ExternalSecret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "example",
+			Namespace: "test",
+		},
+	})
+
+	err := deleteExternalSecretAndWait(context.Background(), cl, types.NamespacedName{
+		Name:      "example",
+		Namespace: "test",
+	})
+	Expect(err).NotTo(HaveOccurred())
+
+	var externalSecret esv1.ExternalSecret
+	err = cl.Get(context.Background(), client.ObjectKey{Name: "example", Namespace: "test"}, &externalSecret)
+	Expect(apierrors.IsNotFound(err)).To(BeTrue())
+}
+
+func TestDeleteExternalSecretAndWaitIgnoresMissingExternalSecret(t *testing.T) {
+	t.Helper()
+	RegisterTestingT(t)
+
+	cl := newClusterProviderScenarioTestClient(t)
+
+	err := deleteExternalSecretAndWait(context.Background(), cl, types.NamespacedName{
+		Name:      "missing",
+		Namespace: "test",
+	})
+	Expect(err).NotTo(HaveOccurred())
+}
+
+func newClusterProviderScenarioTestClient(t *testing.T, objs ...client.Object) client.Client {
+	t.Helper()
+
+	scheme := runtime.NewScheme()
+	Expect(corev1.AddToScheme(scheme)).To(Succeed())
+	Expect(esv1.AddToScheme(scheme)).To(Succeed())
+
+	builder := fake.NewClientBuilder().WithScheme(scheme)
+	if len(objs) > 0 {
+		builder = builder.WithObjects(objs...)
+	}
+	return builder.Build()
+}

+ 0 - 0
e2e/suites/provider/cases/kubernetes/metrics_v2_test.go → e2e/suites/provider/cases/kubernetes/metrics_v2.go


+ 83 - 0
e2e/suites/provider/cases/kubernetes/provider_v2.go

@@ -0,0 +1,83 @@
+/*
+Copyright © 2026 ESO Maintainer Team
+
+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
+
+    https://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 kubernetes
+
+import (
+	"context"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	frameworkv2 "github.com/external-secrets/external-secrets-e2e/framework/v2"
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+)
+
+var _ = Describe("[kubernetes] v2 namespaced provider", Label("kubernetes", "v2", "namespaced-provider"), func() {
+	f := framework.New("eso-kubernetes-v2-provider")
+	prov := NewProvider(f)
+
+	BeforeEach(func() {
+		if !framework.IsV2ProviderMode() {
+			Skip("v2 mode only")
+		}
+		frameworkv2.WaitForProviderConnectionReady(f, f.Namespace.Name, f.Namespace.Name, defaultV2WaitTimeout)
+	})
+
+	It("syncs an ExternalSecret through a namespaced Provider", func() {
+		prov.CreateSecret("provider-v2-remote", framework.SecretEntry{
+			Value: `{"value":"provider-v2-value"}`,
+		})
+
+		externalSecret := &esv1.ExternalSecret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "provider-v2-es",
+				Namespace: f.Namespace.Name,
+			},
+			Spec: esv1.ExternalSecretSpec{
+				RefreshInterval: &metav1.Duration{Duration: defaultV2RefreshInterval},
+				SecretStoreRef: esv1.SecretStoreRef{
+					Name: f.Namespace.Name,
+					Kind: esv1.ProviderKindStr,
+				},
+				Target: esv1.ExternalSecretTarget{
+					Name: "provider-v2-target",
+				},
+				Data: []esv1.ExternalSecretData{{
+					SecretKey: "value",
+					RemoteRef: esv1.ExternalSecretDataRemoteRef{
+						Key:      "provider-v2-remote",
+						Property: "value",
+					},
+				}},
+			},
+		}
+		Expect(f.CRClient.Create(context.Background(), externalSecret)).To(Succeed())
+
+		expected := &corev1.Secret{
+			Type: corev1.SecretTypeOpaque,
+			Data: map[string][]byte{
+				"value": []byte("provider-v2-value"),
+			},
+		}
+
+		_, err := f.WaitForSecretValue(f.Namespace.Name, "provider-v2-target", expected)
+		Expect(err).NotTo(HaveOccurred())
+	})
+})

+ 0 - 0
e2e/suites/provider/cases/kubernetes/push_v2_test.go → e2e/suites/provider/cases/kubernetes/push_v2.go


+ 0 - 0
e2e/suites/provider/cases/kubernetes/v2_constants_test.go → e2e/suites/provider/cases/kubernetes/v2_constants.go


+ 15 - 3
pkg/controllers/clusterprovider/controller.go

@@ -70,6 +70,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 	if err != nil {
 		log.Error(err, "validation failed")
 		r.setNotReadyCondition(&store, "ValidationFailed", err.Error())
+		store.Status.Capabilities = ""
 		if updateErr := r.Status().Update(ctx, &store); updateErr != nil {
 			log.Error(updateErr, "failed to update status")
 			return ctrl.Result{}, updateErr
@@ -101,7 +102,12 @@ func (r *Reconciler) validateStoreAndGetCapabilities(ctx context.Context, store
 		return "", fmt.Errorf("provider address is required")
 	}
 
-	tlsSecretNamespace := grpc.NamespaceFromAddress(store.Spec.Config.Address, store.Spec.Config.ProviderRef.Namespace)
+	tlsSecretNamespace := grpc.ResolveTLSSecretNamespace(
+		store.Spec.Config.Address,
+		"",
+		"",
+		store.Spec.Config.ProviderRef.Namespace,
+	)
 
 	// Load TLS configuration
 	tlsConfig, err := grpc.LoadClientTLSConfig(ctx, r.Client, store.Spec.Config.Address, tlsSecretNamespace)
@@ -183,8 +189,14 @@ func (r *Reconciler) setCondition(store *esv1.ClusterProvider, newCondition esv1
 	// Find existing condition
 	for i, condition := range store.Status.Conditions {
 		if condition.Type == newCondition.Type {
-			// Only update if status changed
-			if condition.Status != newCondition.Status {
+			// Preserve LastTransitionTime unless the condition status actually changes.
+			if condition.Status == newCondition.Status {
+				newCondition.LastTransitionTime = condition.LastTransitionTime
+			}
+
+			if condition.Status != newCondition.Status ||
+				condition.Reason != newCondition.Reason ||
+				condition.Message != newCondition.Message {
 				store.Status.Conditions[i] = newCondition
 			}
 			// Update metrics

+ 514 - 0
pkg/controllers/clusterprovider/controller_test.go

@@ -0,0 +1,514 @@
+/*
+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 clusterprovider
+
+import (
+	"context"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/tls"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/pem"
+	"math/big"
+	"net"
+	"testing"
+	"time"
+
+	"github.com/go-logr/logr"
+	"github.com/prometheus/client_golang/prometheus"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+	clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/credentials"
+	"google.golang.org/grpc/status"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	pb "github.com/external-secrets/external-secrets/proto/provider"
+)
+
+type recordingClusterProviderGRPCServer struct {
+	pb.UnimplementedSecretStoreProviderServer
+
+	validateRequest     *pb.ValidateRequest
+	capabilitiesRequest *pb.CapabilitiesRequest
+	capabilitiesResp    *pb.CapabilitiesResponse
+	capabilitiesErr     error
+}
+
+func (s *recordingClusterProviderGRPCServer) Validate(_ context.Context, req *pb.ValidateRequest) (*pb.ValidateResponse, error) {
+	s.validateRequest = req
+	return &pb.ValidateResponse{Valid: true}, nil
+}
+
+func (s *recordingClusterProviderGRPCServer) Capabilities(_ context.Context, req *pb.CapabilitiesRequest) (*pb.CapabilitiesResponse, error) {
+	s.capabilitiesRequest = req
+	if s.capabilitiesErr != nil {
+		return nil, s.capabilitiesErr
+	}
+	if s.capabilitiesResp != nil {
+		return s.capabilitiesResp, nil
+	}
+	return &pb.CapabilitiesResponse{Capabilities: pb.SecretStoreCapabilities_READ_WRITE}, nil
+}
+
+func TestValidateStoreAndGetCapabilitiesUsesCapabilitiesOnly(t *testing.T) {
+	scheme := runtime.NewScheme()
+	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+	utilruntime.Must(esv1.AddToScheme(scheme))
+
+	server, address, tlsSecret := newClusterProviderGRPCServer(t)
+	store := &esv1.ClusterProvider{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "cluster-provider",
+		},
+		Spec: esv1.ClusterProviderSpec{
+			Config: esv1.ProviderConfig{
+				Address: address,
+				ProviderRef: esv1.ProviderReference{
+					APIVersion: "provider.external-secrets.io/v2alpha1",
+					Kind:       "Kubernetes",
+					Name:       "backend",
+					Namespace:  "config-ns",
+				},
+			},
+		},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(store, &corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "external-secrets-provider-tls",
+				Namespace: "config-ns",
+			},
+			Data: tlsSecret,
+		}).
+		Build()
+
+	r := &Reconciler{Client: kubeClient, Log: logr.Discard()}
+
+	caps, err := r.validateStoreAndGetCapabilities(context.Background(), store)
+	if err != nil {
+		t.Fatalf("validateStoreAndGetCapabilities() error = %v", err)
+	}
+	if caps != esv1.ProviderReadWrite {
+		t.Fatalf("expected ProviderReadWrite, got %q", caps)
+	}
+	if server.validateRequest != nil {
+		t.Fatalf("expected Validate not to be called, got %#v", server.validateRequest)
+	}
+	assertClusterProviderReference(t, server.capabilitiesRequest.ProviderRef, store.Spec.Config.ProviderRef)
+	if server.capabilitiesRequest.SourceNamespace != "" {
+		t.Fatalf("expected empty source namespace, got %q", server.capabilitiesRequest.SourceNamespace)
+	}
+}
+
+func TestValidateStoreAndGetCapabilitiesFallsBackToReadOnlyOnCapabilitiesError(t *testing.T) {
+	scheme := runtime.NewScheme()
+	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+	utilruntime.Must(esv1.AddToScheme(scheme))
+
+	server, address, tlsSecret := newClusterProviderGRPCServer(t)
+	server.capabilitiesErr = status.Error(codes.Unavailable, "capabilities unavailable")
+
+	store := &esv1.ClusterProvider{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "cluster-provider",
+		},
+		Spec: esv1.ClusterProviderSpec{
+			Config: esv1.ProviderConfig{
+				Address: address,
+				ProviderRef: esv1.ProviderReference{
+					APIVersion: "provider.external-secrets.io/v2alpha1",
+					Kind:       "Kubernetes",
+					Name:       "backend",
+					Namespace:  "config-ns",
+				},
+			},
+		},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(store, &corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "external-secrets-provider-tls",
+				Namespace: "config-ns",
+			},
+			Data: tlsSecret,
+		}).
+		Build()
+
+	r := &Reconciler{Client: kubeClient, Log: logr.Discard()}
+
+	caps, err := r.validateStoreAndGetCapabilities(context.Background(), store)
+	if err != nil {
+		t.Fatalf("expected fallback to read-only, got error %v", err)
+	}
+	if caps != esv1.ProviderReadOnly {
+		t.Fatalf("expected ProviderReadOnly, got %q", caps)
+	}
+}
+
+func TestReconcileValidationFailureClearsStaleCapabilitiesAndUpdatesCondition(t *testing.T) {
+	previousMetrics := gaugeVecMetrics
+	gaugeVecMetrics = map[string]*prometheus.GaugeVec{
+		ClusterProviderReconcileDurationKey: prometheus.NewGaugeVec(prometheus.GaugeOpts{
+			Subsystem: ClusterProviderSubsystem,
+			Name:      ClusterProviderReconcileDurationKey,
+		}, []string{"name"}),
+		StatusConditionKey: prometheus.NewGaugeVec(prometheus.GaugeOpts{
+			Subsystem: ClusterProviderSubsystem,
+			Name:      StatusConditionKey,
+		}, []string{"name", "condition", "status"}),
+	}
+	t.Cleanup(func() {
+		gaugeVecMetrics = previousMetrics
+	})
+
+	scheme := runtime.NewScheme()
+	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+	utilruntime.Must(esv1.AddToScheme(scheme))
+
+	server, address, tlsSecret := newClusterProviderGRPCServer(t)
+	server.capabilitiesErr = status.Error(codes.InvalidArgument, "invalid configuration")
+
+	store := &esv1.ClusterProvider{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "cluster-provider",
+		},
+		Spec: esv1.ClusterProviderSpec{
+			Config: esv1.ProviderConfig{
+				Address: address,
+				ProviderRef: esv1.ProviderReference{
+					APIVersion: "provider.external-secrets.io/v2alpha1",
+					Kind:       "Kubernetes",
+					Name:       "backend",
+					Namespace:  "config-ns",
+				},
+			},
+		},
+		Status: esv1.ProviderStatus{
+			Capabilities: esv1.ProviderReadWrite,
+		},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(store, &corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "external-secrets-provider-tls",
+				Namespace: "config-ns",
+			},
+			Data: tlsSecret,
+		}).
+		WithStatusSubresource(store).
+		Build()
+
+	r := &Reconciler{
+		Client:          kubeClient,
+		Log:             logr.Discard(),
+		RequeueInterval: 29 * time.Second,
+	}
+
+	result, err := r.Reconcile(context.Background(), ctrl.Request{
+		NamespacedName: client.ObjectKey{Name: "cluster-provider"},
+	})
+	if err != nil {
+		t.Fatalf("Reconcile() error = %v", err)
+	}
+	if result.RequeueAfter != 29*time.Second {
+		t.Fatalf("expected requeue interval, got %#v", result)
+	}
+
+	var updated esv1.ClusterProvider
+	if err := kubeClient.Get(context.Background(), client.ObjectKey{Name: "cluster-provider"}, &updated); err != nil {
+		t.Fatalf("Get() error = %v", err)
+	}
+
+	if updated.Status.Capabilities != esv1.ProviderReadOnly {
+		t.Fatalf("expected fallback read-only capabilities, got %q", updated.Status.Capabilities)
+	}
+	if len(updated.Status.Conditions) != 1 {
+		t.Fatalf("expected a single condition, got %#v", updated.Status.Conditions)
+	}
+
+	condition := updated.Status.Conditions[0]
+	if condition.Type != esv1.ProviderReady || condition.Status != metav1.ConditionTrue {
+		t.Fatalf("unexpected condition: %#v", condition)
+	}
+}
+
+func TestReconcileHardValidationFailureClearsStaleCapabilitiesAndUpdatesCondition(t *testing.T) {
+	previousMetrics := gaugeVecMetrics
+	gaugeVecMetrics = map[string]*prometheus.GaugeVec{
+		ClusterProviderReconcileDurationKey: prometheus.NewGaugeVec(prometheus.GaugeOpts{
+			Subsystem: ClusterProviderSubsystem,
+			Name:      ClusterProviderReconcileDurationKey,
+		}, []string{"name"}),
+		StatusConditionKey: prometheus.NewGaugeVec(prometheus.GaugeOpts{
+			Subsystem: ClusterProviderSubsystem,
+			Name:      StatusConditionKey,
+		}, []string{"name", "condition", "status"}),
+	}
+	t.Cleanup(func() {
+		gaugeVecMetrics = previousMetrics
+	})
+
+	scheme := runtime.NewScheme()
+	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+	utilruntime.Must(esv1.AddToScheme(scheme))
+
+	store := &esv1.ClusterProvider{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "cluster-provider",
+		},
+		Spec: esv1.ClusterProviderSpec{
+			Config: esv1.ProviderConfig{
+				ProviderRef: esv1.ProviderReference{
+					APIVersion: "provider.external-secrets.io/v2alpha1",
+					Kind:       "Kubernetes",
+					Name:       "backend",
+					Namespace:  "config-ns",
+				},
+			},
+		},
+		Status: esv1.ProviderStatus{
+			Capabilities: esv1.ProviderReadWrite,
+		},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(store).
+		WithStatusSubresource(store).
+		Build()
+
+	r := &Reconciler{
+		Client:          kubeClient,
+		Log:             logr.Discard(),
+		RequeueInterval: 31 * time.Second,
+	}
+
+	result, err := r.Reconcile(context.Background(), ctrl.Request{
+		NamespacedName: client.ObjectKey{Name: "cluster-provider"},
+	})
+	if err != nil {
+		t.Fatalf("Reconcile() error = %v", err)
+	}
+	if result.RequeueAfter != 31*time.Second {
+		t.Fatalf("expected requeue interval, got %#v", result)
+	}
+
+	var updated esv1.ClusterProvider
+	if err := kubeClient.Get(context.Background(), client.ObjectKey{Name: "cluster-provider"}, &updated); err != nil {
+		t.Fatalf("Get() error = %v", err)
+	}
+
+	if updated.Status.Capabilities != "" {
+		t.Fatalf("expected capabilities to be cleared, got %q", updated.Status.Capabilities)
+	}
+	if len(updated.Status.Conditions) != 1 {
+		t.Fatalf("expected a single condition, got %#v", updated.Status.Conditions)
+	}
+
+	condition := updated.Status.Conditions[0]
+	if condition.Type != esv1.ProviderReady || condition.Status != metav1.ConditionFalse {
+		t.Fatalf("unexpected condition: %#v", condition)
+	}
+	if condition.Reason != "ValidationFailed" {
+		t.Fatalf("unexpected condition reason: %q", condition.Reason)
+	}
+	if condition.Message != "provider address is required" {
+		t.Fatalf("unexpected condition message: %q", condition.Message)
+	}
+}
+
+func TestSetNotReadyConditionUpdatesReasonAndMessageWithoutChangingTransitionTime(t *testing.T) {
+	previousMetrics := gaugeVecMetrics
+	gaugeVecMetrics = map[string]*prometheus.GaugeVec{
+		StatusConditionKey: prometheus.NewGaugeVec(prometheus.GaugeOpts{
+			Subsystem: ClusterProviderSubsystem,
+			Name:      StatusConditionKey,
+		}, []string{"name", "condition", "status"}),
+	}
+	t.Cleanup(func() {
+		gaugeVecMetrics = previousMetrics
+	})
+
+	previousTransition := metav1.NewTime(time.Unix(1700000000, 0))
+	store := &esv1.ClusterProvider{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "cluster-provider",
+		},
+		Status: esv1.ProviderStatus{
+			Conditions: []esv1.ProviderCondition{{
+				Type:               esv1.ProviderReady,
+				Status:             metav1.ConditionFalse,
+				LastTransitionTime: previousTransition,
+				Reason:             "OldReason",
+				Message:            "old message",
+			}},
+		},
+	}
+
+	r := &Reconciler{Log: logr.Discard()}
+	r.setNotReadyCondition(store, "ValidationFailed", "new message")
+
+	if len(store.Status.Conditions) != 1 {
+		t.Fatalf("expected a single condition, got %#v", store.Status.Conditions)
+	}
+
+	condition := store.Status.Conditions[0]
+	if condition.Status != metav1.ConditionFalse {
+		t.Fatalf("expected false status, got %q", condition.Status)
+	}
+	if condition.Reason != "ValidationFailed" {
+		t.Fatalf("expected updated reason, got %q", condition.Reason)
+	}
+	if condition.Message != "new message" {
+		t.Fatalf("expected updated message, got %q", condition.Message)
+	}
+	if !condition.LastTransitionTime.Equal(&previousTransition) {
+		t.Fatalf("expected transition time to remain %v, got %v", previousTransition, condition.LastTransitionTime)
+	}
+}
+
+func newClusterProviderGRPCServer(t *testing.T) (*recordingClusterProviderGRPCServer, string, map[string][]byte) {
+	t.Helper()
+
+	serverCert, serverKey, clientCert, clientKey, caCert := newClusterProviderTLSArtifacts(t, "127.0.0.1")
+
+	caPool := x509.NewCertPool()
+	if !caPool.AppendCertsFromPEM(caCert) {
+		t.Fatal("failed to append CA cert")
+	}
+	tlsCert, err := tls.X509KeyPair(serverCert, serverKey)
+	if err != nil {
+		t.Fatalf("X509KeyPair() error = %v", err)
+	}
+
+	lis, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatalf("Listen() error = %v", err)
+	}
+
+	server := &recordingClusterProviderGRPCServer{}
+	grpcServer := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{
+		MinVersion:   tls.VersionTLS12,
+		Certificates: []tls.Certificate{tlsCert},
+		ClientCAs:    caPool,
+		ClientAuth:   tls.RequireAndVerifyClientCert,
+	})))
+	pb.RegisterSecretStoreProviderServer(grpcServer, server)
+	go func() {
+		_ = grpcServer.Serve(lis)
+	}()
+
+	t.Cleanup(func() {
+		grpcServer.Stop()
+		_ = lis.Close()
+	})
+
+	return server, lis.Addr().String(), map[string][]byte{
+		"ca.crt":     caCert,
+		"client.crt": clientCert,
+		"client.key": clientKey,
+	}
+}
+
+func assertClusterProviderReference(t *testing.T, got *pb.ProviderReference, want esv1.ProviderReference) {
+	t.Helper()
+
+	if got == nil {
+		t.Fatal("expected provider reference to be set")
+	}
+	if got.ApiVersion != want.APIVersion || got.Kind != want.Kind || got.Name != want.Name || got.Namespace != want.Namespace {
+		t.Fatalf("unexpected provider ref: got=%#v want=%#v", got, want)
+	}
+}
+
+func newClusterProviderTLSArtifacts(t *testing.T, host string) (serverCertPEM, serverKeyPEM, clientCertPEM, clientKeyPEM, caCertPEM []byte) {
+	t.Helper()
+
+	caKey, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		t.Fatalf("GenerateKey() error = %v", err)
+	}
+
+	caTemplate := &x509.Certificate{
+		SerialNumber: big.NewInt(1),
+		Subject: pkix.Name{CommonName: "cluster-provider-controller-test-ca"},
+		NotBefore:             time.Now().Add(-time.Hour),
+		NotAfter:              time.Now().Add(24 * time.Hour),
+		KeyUsage:              x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
+		BasicConstraintsValid: true,
+		IsCA:                  true,
+	}
+
+	caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
+	if err != nil {
+		t.Fatalf("CreateCertificate() error = %v", err)
+	}
+	caCert, err := x509.ParseCertificate(caDER)
+	if err != nil {
+		t.Fatalf("ParseCertificate() error = %v", err)
+	}
+
+	serverCertPEM, serverKeyPEM = newClusterProviderSignedTLSCert(t, caCert, caKey, 2, host, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth})
+	clientCertPEM, clientKeyPEM = newClusterProviderSignedTLSCert(t, caCert, caKey, 3, host, []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth})
+	caCertPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER})
+	return serverCertPEM, serverKeyPEM, clientCertPEM, clientKeyPEM, caCertPEM
+}
+
+func newClusterProviderSignedTLSCert(t *testing.T, caCert *x509.Certificate, caKey *rsa.PrivateKey, serial int64, host string, usages []x509.ExtKeyUsage) ([]byte, []byte) {
+	t.Helper()
+
+	key, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		t.Fatalf("GenerateKey() error = %v", err)
+	}
+
+	template := &x509.Certificate{
+		SerialNumber: big.NewInt(serial),
+		Subject: pkix.Name{CommonName: host},
+		NotBefore:   time.Now().Add(-time.Hour),
+		NotAfter:    time.Now().Add(24 * time.Hour),
+		KeyUsage:    x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
+		ExtKeyUsage: usages,
+	}
+
+	if ip := net.ParseIP(host); ip != nil {
+		template.IPAddresses = []net.IP{ip}
+	} else {
+		template.DNSNames = []string{host}
+	}
+
+	der, err := x509.CreateCertificate(rand.Reader, template, caCert, &key.PublicKey, caKey)
+	if err != nil {
+		t.Fatalf("CreateCertificate() error = %v", err)
+	}
+
+	return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
+		pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
+}

+ 15 - 3
pkg/controllers/provider/controller.go

@@ -70,6 +70,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 	if err != nil {
 		log.Error(err, "validation failed")
 		r.setNotReadyCondition(&store, "ValidationFailed", err.Error())
+		store.Status.Capabilities = ""
 		if updateErr := r.Status().Update(ctx, &store); updateErr != nil {
 			log.Error(updateErr, "failed to update status")
 			return ctrl.Result{}, updateErr
@@ -101,7 +102,12 @@ func (r *Reconciler) validateStoreAndGetCapabilities(ctx context.Context, store
 		return "", fmt.Errorf("provider address is required")
 	}
 
-	tlsSecretNamespace := grpc.NamespaceFromAddress(store.Spec.Config.Address, store.Namespace)
+	tlsSecretNamespace := grpc.ResolveTLSSecretNamespace(
+		store.Spec.Config.Address,
+		"",
+		store.Namespace,
+		store.Spec.Config.ProviderRef.Namespace,
+	)
 
 	// Load TLS configuration
 	tlsConfig, err := grpc.LoadClientTLSConfig(ctx, r.Client, store.Spec.Config.Address, tlsSecretNamespace)
@@ -183,8 +189,14 @@ func (r *Reconciler) setCondition(store *esv1.Provider, newCondition esv1.Provid
 	// Find existing condition
 	for i, condition := range store.Status.Conditions {
 		if condition.Type == newCondition.Type {
-			// Only update if status changed
-			if condition.Status != newCondition.Status {
+			// Preserve LastTransitionTime unless the condition status actually changes.
+			if condition.Status == newCondition.Status {
+				newCondition.LastTransitionTime = condition.LastTransitionTime
+			}
+
+			if condition.Status != newCondition.Status ||
+				condition.Reason != newCondition.Reason ||
+				condition.Message != newCondition.Message {
 				store.Status.Conditions[i] = newCondition
 			}
 			// Update metrics

+ 495 - 0
pkg/controllers/provider/controller_test.go

@@ -0,0 +1,495 @@
+/*
+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 provider
+
+import (
+	"context"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/tls"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/pem"
+	"math/big"
+	"net"
+	"testing"
+	"time"
+
+	"github.com/go-logr/logr"
+	"github.com/prometheus/client_golang/prometheus"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+	clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/credentials"
+	"google.golang.org/grpc/status"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	ctrlmetrics "github.com/external-secrets/external-secrets/pkg/controllers/metrics"
+	pb "github.com/external-secrets/external-secrets/proto/provider"
+)
+
+type recordingProviderGRPCServer struct {
+	pb.UnimplementedSecretStoreProviderServer
+
+	validateRequest     *pb.ValidateRequest
+	validateResponse    *pb.ValidateResponse
+	capabilitiesRequest *pb.CapabilitiesRequest
+	capabilitiesResp    *pb.CapabilitiesResponse
+	capabilitiesErr     error
+}
+
+func (s *recordingProviderGRPCServer) Validate(_ context.Context, req *pb.ValidateRequest) (*pb.ValidateResponse, error) {
+	s.validateRequest = req
+	if s.validateResponse != nil {
+		return s.validateResponse, nil
+	}
+	return &pb.ValidateResponse{Valid: true}, nil
+}
+
+func (s *recordingProviderGRPCServer) Capabilities(_ context.Context, req *pb.CapabilitiesRequest) (*pb.CapabilitiesResponse, error) {
+	s.capabilitiesRequest = req
+	if s.capabilitiesErr != nil {
+		return nil, s.capabilitiesErr
+	}
+	if s.capabilitiesResp != nil {
+		return s.capabilitiesResp, nil
+	}
+	return &pb.CapabilitiesResponse{Capabilities: pb.SecretStoreCapabilities_READ_WRITE}, nil
+}
+
+func TestValidateStoreAndGetCapabilitiesSendsProviderReferenceAndNamespace(t *testing.T) {
+	scheme := runtime.NewScheme()
+	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+	utilruntime.Must(esv1.AddToScheme(scheme))
+
+	server, address, tlsSecret := newProviderGRPCServer(t)
+	store := &esv1.Provider{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "provider",
+			Namespace: "tenant-a",
+		},
+		Spec: esv1.ProviderSpec{
+			Config: esv1.ProviderConfig{
+				Address: address,
+				ProviderRef: esv1.ProviderReference{
+					APIVersion: "provider.external-secrets.io/v2alpha1",
+					Kind:       "Kubernetes",
+					Name:       "backend",
+					Namespace:  "config-ns",
+				},
+			},
+		},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(store, &corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "external-secrets-provider-tls",
+				Namespace: "tenant-a",
+			},
+			Data: tlsSecret,
+		}).
+		Build()
+
+	r := &Reconciler{Client: kubeClient, Log: logr.Discard()}
+
+	caps, err := r.validateStoreAndGetCapabilities(context.Background(), store)
+	if err != nil {
+		t.Fatalf("validateStoreAndGetCapabilities() error = %v", err)
+	}
+	if caps != esv1.ProviderReadWrite {
+		t.Fatalf("expected ProviderReadWrite, got %q", caps)
+	}
+	assertProviderReference(t, server.validateRequest.ProviderRef, store.Spec.Config.ProviderRef)
+	if server.validateRequest.SourceNamespace != "tenant-a" {
+		t.Fatalf("unexpected validate source namespace: %q", server.validateRequest.SourceNamespace)
+	}
+	assertProviderReference(t, server.capabilitiesRequest.ProviderRef, store.Spec.Config.ProviderRef)
+	if server.capabilitiesRequest.SourceNamespace != "tenant-a" {
+		t.Fatalf("unexpected capabilities source namespace: %q", server.capabilitiesRequest.SourceNamespace)
+	}
+}
+
+func TestValidateStoreAndGetCapabilitiesFallsBackToReadOnlyOnCapabilitiesError(t *testing.T) {
+	scheme := runtime.NewScheme()
+	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+	utilruntime.Must(esv1.AddToScheme(scheme))
+
+	server, address, tlsSecret := newProviderGRPCServer(t)
+	server.capabilitiesErr = status.Error(codes.Unavailable, "capabilities unavailable")
+
+	store := &esv1.Provider{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "provider",
+			Namespace: "tenant-a",
+		},
+		Spec: esv1.ProviderSpec{
+			Config: esv1.ProviderConfig{
+				Address: address,
+				ProviderRef: esv1.ProviderReference{
+					APIVersion: "provider.external-secrets.io/v2alpha1",
+					Kind:       "Kubernetes",
+					Name:       "backend",
+				},
+			},
+		},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(store, &corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "external-secrets-provider-tls",
+				Namespace: "tenant-a",
+			},
+			Data: tlsSecret,
+		}).
+		Build()
+
+	r := &Reconciler{Client: kubeClient, Log: logr.Discard()}
+
+	caps, err := r.validateStoreAndGetCapabilities(context.Background(), store)
+	if err != nil {
+		t.Fatalf("expected fallback to read-only, got error %v", err)
+	}
+	if caps != esv1.ProviderReadOnly {
+		t.Fatalf("expected ProviderReadOnly, got %q", caps)
+	}
+}
+
+func TestValidateStoreAndGetCapabilitiesReturnsValidationError(t *testing.T) {
+	scheme := runtime.NewScheme()
+	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+	utilruntime.Must(esv1.AddToScheme(scheme))
+
+	server, address, tlsSecret := newProviderGRPCServer(t)
+	server.validateResponse = &pb.ValidateResponse{
+		Valid: false,
+		Error: "invalid credentials",
+	}
+
+	store := &esv1.Provider{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "provider",
+			Namespace: "tenant-a",
+		},
+		Spec: esv1.ProviderSpec{
+			Config: esv1.ProviderConfig{
+				Address: address,
+				ProviderRef: esv1.ProviderReference{
+					APIVersion: "provider.external-secrets.io/v2alpha1",
+					Kind:       "Kubernetes",
+					Name:       "backend",
+				},
+			},
+		},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(store, &corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "external-secrets-provider-tls",
+				Namespace: "tenant-a",
+			},
+			Data: tlsSecret,
+		}).
+		Build()
+
+	r := &Reconciler{Client: kubeClient, Log: logr.Discard()}
+
+	_, err := r.validateStoreAndGetCapabilities(context.Background(), store)
+	if err == nil || err.Error() != "provider validation failed: provider validation failed: invalid credentials" {
+		t.Fatalf("unexpected error: %v", err)
+	}
+}
+
+func TestReconcileValidationFailureClearsStaleCapabilitiesAndUpdatesCondition(t *testing.T) {
+	ctrlmetrics.SetUpLabelNames(false)
+	previousMetrics := gaugeVecMetrics
+	gaugeVecMetrics = map[string]*prometheus.GaugeVec{
+		ProviderReconcileDurationKey: prometheus.NewGaugeVec(prometheus.GaugeOpts{
+			Subsystem: ProviderSubsystem,
+			Name:      ProviderReconcileDurationKey,
+		}, ctrlmetrics.NonConditionMetricLabelNames),
+		StatusConditionKey: prometheus.NewGaugeVec(prometheus.GaugeOpts{
+			Subsystem: ProviderSubsystem,
+			Name:      StatusConditionKey,
+		}, ctrlmetrics.ConditionMetricLabelNames),
+	}
+	t.Cleanup(func() {
+		gaugeVecMetrics = previousMetrics
+	})
+
+	scheme := runtime.NewScheme()
+	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+	utilruntime.Must(esv1.AddToScheme(scheme))
+
+	server, address, tlsSecret := newProviderGRPCServer(t)
+	server.validateResponse = &pb.ValidateResponse{
+		Valid: false,
+		Error: "invalid credentials",
+	}
+
+	store := &esv1.Provider{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "provider",
+			Namespace: "tenant-a",
+		},
+		Spec: esv1.ProviderSpec{
+			Config: esv1.ProviderConfig{
+				Address: address,
+				ProviderRef: esv1.ProviderReference{
+					APIVersion: "provider.external-secrets.io/v2alpha1",
+					Kind:       "Kubernetes",
+					Name:       "backend",
+				},
+			},
+		},
+		Status: esv1.ProviderStatus{
+			Capabilities: esv1.ProviderReadWrite,
+		},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(store, &corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "external-secrets-provider-tls",
+				Namespace: "tenant-a",
+			},
+			Data: tlsSecret,
+		}).
+		WithStatusSubresource(store).
+		Build()
+
+	r := &Reconciler{
+		Client:          kubeClient,
+		Log:             logr.Discard(),
+		RequeueInterval: 37 * time.Second,
+	}
+
+	result, err := r.Reconcile(context.Background(), ctrl.Request{
+		NamespacedName: client.ObjectKey{Name: "provider", Namespace: "tenant-a"},
+	})
+	if err != nil {
+		t.Fatalf("Reconcile() error = %v", err)
+	}
+	if result.RequeueAfter != 37*time.Second {
+		t.Fatalf("expected requeue interval, got %#v", result)
+	}
+
+	var updated esv1.Provider
+	if err := kubeClient.Get(context.Background(), client.ObjectKey{Name: "provider", Namespace: "tenant-a"}, &updated); err != nil {
+		t.Fatalf("Get() error = %v", err)
+	}
+
+	if updated.Status.Capabilities != "" {
+		t.Fatalf("expected capabilities to be cleared, got %q", updated.Status.Capabilities)
+	}
+	if len(updated.Status.Conditions) != 1 {
+		t.Fatalf("expected a single condition, got %#v", updated.Status.Conditions)
+	}
+
+	condition := updated.Status.Conditions[0]
+	if condition.Type != esv1.ProviderReady || condition.Status != metav1.ConditionFalse {
+		t.Fatalf("unexpected condition: %#v", condition)
+	}
+	if condition.Reason != "ValidationFailed" {
+		t.Fatalf("unexpected condition reason: %q", condition.Reason)
+	}
+	if condition.Message != "provider validation failed: provider validation failed: invalid credentials" {
+		t.Fatalf("unexpected condition message: %q", condition.Message)
+	}
+}
+
+func TestSetNotReadyConditionUpdatesReasonAndMessageWithoutChangingTransitionTime(t *testing.T) {
+	ctrlmetrics.SetUpLabelNames(false)
+	previousMetrics := gaugeVecMetrics
+	gaugeVecMetrics = map[string]*prometheus.GaugeVec{
+		StatusConditionKey: prometheus.NewGaugeVec(prometheus.GaugeOpts{
+			Subsystem: ProviderSubsystem,
+			Name:      StatusConditionKey,
+		}, ctrlmetrics.ConditionMetricLabelNames),
+	}
+	t.Cleanup(func() {
+		gaugeVecMetrics = previousMetrics
+	})
+
+	previousTransition := metav1.NewTime(time.Unix(1700000000, 0))
+	store := &esv1.Provider{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "provider",
+			Namespace: "tenant-a",
+		},
+		Status: esv1.ProviderStatus{
+			Conditions: []esv1.ProviderCondition{{
+				Type:               esv1.ProviderReady,
+				Status:             metav1.ConditionFalse,
+				LastTransitionTime: previousTransition,
+				Reason:             "OldReason",
+				Message:            "old message",
+			}},
+		},
+	}
+
+	r := &Reconciler{Log: logr.Discard()}
+	r.setNotReadyCondition(store, "ValidationFailed", "new message")
+
+	if len(store.Status.Conditions) != 1 {
+		t.Fatalf("expected a single condition, got %#v", store.Status.Conditions)
+	}
+
+	condition := store.Status.Conditions[0]
+	if condition.Status != metav1.ConditionFalse {
+		t.Fatalf("expected false status, got %q", condition.Status)
+	}
+	if condition.Reason != "ValidationFailed" {
+		t.Fatalf("expected updated reason, got %q", condition.Reason)
+	}
+	if condition.Message != "new message" {
+		t.Fatalf("expected updated message, got %q", condition.Message)
+	}
+	if !condition.LastTransitionTime.Equal(&previousTransition) {
+		t.Fatalf("expected transition time to remain %v, got %v", previousTransition, condition.LastTransitionTime)
+	}
+}
+
+func newProviderGRPCServer(t *testing.T) (*recordingProviderGRPCServer, string, map[string][]byte) {
+	t.Helper()
+
+	serverCert, serverKey, clientCert, clientKey, caCert := newProviderTLSArtifacts(t, "127.0.0.1")
+
+	caPool := x509.NewCertPool()
+	if !caPool.AppendCertsFromPEM(caCert) {
+		t.Fatal("failed to append CA cert")
+	}
+	tlsCert, err := tls.X509KeyPair(serverCert, serverKey)
+	if err != nil {
+		t.Fatalf("X509KeyPair() error = %v", err)
+	}
+
+	lis, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatalf("Listen() error = %v", err)
+	}
+
+	server := &recordingProviderGRPCServer{}
+	grpcServer := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{
+		MinVersion:   tls.VersionTLS12,
+		Certificates: []tls.Certificate{tlsCert},
+		ClientCAs:    caPool,
+		ClientAuth:   tls.RequireAndVerifyClientCert,
+	})))
+	pb.RegisterSecretStoreProviderServer(grpcServer, server)
+	go func() {
+		_ = grpcServer.Serve(lis)
+	}()
+
+	t.Cleanup(func() {
+		grpcServer.Stop()
+		_ = lis.Close()
+	})
+
+	return server, lis.Addr().String(), map[string][]byte{
+		"ca.crt":     caCert,
+		"client.crt": clientCert,
+		"client.key": clientKey,
+	}
+}
+
+func assertProviderReference(t *testing.T, got *pb.ProviderReference, want esv1.ProviderReference) {
+	t.Helper()
+
+	if got == nil {
+		t.Fatal("expected provider reference to be set")
+	}
+	if got.ApiVersion != want.APIVersion || got.Kind != want.Kind || got.Name != want.Name || got.Namespace != want.Namespace {
+		t.Fatalf("unexpected provider ref: got=%#v want=%#v", got, want)
+	}
+}
+
+func newProviderTLSArtifacts(t *testing.T, host string) (serverCertPEM, serverKeyPEM, clientCertPEM, clientKeyPEM, caCertPEM []byte) {
+	t.Helper()
+
+	caKey, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		t.Fatalf("GenerateKey() error = %v", err)
+	}
+
+	caTemplate := &x509.Certificate{
+		SerialNumber: big.NewInt(1),
+		Subject: pkix.Name{CommonName: "provider-controller-test-ca"},
+		NotBefore:             time.Now().Add(-time.Hour),
+		NotAfter:              time.Now().Add(24 * time.Hour),
+		KeyUsage:              x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
+		BasicConstraintsValid: true,
+		IsCA:                  true,
+	}
+
+	caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
+	if err != nil {
+		t.Fatalf("CreateCertificate() error = %v", err)
+	}
+	caCert, err := x509.ParseCertificate(caDER)
+	if err != nil {
+		t.Fatalf("ParseCertificate() error = %v", err)
+	}
+
+	serverCertPEM, serverKeyPEM = newProviderSignedTLSCert(t, caCert, caKey, 2, host, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth})
+	clientCertPEM, clientKeyPEM = newProviderSignedTLSCert(t, caCert, caKey, 3, host, []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth})
+	caCertPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER})
+	return serverCertPEM, serverKeyPEM, clientCertPEM, clientKeyPEM, caCertPEM
+}
+
+func newProviderSignedTLSCert(t *testing.T, caCert *x509.Certificate, caKey *rsa.PrivateKey, serial int64, host string, usages []x509.ExtKeyUsage) ([]byte, []byte) {
+	t.Helper()
+
+	key, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		t.Fatalf("GenerateKey() error = %v", err)
+	}
+
+	template := &x509.Certificate{
+		SerialNumber: big.NewInt(serial),
+		Subject: pkix.Name{CommonName: host},
+		NotBefore:   time.Now().Add(-time.Hour),
+		NotAfter:    time.Now().Add(24 * time.Hour),
+		KeyUsage:    x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
+		ExtKeyUsage: usages,
+	}
+
+	if ip := net.ParseIP(host); ip != nil {
+		template.IPAddresses = []net.IP{ip}
+	} else {
+		template.DNSNames = []string{host}
+	}
+
+	der, err := x509.CreateCertificate(rand.Reader, template, caCert, &key.PublicKey, caKey)
+	if err != nil {
+		t.Fatalf("CreateCertificate() error = %v", err)
+	}
+
+	return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
+		pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
+}

+ 664 - 0
pkg/controllers/pushsecret/pushsecret_controller_v2_test.go

@@ -0,0 +1,664 @@
+/*
+Copyright © The ESO Authors
+
+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
+
+    https://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 pushsecret
+
+import (
+	"context"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/tls"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/pem"
+	"math/big"
+	"net"
+	"testing"
+	"time"
+
+	"github.com/go-logr/logr"
+	corev1 "k8s.io/api/core/v1"
+	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+	clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/credentials"
+	fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esapi "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+	pb "github.com/external-secrets/external-secrets/proto/provider"
+	"github.com/external-secrets/external-secrets/runtime/clientmanager"
+)
+
+type pushsecretRecordingProviderServer struct {
+	pb.UnimplementedSecretStoreProviderServer
+	pushRequest   *pb.PushSecretRequest
+	deleteRequest *pb.DeleteSecretRequest
+}
+
+func (s *pushsecretRecordingProviderServer) PushSecret(_ context.Context, req *pb.PushSecretRequest) (*pb.PushSecretResponse, error) {
+	s.pushRequest = req
+	return &pb.PushSecretResponse{}, nil
+}
+
+func (s *pushsecretRecordingProviderServer) DeleteSecret(_ context.Context, req *pb.DeleteSecretRequest) (*pb.DeleteSecretResponse, error) {
+	s.deleteRequest = req
+	return &pb.DeleteSecretResponse{}, nil
+}
+
+func (s *pushsecretRecordingProviderServer) SecretExists(_ context.Context, _ *pb.SecretExistsRequest) (*pb.SecretExistsResponse, error) {
+	return &pb.SecretExistsResponse{Exists: false}, nil
+}
+
+func TestResolvedStoreInfoSupportsProviderKinds(t *testing.T) {
+	providerInfo, ok := resolvedStoreInfo(esapi.PushSecretStoreRef{
+		Name: "provider",
+		Kind: esv1.ProviderKindStr,
+	}, &esv1.Provider{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:   "provider",
+			Labels: map[string]string{"team": "a"},
+		},
+	})
+	if !ok {
+		t.Fatal("expected provider store info to resolve")
+	}
+	if providerInfo.Name != "provider" || providerInfo.Kind != esv1.ProviderKindStr || providerInfo.Labels["team"] != "a" {
+		t.Fatalf("unexpected provider info: %#v", providerInfo)
+	}
+
+	clusterProviderInfo, ok := resolvedStoreInfo(esapi.PushSecretStoreRef{
+		Name: "cluster-provider",
+		Kind: esv1.ClusterProviderKindStr,
+	}, &esv1.ClusterProvider{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:   "cluster-provider",
+			Labels: map[string]string{"scope": "cluster"},
+		},
+	})
+	if !ok {
+		t.Fatal("expected cluster provider store info to resolve")
+	}
+	if clusterProviderInfo.Name != "cluster-provider" || clusterProviderInfo.Kind != esv1.ClusterProviderKindStr || clusterProviderInfo.Labels["scope"] != "cluster" {
+		t.Fatalf("unexpected cluster provider info: %#v", clusterProviderInfo)
+	}
+}
+
+func TestValidateDataToMatchesResolvedStoresSupportsProviderKinds(t *testing.T) {
+	err := validateDataToMatchesResolvedStores([]esapi.PushSecretDataTo{
+		{
+			StoreRef: &esapi.PushSecretStoreRef{
+				Kind: esv1.ProviderKindStr,
+				LabelSelector: &metav1.LabelSelector{
+					MatchLabels: map[string]string{"team": "a"},
+				},
+			},
+			RemoteKey: "bundle",
+		},
+	}, []storeInfo{
+		{Name: "provider", Kind: esv1.ProviderKindStr, Labels: map[string]string{"team": "a"}},
+	})
+	if err != nil {
+		t.Fatalf("expected provider label selector to match, got %v", err)
+	}
+
+	err = validateDataToMatchesResolvedStores([]esapi.PushSecretDataTo{
+		{
+			StoreRef: &esapi.PushSecretStoreRef{
+				Kind: esv1.ClusterProviderKindStr,
+				LabelSelector: &metav1.LabelSelector{
+					MatchLabels: map[string]string{"scope": "missing"},
+				},
+			},
+			RemoteKey: "bundle",
+		},
+	}, []storeInfo{
+		{Name: "cluster-provider", Kind: esv1.ClusterProviderKindStr, Labels: map[string]string{"scope": "cluster"}},
+	})
+	if err == nil || err.Error() != "dataTo[0]: labelSelector does not match any store in secretStoreRefs" {
+		t.Fatalf("unexpected error: %v", err)
+	}
+}
+
+func TestPushSecretToProvidersV2UsesProviderPath(t *testing.T) {
+	previous := clientmanager.V2ProvidersEnabled()
+	clientmanager.SetV2ProvidersEnabled(true)
+	t.Cleanup(func() {
+		clientmanager.SetV2ProvidersEnabled(previous)
+	})
+
+	scheme := newPushSecretTestScheme(t)
+	server, address, tlsSecret := newPushSecretProviderServer(t)
+
+	provider := &esv1.Provider{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "provider",
+			Namespace: "tenant-a",
+			Labels:    map[string]string{"team": "a"},
+		},
+		Spec: esv1.ProviderSpec{
+			Config: esv1.ProviderConfig{
+				Address: address,
+				ProviderRef: esv1.ProviderReference{
+					APIVersion: "provider.external-secrets.io/v2alpha1",
+					Kind:       "Kubernetes",
+					Name:       "backend",
+				},
+			},
+		},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(provider, &corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "external-secrets-provider-tls",
+				Namespace: "tenant-a",
+			},
+			Data: tlsSecret,
+		}).
+		Build()
+
+	r := &Reconciler{Client: kubeClient, Log: logr.Discard()}
+	mgr := clientmanager.NewManager(kubeClient, "", false)
+	defer func() {
+		_ = mgr.Close(context.Background())
+	}()
+
+	ps := esapi.PushSecret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "pushsecret",
+			Namespace: "tenant-a",
+		},
+		Spec: esapi.PushSecretSpec{
+			SecretStoreRefs: []esapi.PushSecretStoreRef{{
+				Name: "provider",
+				Kind: esv1.ProviderKindStr,
+			}},
+			Data: []esapi.PushSecretData{{
+				Match: esapi.PushSecretMatch{
+					SecretKey: "token",
+					RemoteRef: esapi.PushSecretRemoteRef{
+						RemoteKey: "remote/path",
+						Property:  "property",
+					},
+				},
+				Metadata: &apiextensionsv1.JSON{Raw: []byte(`{"owner":"eso"}`)},
+			}},
+		},
+	}
+
+	secret := &corev1.Secret{
+		Data: map[string][]byte{"token": []byte("value")},
+	}
+
+	synced, err := r.PushSecretToProvidersV2(context.Background(), map[esapi.PushSecretStoreRef]interface{}{
+		{Name: "provider", Kind: esv1.ProviderKindStr}: provider,
+	}, ps, secret, mgr)
+	if err != nil {
+		t.Fatalf("PushSecretToProvidersV2() error = %v", err)
+	}
+
+	if server.pushRequest == nil {
+		t.Fatal("expected push request to be recorded")
+	}
+	if server.pushRequest.SourceNamespace != "tenant-a" {
+		t.Fatalf("unexpected source namespace: %q", server.pushRequest.SourceNamespace)
+	}
+	if server.pushRequest.ProviderRef == nil || server.pushRequest.ProviderRef.Name != "backend" {
+		t.Fatalf("unexpected provider ref: %#v", server.pushRequest.ProviderRef)
+	}
+	if string(server.pushRequest.SecretData["token"]) != "value" {
+		t.Fatalf("unexpected secret data: %#v", server.pushRequest.SecretData)
+	}
+	if server.pushRequest.PushSecretData == nil || server.pushRequest.PushSecretData.RemoteKey != "remote/path" || server.pushRequest.PushSecretData.Property != "property" {
+		t.Fatalf("unexpected push payload: %#v", server.pushRequest.PushSecretData)
+	}
+	if string(server.pushRequest.PushSecretData.Metadata) != `{"owner":"eso"}` {
+		t.Fatalf("unexpected metadata: %q", string(server.pushRequest.PushSecretData.Metadata))
+	}
+	if synced["Provider/provider"]["remote/path/property"].Match.SecretKey != "token" {
+		t.Fatalf("unexpected synced map: %#v", synced)
+	}
+}
+
+func TestPushSecretToProvidersV2UsesProviderNamespaceAuthScope(t *testing.T) {
+	previous := clientmanager.V2ProvidersEnabled()
+	clientmanager.SetV2ProvidersEnabled(true)
+	t.Cleanup(func() {
+		clientmanager.SetV2ProvidersEnabled(previous)
+	})
+
+	scheme := newPushSecretTestScheme(t)
+	server, address, tlsSecret := newPushSecretProviderServer(t)
+
+	const manifestNamespace = "tenant-a"
+	const providerNamespace = "provider-config-ns"
+
+	clusterProvider := &esv1.ClusterProvider{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:   "cluster-provider",
+			Labels: map[string]string{"scope": "cluster"},
+		},
+		Spec: esv1.ClusterProviderSpec{
+			Config: esv1.ProviderConfig{
+				Address: address,
+				ProviderRef: esv1.ProviderReference{
+					APIVersion: "provider.external-secrets.io/v2alpha1",
+					Kind:       "Kubernetes",
+					Name:       "backend",
+					Namespace:  providerNamespace,
+				},
+			},
+			AuthenticationScope: esv1.AuthenticationScopeProviderNamespace,
+			Conditions: []esv1.ClusterSecretStoreCondition{{
+				Namespaces: []string{manifestNamespace},
+			}},
+		},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(
+			clusterProvider,
+			&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{
+				Name: manifestNamespace,
+				Labels: map[string]string{
+					"kubernetes.io/metadata.name": manifestNamespace,
+				},
+			}},
+			&corev1.Secret{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "external-secrets-provider-tls",
+					Namespace: providerNamespace,
+				},
+				Data: tlsSecret,
+			},
+		).
+		Build()
+
+	r := &Reconciler{Client: kubeClient, Log: logr.Discard()}
+	mgr := clientmanager.NewManager(kubeClient, "", false)
+	defer func() {
+		_ = mgr.Close(context.Background())
+	}()
+
+	ps := esapi.PushSecret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "pushsecret",
+			Namespace: manifestNamespace,
+		},
+		Spec: esapi.PushSecretSpec{
+			SecretStoreRefs: []esapi.PushSecretStoreRef{{
+				Name: "cluster-provider",
+				Kind: esv1.ClusterProviderKindStr,
+			}},
+			Data: []esapi.PushSecretData{{
+				Match: esapi.PushSecretMatch{
+					SecretKey: "token",
+					RemoteRef: esapi.PushSecretRemoteRef{
+						RemoteKey: "remote/path",
+						Property:  "property",
+					},
+				},
+				Metadata: &apiextensionsv1.JSON{Raw: []byte(`{"owner":"eso"}`)},
+			}},
+		},
+	}
+
+	secret := &corev1.Secret{
+		Data: map[string][]byte{"token": []byte("value")},
+	}
+
+	synced, err := r.PushSecretToProvidersV2(context.Background(), map[esapi.PushSecretStoreRef]interface{}{
+		{Name: "cluster-provider", Kind: esv1.ClusterProviderKindStr}: clusterProvider,
+	}, ps, secret, mgr)
+	if err != nil {
+		t.Fatalf("PushSecretToProvidersV2() error = %v", err)
+	}
+
+	if server.pushRequest == nil {
+		t.Fatal("expected push request to be recorded")
+	}
+	if server.pushRequest.SourceNamespace != providerNamespace {
+		t.Fatalf("unexpected source namespace: %q", server.pushRequest.SourceNamespace)
+	}
+	if server.pushRequest.ProviderRef == nil || server.pushRequest.ProviderRef.Name != "backend" || server.pushRequest.ProviderRef.Namespace != providerNamespace {
+		t.Fatalf("unexpected provider ref: %#v", server.pushRequest.ProviderRef)
+	}
+	if synced["ClusterProvider/cluster-provider"]["remote/path/property"].Match.SecretKey != "token" {
+		t.Fatalf("unexpected synced map: %#v", synced)
+	}
+}
+
+func TestDeleteSecretFromProvidersV2UsesClusterProviderPath(t *testing.T) {
+	previous := clientmanager.V2ProvidersEnabled()
+	clientmanager.SetV2ProvidersEnabled(true)
+	t.Cleanup(func() {
+		clientmanager.SetV2ProvidersEnabled(previous)
+	})
+
+	scheme := newPushSecretTestScheme(t)
+	server, address, tlsSecret := newPushSecretProviderServer(t)
+
+	clusterProvider := &esv1.ClusterProvider{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:   "cluster-provider",
+			Labels: map[string]string{"scope": "cluster"},
+		},
+		Spec: esv1.ClusterProviderSpec{
+			Config: esv1.ProviderConfig{
+				Address: address,
+				ProviderRef: esv1.ProviderReference{
+					APIVersion: "provider.external-secrets.io/v2alpha1",
+					Kind:       "Kubernetes",
+					Name:       "backend",
+				},
+			},
+			AuthenticationScope: esv1.AuthenticationScopeManifestNamespace,
+			Conditions: []esv1.ClusterSecretStoreCondition{{
+				Namespaces: []string{"tenant-a"},
+			}},
+		},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(
+			clusterProvider,
+			&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{
+				Name: "tenant-a",
+				Labels: map[string]string{
+					"kubernetes.io/metadata.name": "tenant-a",
+				},
+			}},
+			&corev1.Secret{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "external-secrets-provider-tls",
+					Namespace: "tenant-a",
+				},
+				Data: tlsSecret,
+			},
+		).
+		Build()
+
+	r := &Reconciler{Client: kubeClient, Log: logr.Discard()}
+	ps := &esapi.PushSecret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "pushsecret",
+			Namespace: "tenant-a",
+		},
+		Status: esapi.PushSecretStatus{
+			SyncedPushSecrets: esapi.SyncedPushSecretsMap{
+				"ClusterProvider/cluster-provider": {
+					"remote/path": {
+						Match: esapi.PushSecretMatch{
+							SecretKey: "token",
+							RemoteRef: esapi.PushSecretRemoteRef{
+								RemoteKey: "remote/path",
+								Property:  "property",
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	result, err := r.DeleteSecretFromProvidersV2(context.Background(), ps, esapi.SyncedPushSecretsMap{}, map[esapi.PushSecretStoreRef]interface{}{
+		{Name: "cluster-provider", Kind: esv1.ClusterProviderKindStr}: clusterProvider,
+	})
+	if err != nil {
+		t.Fatalf("DeleteSecretFromProvidersV2() error = %v", err)
+	}
+
+	if server.deleteRequest == nil {
+		t.Fatal("expected delete request to be recorded")
+	}
+	if server.deleteRequest.SourceNamespace != "tenant-a" {
+		t.Fatalf("unexpected source namespace: %q", server.deleteRequest.SourceNamespace)
+	}
+	if server.deleteRequest.RemoteRef == nil || server.deleteRequest.RemoteRef.RemoteKey != "remote/path" || server.deleteRequest.RemoteRef.Property != "property" {
+		t.Fatalf("unexpected delete ref: %#v", server.deleteRequest.RemoteRef)
+	}
+	if _, ok := result["ClusterProvider/cluster-provider"]; ok {
+		t.Fatalf("expected synced state to be cleaned up, got %#v", result)
+	}
+}
+
+func TestDeleteSecretFromProvidersV2UsesProviderNamespaceAuthScope(t *testing.T) {
+	previous := clientmanager.V2ProvidersEnabled()
+	clientmanager.SetV2ProvidersEnabled(true)
+	t.Cleanup(func() {
+		clientmanager.SetV2ProvidersEnabled(previous)
+	})
+
+	scheme := newPushSecretTestScheme(t)
+	server, address, tlsSecret := newPushSecretProviderServer(t)
+
+	const manifestNamespace = "tenant-a"
+	const providerNamespace = "provider-config-ns"
+
+	clusterProvider := &esv1.ClusterProvider{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:   "cluster-provider",
+			Labels: map[string]string{"scope": "cluster"},
+		},
+		Spec: esv1.ClusterProviderSpec{
+			Config: esv1.ProviderConfig{
+				Address: address,
+				ProviderRef: esv1.ProviderReference{
+					APIVersion: "provider.external-secrets.io/v2alpha1",
+					Kind:       "Kubernetes",
+					Name:       "backend",
+					Namespace:  providerNamespace,
+				},
+			},
+			AuthenticationScope: esv1.AuthenticationScopeProviderNamespace,
+			Conditions: []esv1.ClusterSecretStoreCondition{{
+				Namespaces: []string{manifestNamespace},
+			}},
+		},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(
+			clusterProvider,
+			&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{
+				Name: manifestNamespace,
+				Labels: map[string]string{
+					"kubernetes.io/metadata.name": manifestNamespace,
+				},
+			}},
+			&corev1.Secret{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "external-secrets-provider-tls",
+					Namespace: providerNamespace,
+				},
+				Data: tlsSecret,
+			},
+		).
+		Build()
+
+	r := &Reconciler{Client: kubeClient, Log: logr.Discard()}
+	ps := &esapi.PushSecret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "pushsecret",
+			Namespace: manifestNamespace,
+		},
+		Status: esapi.PushSecretStatus{
+			SyncedPushSecrets: esapi.SyncedPushSecretsMap{
+				"ClusterProvider/cluster-provider": {
+					"remote/path": {
+						Match: esapi.PushSecretMatch{
+							SecretKey: "token",
+							RemoteRef: esapi.PushSecretRemoteRef{
+								RemoteKey: "remote/path",
+								Property:  "property",
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	result, err := r.DeleteSecretFromProvidersV2(context.Background(), ps, esapi.SyncedPushSecretsMap{}, map[esapi.PushSecretStoreRef]interface{}{
+		{Name: "cluster-provider", Kind: esv1.ClusterProviderKindStr}: clusterProvider,
+	})
+	if err != nil {
+		t.Fatalf("DeleteSecretFromProvidersV2() error = %v", err)
+	}
+
+	if server.deleteRequest == nil {
+		t.Fatal("expected delete request to be recorded")
+	}
+	if server.deleteRequest.SourceNamespace != providerNamespace {
+		t.Fatalf("unexpected source namespace: %q", server.deleteRequest.SourceNamespace)
+	}
+	if server.deleteRequest.ProviderRef == nil || server.deleteRequest.ProviderRef.Namespace != providerNamespace {
+		t.Fatalf("unexpected provider ref: %#v", server.deleteRequest.ProviderRef)
+	}
+	if _, ok := result["ClusterProvider/cluster-provider"]; ok {
+		t.Fatalf("expected synced state to be cleaned up, got %#v", result)
+	}
+}
+
+func newPushSecretTestScheme(t *testing.T) *runtime.Scheme {
+	t.Helper()
+
+	scheme := runtime.NewScheme()
+	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+	utilruntime.Must(esv1.AddToScheme(scheme))
+	utilruntime.Must(esapi.AddToScheme(scheme))
+	return scheme
+}
+
+func newPushSecretProviderServer(t *testing.T) (*pushsecretRecordingProviderServer, string, map[string][]byte) {
+	t.Helper()
+
+	serverCert, serverKey, clientCert, clientKey, caCert := newPushSecretTLSArtifacts(t, "127.0.0.1")
+
+	caPool := x509.NewCertPool()
+	if !caPool.AppendCertsFromPEM(caCert) {
+		t.Fatal("failed to append CA cert")
+	}
+	tlsCert, err := tls.X509KeyPair(serverCert, serverKey)
+	if err != nil {
+		t.Fatalf("X509KeyPair() error = %v", err)
+	}
+
+	lis, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatalf("Listen() error = %v", err)
+	}
+
+	server := &pushsecretRecordingProviderServer{}
+	grpcServer := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{
+		MinVersion:   tls.VersionTLS12,
+		Certificates: []tls.Certificate{tlsCert},
+		ClientCAs:    caPool,
+		ClientAuth:   tls.RequireAndVerifyClientCert,
+	})))
+	pb.RegisterSecretStoreProviderServer(grpcServer, server)
+	go func() {
+		_ = grpcServer.Serve(lis)
+	}()
+
+	t.Cleanup(func() {
+		grpcServer.Stop()
+		_ = lis.Close()
+	})
+
+	return server, lis.Addr().String(), map[string][]byte{
+		"ca.crt":     caCert,
+		"client.crt": clientCert,
+		"client.key": clientKey,
+	}
+}
+
+func newPushSecretTLSArtifacts(t *testing.T, host string) (serverCertPEM, serverKeyPEM, clientCertPEM, clientKeyPEM, caCertPEM []byte) {
+	t.Helper()
+
+	caKey, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		t.Fatalf("GenerateKey() error = %v", err)
+	}
+
+	caTemplate := &x509.Certificate{
+		SerialNumber: big.NewInt(1),
+		Subject: pkix.Name{
+			CommonName: "pushsecret-test-ca",
+		},
+		NotBefore:             time.Now().Add(-time.Hour),
+		NotAfter:              time.Now().Add(24 * time.Hour),
+		KeyUsage:              x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
+		BasicConstraintsValid: true,
+		IsCA:                  true,
+	}
+
+	caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
+	if err != nil {
+		t.Fatalf("CreateCertificate() error = %v", err)
+	}
+	caCert, err := x509.ParseCertificate(caDER)
+	if err != nil {
+		t.Fatalf("ParseCertificate() error = %v", err)
+	}
+
+	serverCertPEM, serverKeyPEM = newPushSecretSignedTLSCert(t, caCert, caKey, 2, host, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth})
+	clientCertPEM, clientKeyPEM = newPushSecretSignedTLSCert(t, caCert, caKey, 3, host, []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth})
+	caCertPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER})
+	return serverCertPEM, serverKeyPEM, clientCertPEM, clientKeyPEM, caCertPEM
+}
+
+func newPushSecretSignedTLSCert(t *testing.T, caCert *x509.Certificate, caKey *rsa.PrivateKey, serial int64, host string, usages []x509.ExtKeyUsage) ([]byte, []byte) {
+	t.Helper()
+
+	key, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		t.Fatalf("GenerateKey() error = %v", err)
+	}
+
+	template := &x509.Certificate{
+		SerialNumber: big.NewInt(serial),
+		Subject: pkix.Name{
+			CommonName: host,
+		},
+		NotBefore:   time.Now().Add(-time.Hour),
+		NotAfter:    time.Now().Add(24 * time.Hour),
+		KeyUsage:    x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
+		ExtKeyUsage: usages,
+	}
+
+	if ip := net.ParseIP(host); ip != nil {
+		template.IPAddresses = []net.IP{ip}
+	} else {
+		template.DNSNames = []string{host}
+	}
+
+	der, err := x509.CreateCertificate(rand.Reader, template, caCert, &key.PublicKey, caKey)
+	if err != nil {
+		t.Fatalf("CreateCertificate() error = %v", err)
+	}
+
+	return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
+		pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
+}

+ 347 - 30
providers/v2/adapter/store/client_test.go

@@ -16,43 +16,103 @@ package store
 
 import (
 	"context"
+	"errors"
 	"testing"
 
+	corev1 "k8s.io/api/core/v1"
+	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+
 	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
 	pb "github.com/external-secrets/external-secrets/proto/provider"
 )
 
 type fakeV2Provider struct {
+	getSecretResponse    []byte
+	getSecretErr         error
+	getSecretRef         esv1.ExternalSecretDataRemoteRef
+	getSecretProviderRef *pb.ProviderReference
+	getSecretNamespace   string
+
 	getSecretMapResponse map[string][]byte
 	getSecretMapErr      error
+	getSecretMapRef      esv1.ExternalSecretDataRemoteRef
+
+	getAllSecretsResponse map[string][]byte
+	getAllSecretsErr      error
+	getAllSecretsFind     esv1.ExternalSecretFind
+
+	pushSecretErr         error
+	pushSecretData        map[string][]byte
+	pushSecretPayload     *pb.PushSecretData
+	pushSecretProviderRef *pb.ProviderReference
+	pushSecretNamespace   string
+
+	deleteSecretErr         error
+	deleteSecretRemoteRef   *pb.PushSecretRemoteRef
+	deleteSecretProviderRef *pb.ProviderReference
+	deleteSecretNamespace   string
+
+	secretExistsResponse    bool
+	secretExistsErr         error
+	secretExistsRemoteRef   *pb.PushSecretRemoteRef
+	secretExistsProviderRef *pb.ProviderReference
+	secretExistsNamespace   string
+
+	validateErr         error
+	validateProviderRef *pb.ProviderReference
+	validateNamespace   string
+
+	closeErr    error
+	closeCalled bool
 }
 
-func (f *fakeV2Provider) GetSecret(context.Context, esv1.ExternalSecretDataRemoteRef, *pb.ProviderReference, string) ([]byte, error) {
-	return nil, nil
+func (f *fakeV2Provider) GetSecret(_ context.Context, ref esv1.ExternalSecretDataRemoteRef, providerRef *pb.ProviderReference, sourceNamespace string) ([]byte, error) {
+	f.getSecretRef = ref
+	f.getSecretProviderRef = providerRef
+	f.getSecretNamespace = sourceNamespace
+	return f.getSecretResponse, f.getSecretErr
 }
 
-func (f *fakeV2Provider) GetSecretMap(context.Context, esv1.ExternalSecretDataRemoteRef, *pb.ProviderReference, string) (map[string][]byte, error) {
+func (f *fakeV2Provider) GetSecretMap(_ context.Context, ref esv1.ExternalSecretDataRemoteRef, providerRef *pb.ProviderReference, sourceNamespace string) (map[string][]byte, error) {
+	f.getSecretMapRef = ref
+	f.getSecretProviderRef = providerRef
+	f.getSecretNamespace = sourceNamespace
 	return f.getSecretMapResponse, f.getSecretMapErr
 }
 
-func (f *fakeV2Provider) GetAllSecrets(context.Context, esv1.ExternalSecretFind, *pb.ProviderReference, string) (map[string][]byte, error) {
-	return nil, nil
+func (f *fakeV2Provider) GetAllSecrets(_ context.Context, find esv1.ExternalSecretFind, providerRef *pb.ProviderReference, sourceNamespace string) (map[string][]byte, error) {
+	f.getAllSecretsFind = find
+	f.getSecretProviderRef = providerRef
+	f.getSecretNamespace = sourceNamespace
+	return f.getAllSecretsResponse, f.getAllSecretsErr
 }
 
-func (f *fakeV2Provider) PushSecret(context.Context, map[string][]byte, *pb.PushSecretData, *pb.ProviderReference, string) error {
-	return nil
+func (f *fakeV2Provider) PushSecret(_ context.Context, secretData map[string][]byte, pushSecretData *pb.PushSecretData, providerRef *pb.ProviderReference, sourceNamespace string) error {
+	f.pushSecretData = secretData
+	f.pushSecretPayload = pushSecretData
+	f.pushSecretProviderRef = providerRef
+	f.pushSecretNamespace = sourceNamespace
+	return f.pushSecretErr
 }
 
-func (f *fakeV2Provider) DeleteSecret(context.Context, *pb.PushSecretRemoteRef, *pb.ProviderReference, string) error {
-	return nil
+func (f *fakeV2Provider) DeleteSecret(_ context.Context, remoteRef *pb.PushSecretRemoteRef, providerRef *pb.ProviderReference, sourceNamespace string) error {
+	f.deleteSecretRemoteRef = remoteRef
+	f.deleteSecretProviderRef = providerRef
+	f.deleteSecretNamespace = sourceNamespace
+	return f.deleteSecretErr
 }
 
-func (f *fakeV2Provider) SecretExists(context.Context, *pb.PushSecretRemoteRef, *pb.ProviderReference, string) (bool, error) {
-	return false, nil
+func (f *fakeV2Provider) SecretExists(_ context.Context, remoteRef *pb.PushSecretRemoteRef, providerRef *pb.ProviderReference, sourceNamespace string) (bool, error) {
+	f.secretExistsRemoteRef = remoteRef
+	f.secretExistsProviderRef = providerRef
+	f.secretExistsNamespace = sourceNamespace
+	return f.secretExistsResponse, f.secretExistsErr
 }
 
-func (f *fakeV2Provider) Validate(context.Context, *pb.ProviderReference, string) error {
-	return nil
+func (f *fakeV2Provider) Validate(_ context.Context, providerRef *pb.ProviderReference, sourceNamespace string) error {
+	f.validateProviderRef = providerRef
+	f.validateNamespace = sourceNamespace
+	return f.validateErr
 }
 
 func (f *fakeV2Provider) Capabilities(context.Context, *pb.ProviderReference, string) (pb.SecretStoreCapabilities, error) {
@@ -60,29 +120,286 @@ func (f *fakeV2Provider) Capabilities(context.Context, *pb.ProviderReference, st
 }
 
 func (f *fakeV2Provider) Close(context.Context) error {
-	return nil
+	f.closeCalled = true
+	return f.closeErr
 }
 
-func TestGetSecretMap(t *testing.T) {
-	t.Run("delegates to provider GetSecretMap", func(t *testing.T) {
-		expected := map[string][]byte{
-			"foo": []byte("bar"),
-			"baz": []byte("qux"),
-		}
-		provider := &fakeV2Provider{
-			getSecretMapResponse: expected,
-		}
-		client := NewClient(provider, &pb.ProviderReference{Name: "provider"}, "default")
+type fakePushSecretData struct {
+	property  string
+	secretKey string
+	remoteKey string
+	metadata  *apiextensionsv1.JSON
+}
+
+func (f fakePushSecretData) GetProperty() string {
+	return f.property
+}
+
+func (f fakePushSecretData) GetSecretKey() string {
+	return f.secretKey
+}
+
+func (f fakePushSecretData) GetRemoteKey() string {
+	return f.remoteKey
+}
+
+func (f fakePushSecretData) GetMetadata() *apiextensionsv1.JSON {
+	return f.metadata
+}
+
+type fakePushSecretRemoteRef struct {
+	remoteKey string
+	property  string
+}
+
+func (f fakePushSecretRemoteRef) GetRemoteKey() string {
+	return f.remoteKey
+}
+
+func (f fakePushSecretRemoteRef) GetProperty() string {
+	return f.property
+}
+
+func TestClientGetSecretDelegatesProviderReferenceAndNamespace(t *testing.T) {
+	providerRef := &pb.ProviderReference{Name: "provider", Namespace: "config-ns"}
+	provider := &fakeV2Provider{getSecretResponse: []byte("secret-value")}
+	client := NewClient(provider, providerRef, "tenant-a")
+
+	ref := esv1.ExternalSecretDataRemoteRef{Key: "sample", Version: "v1", Property: "password"}
+	value, err := client.GetSecret(context.Background(), ref)
+	if err != nil {
+		t.Fatalf("GetSecret() error = %v", err)
+	}
+
+	if string(value) != "secret-value" {
+		t.Fatalf("expected secret-value, got %q", string(value))
+	}
+	if provider.getSecretRef != ref {
+		t.Fatalf("unexpected ref: %#v", provider.getSecretRef)
+	}
+	if provider.getSecretProviderRef != providerRef {
+		t.Fatalf("unexpected provider ref: %#v", provider.getSecretProviderRef)
+	}
+	if provider.getSecretNamespace != "tenant-a" {
+		t.Fatalf("unexpected source namespace: %q", provider.getSecretNamespace)
+	}
+}
+
+func TestClientGetSecretMapDelegatesProviderReferenceAndNamespace(t *testing.T) {
+	providerRef := &pb.ProviderReference{Name: "provider", Namespace: "config-ns"}
+	expected := map[string][]byte{
+		"foo": []byte("bar"),
+		"baz": []byte("qux"),
+	}
+	provider := &fakeV2Provider{getSecretMapResponse: expected}
+	client := NewClient(provider, providerRef, "tenant-a")
 
-		secretMap, err := client.GetSecretMap(context.Background(), esv1.ExternalSecretDataRemoteRef{Key: "sample"})
+	ref := esv1.ExternalSecretDataRemoteRef{Key: "sample"}
+	secretMap, err := client.GetSecretMap(context.Background(), ref)
+	if err != nil {
+		t.Fatalf("GetSecretMap() error = %v", err)
+	}
+
+	if string(secretMap["foo"]) != "bar" || string(secretMap["baz"]) != "qux" {
+		t.Fatalf("unexpected secret map: %#v", secretMap)
+	}
+	if provider.getSecretMapRef != ref {
+		t.Fatalf("unexpected ref: %#v", provider.getSecretMapRef)
+	}
+	if provider.getSecretProviderRef != providerRef {
+		t.Fatalf("unexpected provider ref: %#v", provider.getSecretProviderRef)
+	}
+	if provider.getSecretNamespace != "tenant-a" {
+		t.Fatalf("unexpected source namespace: %q", provider.getSecretNamespace)
+	}
+}
+
+func TestClientGetAllSecretsDelegatesFindCriteria(t *testing.T) {
+	providerRef := &pb.ProviderReference{Name: "provider", Namespace: "config-ns"}
+	path := "/team-a"
+	expected := map[string][]byte{"db-password": []byte("value")}
+	provider := &fakeV2Provider{getAllSecretsResponse: expected}
+	client := NewClient(provider, providerRef, "tenant-a")
+
+	find := esv1.ExternalSecretFind{
+		Tags: map[string]string{
+			"team": "a",
+		},
+		Path: &path,
+		Name: &esv1.FindName{RegExp: "db-.*"},
+	}
+
+	secrets, err := client.GetAllSecrets(context.Background(), find)
+	if err != nil {
+		t.Fatalf("GetAllSecrets() error = %v", err)
+	}
+
+	if string(secrets["db-password"]) != "value" {
+		t.Fatalf("unexpected secret value: %#v", secrets)
+	}
+	if provider.getAllSecretsFind.Tags["team"] != "a" {
+		t.Fatalf("unexpected find tags: %#v", provider.getAllSecretsFind)
+	}
+	if provider.getAllSecretsFind.Path == nil || *provider.getAllSecretsFind.Path != path {
+		t.Fatalf("unexpected find path: %#v", provider.getAllSecretsFind.Path)
+	}
+	if provider.getAllSecretsFind.Name == nil || provider.getAllSecretsFind.Name.RegExp != "db-.*" {
+		t.Fatalf("unexpected find name: %#v", provider.getAllSecretsFind.Name)
+	}
+	if provider.getSecretProviderRef != providerRef {
+		t.Fatalf("unexpected provider ref: %#v", provider.getSecretProviderRef)
+	}
+	if provider.getSecretNamespace != "tenant-a" {
+		t.Fatalf("unexpected source namespace: %q", provider.getSecretNamespace)
+	}
+}
+
+func TestClientPushSecretConvertsPayloadAndMetadata(t *testing.T) {
+	providerRef := &pb.ProviderReference{Name: "provider", Namespace: "config-ns"}
+	provider := &fakeV2Provider{}
+	client := NewClient(provider, providerRef, "tenant-a")
+
+	metadata := []byte(`{"owner":"eso"}`)
+	secret := &corev1.Secret{
+		Data: map[string][]byte{
+			"token": []byte("value"),
+		},
+	}
+	pushData := fakePushSecretData{
+		property:  "property",
+		secretKey: "token",
+		remoteKey: "remote/path",
+		metadata:  &apiextensionsv1.JSON{Raw: metadata},
+	}
+
+	err := client.PushSecret(context.Background(), secret, pushData)
+	if err != nil {
+		t.Fatalf("PushSecret() error = %v", err)
+	}
+
+	if string(provider.pushSecretData["token"]) != "value" {
+		t.Fatalf("unexpected secret data: %#v", provider.pushSecretData)
+	}
+	if provider.pushSecretPayload == nil {
+		t.Fatal("expected push payload to be recorded")
+	}
+	if provider.pushSecretPayload.RemoteKey != "remote/path" || provider.pushSecretPayload.SecretKey != "token" || provider.pushSecretPayload.Property != "property" {
+		t.Fatalf("unexpected push payload: %#v", provider.pushSecretPayload)
+	}
+	if string(provider.pushSecretPayload.Metadata) != string(metadata) {
+		t.Fatalf("unexpected metadata: %q", string(provider.pushSecretPayload.Metadata))
+	}
+	if provider.pushSecretProviderRef != providerRef {
+		t.Fatalf("unexpected provider ref: %#v", provider.pushSecretProviderRef)
+	}
+	if provider.pushSecretNamespace != "tenant-a" {
+		t.Fatalf("unexpected source namespace: %q", provider.pushSecretNamespace)
+	}
+}
+
+func TestClientDeleteSecretConvertsRemoteRef(t *testing.T) {
+	providerRef := &pb.ProviderReference{Name: "provider", Namespace: "config-ns"}
+	provider := &fakeV2Provider{}
+	client := NewClient(provider, providerRef, "tenant-a")
+
+	err := client.DeleteSecret(context.Background(), fakePushSecretRemoteRef{
+		remoteKey: "remote/path",
+		property:  "property",
+	})
+	if err != nil {
+		t.Fatalf("DeleteSecret() error = %v", err)
+	}
+
+	if provider.deleteSecretRemoteRef == nil {
+		t.Fatal("expected delete remote ref to be recorded")
+	}
+	if provider.deleteSecretRemoteRef.RemoteKey != "remote/path" || provider.deleteSecretRemoteRef.Property != "property" {
+		t.Fatalf("unexpected remote ref: %#v", provider.deleteSecretRemoteRef)
+	}
+	if provider.deleteSecretProviderRef != providerRef {
+		t.Fatalf("unexpected provider ref: %#v", provider.deleteSecretProviderRef)
+	}
+	if provider.deleteSecretNamespace != "tenant-a" {
+		t.Fatalf("unexpected source namespace: %q", provider.deleteSecretNamespace)
+	}
+}
+
+func TestClientSecretExistsConvertsRemoteRef(t *testing.T) {
+	providerRef := &pb.ProviderReference{Name: "provider", Namespace: "config-ns"}
+	provider := &fakeV2Provider{secretExistsResponse: true}
+	client := NewClient(provider, providerRef, "tenant-a")
+
+	exists, err := client.SecretExists(context.Background(), fakePushSecretRemoteRef{
+		remoteKey: "remote/path",
+		property:  "property",
+	})
+	if err != nil {
+		t.Fatalf("SecretExists() error = %v", err)
+	}
+
+	if !exists {
+		t.Fatal("expected secret to exist")
+	}
+	if provider.secretExistsRemoteRef == nil {
+		t.Fatal("expected exists remote ref to be recorded")
+	}
+	if provider.secretExistsRemoteRef.RemoteKey != "remote/path" || provider.secretExistsRemoteRef.Property != "property" {
+		t.Fatalf("unexpected remote ref: %#v", provider.secretExistsRemoteRef)
+	}
+	if provider.secretExistsProviderRef != providerRef {
+		t.Fatalf("unexpected provider ref: %#v", provider.secretExistsProviderRef)
+	}
+	if provider.secretExistsNamespace != "tenant-a" {
+		t.Fatalf("unexpected source namespace: %q", provider.secretExistsNamespace)
+	}
+}
+
+func TestClientValidateMapsProviderErrors(t *testing.T) {
+	t.Run("success", func(t *testing.T) {
+		providerRef := &pb.ProviderReference{Name: "provider", Namespace: "config-ns"}
+		provider := &fakeV2Provider{}
+		client := NewClient(provider, providerRef, "tenant-a")
+
+		result, err := client.Validate()
 		if err != nil {
-			t.Fatalf("GetSecretMap() error = %v", err)
+			t.Fatalf("Validate() error = %v", err)
 		}
-		if len(secretMap) != len(expected) {
-			t.Fatalf("expected %d keys, got %d", len(expected), len(secretMap))
+		if result != esv1.ValidationResultReady {
+			t.Fatalf("expected ValidationResultReady, got %q", result)
 		}
-		if string(secretMap["foo"]) != "bar" || string(secretMap["baz"]) != "qux" {
-			t.Fatalf("unexpected secret map: %#v", secretMap)
+		if provider.validateProviderRef != providerRef {
+			t.Fatalf("unexpected provider ref: %#v", provider.validateProviderRef)
+		}
+		if provider.validateNamespace != "tenant-a" {
+			t.Fatalf("unexpected source namespace: %q", provider.validateNamespace)
 		}
 	})
+
+	t.Run("error", func(t *testing.T) {
+		validateErr := errors.New("invalid credentials")
+		provider := &fakeV2Provider{validateErr: validateErr}
+		client := NewClient(provider, &pb.ProviderReference{Name: "provider"}, "tenant-a")
+
+		result, err := client.Validate()
+		if !errors.Is(err, validateErr) {
+			t.Fatalf("expected %v, got %v", validateErr, err)
+		}
+		if result != esv1.ValidationResultError {
+			t.Fatalf("expected ValidationResultError, got %q", result)
+		}
+	})
+}
+
+func TestClientCloseDelegates(t *testing.T) {
+	closeErr := errors.New("close failed")
+	provider := &fakeV2Provider{closeErr: closeErr}
+	client := NewClient(provider, &pb.ProviderReference{Name: "provider"}, "tenant-a")
+
+	err := client.Close(context.Background())
+	if !errors.Is(err, closeErr) {
+		t.Fatalf("expected %v, got %v", closeErr, err)
+	}
+	if !provider.closeCalled {
+		t.Fatal("expected provider close to be called")
+	}
 }

+ 550 - 0
providers/v2/adapter/store/server_test.go

@@ -0,0 +1,550 @@
+/*
+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 store
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	pb "github.com/external-secrets/external-secrets/proto/provider"
+)
+
+type fakeProviderInterface struct {
+	newClient func(context.Context, esv1.GenericStore, client.Client, string) (esv1.SecretsClient, error)
+	caps      esv1.SecretStoreCapabilities
+}
+
+func (f *fakeProviderInterface) NewClient(ctx context.Context, store esv1.GenericStore, kube client.Client, namespace string) (esv1.SecretsClient, error) {
+	return f.newClient(ctx, store, kube, namespace)
+}
+
+func (f *fakeProviderInterface) Capabilities() esv1.SecretStoreCapabilities {
+	return f.caps
+}
+
+func (f *fakeProviderInterface) ValidateStore(esv1.GenericStore) (admission.Warnings, error) {
+	return nil, nil
+}
+
+type fakeSecretsClient struct {
+	getSecretResponse []byte
+	getSecretErr      error
+	getSecretRef      esv1.ExternalSecretDataRemoteRef
+
+	getSecretMapResponse map[string][]byte
+	getSecretMapErr      error
+	getSecretMapRef      esv1.ExternalSecretDataRemoteRef
+
+	getAllSecretsResponse map[string][]byte
+	getAllSecretsErr      error
+	getAllSecretsFind     esv1.ExternalSecretFind
+
+	pushSecretErr     error
+	pushSecretSecret  *corev1.Secret
+	pushSecretData    esv1.PushSecretData
+	deleteSecretErr   error
+	deleteSecretRef   esv1.PushSecretRemoteRef
+	secretExistsResp  bool
+	secretExistsErr   error
+	secretExistsRef   esv1.PushSecretRemoteRef
+	validateResult    esv1.ValidationResult
+	validateErr       error
+	closeCalled       bool
+}
+
+func (f *fakeSecretsClient) GetSecret(_ context.Context, ref esv1.ExternalSecretDataRemoteRef) ([]byte, error) {
+	f.getSecretRef = ref
+	return f.getSecretResponse, f.getSecretErr
+}
+
+func (f *fakeSecretsClient) GetSecretMap(_ context.Context, ref esv1.ExternalSecretDataRemoteRef) (map[string][]byte, error) {
+	f.getSecretMapRef = ref
+	return f.getSecretMapResponse, f.getSecretMapErr
+}
+
+func (f *fakeSecretsClient) GetAllSecrets(_ context.Context, find esv1.ExternalSecretFind) (map[string][]byte, error) {
+	f.getAllSecretsFind = find
+	return f.getAllSecretsResponse, f.getAllSecretsErr
+}
+
+func (f *fakeSecretsClient) PushSecret(_ context.Context, secret *corev1.Secret, data esv1.PushSecretData) error {
+	f.pushSecretSecret = secret
+	f.pushSecretData = data
+	return f.pushSecretErr
+}
+
+func (f *fakeSecretsClient) DeleteSecret(_ context.Context, remoteRef esv1.PushSecretRemoteRef) error {
+	f.deleteSecretRef = remoteRef
+	return f.deleteSecretErr
+}
+
+func (f *fakeSecretsClient) SecretExists(_ context.Context, remoteRef esv1.PushSecretRemoteRef) (bool, error) {
+	f.secretExistsRef = remoteRef
+	return f.secretExistsResp, f.secretExistsErr
+}
+
+func (f *fakeSecretsClient) Validate() (esv1.ValidationResult, error) {
+	return f.validateResult, f.validateErr
+}
+
+func (f *fakeSecretsClient) Close(context.Context) error {
+	f.closeCalled = true
+	return nil
+}
+
+type specMapperRecorder struct {
+	ref             *pb.ProviderReference
+	sourceNamespace string
+	spec            *esv1.SecretStoreSpec
+	err             error
+}
+
+func (r *specMapperRecorder) mapRef(ref *pb.ProviderReference, sourceNamespace string) (*esv1.SecretStoreSpec, error) {
+	r.ref = ref
+	r.sourceNamespace = sourceNamespace
+	return r.spec, r.err
+}
+
+func TestServerGetSecretMapsRemoteRefAndSyntheticStoreNamespace(t *testing.T) {
+	mapper := &specMapperRecorder{
+		spec: &esv1.SecretStoreSpec{
+			Provider: &esv1.SecretStoreProvider{
+				Fake: &esv1.FakeProvider{},
+			},
+		},
+	}
+	fakeClient := &fakeSecretsClient{getSecretResponse: []byte("secret-value")}
+
+	var receivedStore esv1.GenericStore
+	var receivedNamespace string
+
+	server := NewServer(nil, ProviderMapping{
+		schema.GroupVersionKind{Group: "provider.external-secrets.io", Version: "v2alpha1", Kind: "Fake"}: &fakeProviderInterface{
+			caps: esv1.SecretStoreReadWrite,
+			newClient: func(_ context.Context, store esv1.GenericStore, _ client.Client, namespace string) (esv1.SecretsClient, error) {
+				receivedStore = store
+				receivedNamespace = namespace
+				return fakeClient, nil
+			},
+		},
+	}, mapper.mapRef)
+
+	req := &pb.GetSecretRequest{
+		ProviderRef: &pb.ProviderReference{
+			ApiVersion: "provider.external-secrets.io/v2alpha1",
+			Kind:       "Fake",
+			Name:       "backend",
+			Namespace:  "provider-config-ns",
+		},
+		SourceNamespace: "tenant-a",
+		RemoteRef: &pb.ExternalSecretDataRemoteRef{
+			Key:              "sample",
+			Version:          "v1",
+			Property:         "password",
+			DecodingStrategy: string(esv1.ExternalSecretDecodeBase64),
+			MetadataPolicy:   string(esv1.ExternalSecretMetadataPolicyFetch),
+		},
+	}
+
+	resp, err := server.GetSecret(context.Background(), req)
+	if err != nil {
+		t.Fatalf("GetSecret() error = %v", err)
+	}
+
+	if string(resp.Value) != "secret-value" {
+		t.Fatalf("expected secret-value, got %q", string(resp.Value))
+	}
+	if mapper.ref != req.ProviderRef || mapper.sourceNamespace != "tenant-a" {
+		t.Fatalf("unexpected spec mapper input: ref=%#v namespace=%q", mapper.ref, mapper.sourceNamespace)
+	}
+	if receivedNamespace != "tenant-a" {
+		t.Fatalf("unexpected new client namespace: %q", receivedNamespace)
+	}
+	syntheticStore, ok := receivedStore.(*SyntheticStore)
+	if !ok {
+		t.Fatalf("expected SyntheticStore, got %T", receivedStore)
+	}
+	if syntheticStore.Namespace != "tenant-a" {
+		t.Fatalf("unexpected synthetic store namespace: %q", syntheticStore.Namespace)
+	}
+	if syntheticStore.GetSpec() != mapper.spec {
+		t.Fatalf("unexpected synthetic spec: %#v", syntheticStore.GetSpec())
+	}
+	if fakeClient.getSecretRef.Key != "sample" || fakeClient.getSecretRef.Version != "v1" || fakeClient.getSecretRef.Property != "password" {
+		t.Fatalf("unexpected remote ref: %#v", fakeClient.getSecretRef)
+	}
+	if fakeClient.getSecretRef.DecodingStrategy != esv1.ExternalSecretDecodeBase64 {
+		t.Fatalf("unexpected decoding strategy: %q", fakeClient.getSecretRef.DecodingStrategy)
+	}
+	if fakeClient.getSecretRef.MetadataPolicy != esv1.ExternalSecretMetadataPolicyFetch {
+		t.Fatalf("unexpected metadata policy: %q", fakeClient.getSecretRef.MetadataPolicy)
+	}
+	if !fakeClient.closeCalled {
+		t.Fatal("expected secrets client to be closed")
+	}
+}
+
+func TestServerGetSecretMapDelegates(t *testing.T) {
+	mapper := &specMapperRecorder{
+		spec: &esv1.SecretStoreSpec{Provider: &esv1.SecretStoreProvider{Fake: &esv1.FakeProvider{}}},
+	}
+	fakeClient := &fakeSecretsClient{
+		getSecretMapResponse: map[string][]byte{"foo": []byte("bar")},
+	}
+
+	server := NewServer(nil, ProviderMapping{
+		schema.GroupVersionKind{Group: "provider.external-secrets.io", Version: "v2alpha1", Kind: "Fake"}: &fakeProviderInterface{
+			caps: esv1.SecretStoreReadWrite,
+			newClient: func(context.Context, esv1.GenericStore, client.Client, string) (esv1.SecretsClient, error) {
+				return fakeClient, nil
+			},
+		},
+	}, mapper.mapRef)
+
+	resp, err := server.GetSecretMap(context.Background(), &pb.GetSecretMapRequest{
+		ProviderRef: &pb.ProviderReference{
+			ApiVersion: "provider.external-secrets.io/v2alpha1",
+			Kind:       "Fake",
+			Name:       "backend",
+		},
+		SourceNamespace: "tenant-a",
+		RemoteRef:       &pb.ExternalSecretDataRemoteRef{Key: "sample"},
+	})
+	if err != nil {
+		t.Fatalf("GetSecretMap() error = %v", err)
+	}
+
+	if string(resp.Secrets["foo"]) != "bar" {
+		t.Fatalf("unexpected response: %#v", resp.Secrets)
+	}
+	if fakeClient.getSecretMapRef.Key != "sample" {
+		t.Fatalf("unexpected ref: %#v", fakeClient.getSecretMapRef)
+	}
+}
+
+func TestServerGetAllSecretsMapsFindCriteria(t *testing.T) {
+	mapper := &specMapperRecorder{
+		spec: &esv1.SecretStoreSpec{Provider: &esv1.SecretStoreProvider{Fake: &esv1.FakeProvider{}}},
+	}
+	fakeClient := &fakeSecretsClient{
+		getAllSecretsResponse: map[string][]byte{"db-password": []byte("value")},
+	}
+
+	server := NewServer(nil, ProviderMapping{
+		schema.GroupVersionKind{Group: "provider.external-secrets.io", Version: "v2alpha1", Kind: "Fake"}: &fakeProviderInterface{
+			caps: esv1.SecretStoreReadWrite,
+			newClient: func(context.Context, esv1.GenericStore, client.Client, string) (esv1.SecretsClient, error) {
+				return fakeClient, nil
+			},
+		},
+	}, mapper.mapRef)
+
+	resp, err := server.GetAllSecrets(context.Background(), &pb.GetAllSecretsRequest{
+		ProviderRef: &pb.ProviderReference{
+			ApiVersion: "provider.external-secrets.io/v2alpha1",
+			Kind:       "Fake",
+			Name:       "backend",
+		},
+		SourceNamespace: "tenant-a",
+		Find: &pb.ExternalSecretFind{
+			Tags:               map[string]string{"team": "a"},
+			Path:               "/team-a",
+			ConversionStrategy: string(esv1.ExternalSecretConversionDefault),
+			DecodingStrategy:   string(esv1.ExternalSecretDecodeBase64),
+			Name:               &pb.FindName{Regexp: "db-.*"},
+		},
+	})
+	if err != nil {
+		t.Fatalf("GetAllSecrets() error = %v", err)
+	}
+
+	if string(resp.Secrets["db-password"]) != "value" {
+		t.Fatalf("unexpected response: %#v", resp.Secrets)
+	}
+	if fakeClient.getAllSecretsFind.Tags["team"] != "a" {
+		t.Fatalf("unexpected find tags: %#v", fakeClient.getAllSecretsFind)
+	}
+	if fakeClient.getAllSecretsFind.Path == nil || *fakeClient.getAllSecretsFind.Path != "/team-a" {
+		t.Fatalf("unexpected find path: %#v", fakeClient.getAllSecretsFind.Path)
+	}
+	if fakeClient.getAllSecretsFind.Name == nil || fakeClient.getAllSecretsFind.Name.RegExp != "db-.*" {
+		t.Fatalf("unexpected find name: %#v", fakeClient.getAllSecretsFind.Name)
+	}
+}
+
+func TestServerPushDeleteAndExistsMapWriteRequests(t *testing.T) {
+	mapper := &specMapperRecorder{
+		spec: &esv1.SecretStoreSpec{Provider: &esv1.SecretStoreProvider{Fake: &esv1.FakeProvider{}}},
+	}
+	fakeClient := &fakeSecretsClient{secretExistsResp: true}
+
+	server := NewServer(nil, ProviderMapping{
+		schema.GroupVersionKind{Group: "provider.external-secrets.io", Version: "v2alpha1", Kind: "Fake"}: &fakeProviderInterface{
+			caps: esv1.SecretStoreReadWrite,
+			newClient: func(context.Context, esv1.GenericStore, client.Client, string) (esv1.SecretsClient, error) {
+				return fakeClient, nil
+			},
+		},
+	}, mapper.mapRef)
+
+	_, err := server.PushSecret(context.Background(), &pb.PushSecretRequest{
+		ProviderRef: &pb.ProviderReference{
+			ApiVersion: "provider.external-secrets.io/v2alpha1",
+			Kind:       "Fake",
+			Name:       "backend",
+		},
+		SourceNamespace: "tenant-a",
+		SecretData: map[string][]byte{
+			"token": []byte("value"),
+		},
+		PushSecretData: &pb.PushSecretData{
+			RemoteKey: "remote/path",
+			SecretKey: "token",
+			Property:  "property",
+			Metadata:  []byte(`{"owner":"eso"}`),
+		},
+	})
+	if err != nil {
+		t.Fatalf("PushSecret() error = %v", err)
+	}
+
+	if fakeClient.pushSecretSecret == nil || string(fakeClient.pushSecretSecret.Data["token"]) != "value" {
+		t.Fatalf("unexpected pushed secret: %#v", fakeClient.pushSecretSecret)
+	}
+	if fakeClient.pushSecretSecret.Type != corev1.SecretTypeOpaque {
+		t.Fatalf("unexpected secret type: %q", fakeClient.pushSecretSecret.Type)
+	}
+	if fakeClient.pushSecretData.GetRemoteKey() != "remote/path" || fakeClient.pushSecretData.GetSecretKey() != "token" || fakeClient.pushSecretData.GetProperty() != "property" {
+		t.Fatalf("unexpected push data: %#v", fakeClient.pushSecretData)
+	}
+	if got := fakeClient.pushSecretData.GetMetadata(); got == nil || string(got.Raw) != `{"owner":"eso"}` {
+		t.Fatalf("unexpected metadata: %#v", got)
+	}
+
+	_, err = server.DeleteSecret(context.Background(), &pb.DeleteSecretRequest{
+		ProviderRef: &pb.ProviderReference{
+			ApiVersion: "provider.external-secrets.io/v2alpha1",
+			Kind:       "Fake",
+			Name:       "backend",
+		},
+		SourceNamespace: "tenant-a",
+		RemoteRef: &pb.PushSecretRemoteRef{
+			RemoteKey: "remote/path",
+			Property:  "property",
+		},
+	})
+	if err != nil {
+		t.Fatalf("DeleteSecret() error = %v", err)
+	}
+
+	if fakeClient.deleteSecretRef.GetRemoteKey() != "remote/path" || fakeClient.deleteSecretRef.GetProperty() != "property" {
+		t.Fatalf("unexpected delete ref: %#v", fakeClient.deleteSecretRef)
+	}
+
+	resp, err := server.SecretExists(context.Background(), &pb.SecretExistsRequest{
+		ProviderRef: &pb.ProviderReference{
+			ApiVersion: "provider.external-secrets.io/v2alpha1",
+			Kind:       "Fake",
+			Name:       "backend",
+		},
+		SourceNamespace: "tenant-a",
+		RemoteRef: &pb.PushSecretRemoteRef{
+			RemoteKey: "remote/path",
+			Property:  "property",
+		},
+	})
+	if err != nil {
+		t.Fatalf("SecretExists() error = %v", err)
+	}
+
+	if !resp.Exists {
+		t.Fatal("expected exists response to be true")
+	}
+	if fakeClient.secretExistsRef.GetRemoteKey() != "remote/path" || fakeClient.secretExistsRef.GetProperty() != "property" {
+		t.Fatalf("unexpected exists ref: %#v", fakeClient.secretExistsRef)
+	}
+}
+
+func TestServerValidateMapsReadyUnknownAndErrorResults(t *testing.T) {
+	t.Run("ready", func(t *testing.T) {
+		resp := runValidateTest(t, esv1.ValidationResultReady, nil)
+		if !resp.Valid {
+			t.Fatalf("expected valid response, got %#v", resp)
+		}
+	})
+
+	t.Run("unknown", func(t *testing.T) {
+		resp := runValidateTest(t, esv1.ValidationResultUnknown, nil)
+		if !resp.Valid {
+			t.Fatalf("expected unknown to be treated as valid, got %#v", resp)
+		}
+	})
+
+	t.Run("error_result", func(t *testing.T) {
+		resp := runValidateTest(t, esv1.ValidationResultError, nil)
+		if resp.Valid {
+			t.Fatalf("expected invalid response, got %#v", resp)
+		}
+	})
+
+	t.Run("error", func(t *testing.T) {
+		validateErr := errors.New("invalid credentials")
+		resp := runValidateTest(t, esv1.ValidationResultError, validateErr)
+		if resp.Valid || resp.Error != "invalid credentials" {
+			t.Fatalf("unexpected response: %#v", resp)
+		}
+	})
+}
+
+func TestServerCapabilitiesMapsProviderCapabilities(t *testing.T) {
+	testCases := []struct {
+		name       string
+		caps       esv1.SecretStoreCapabilities
+		expectedPB pb.SecretStoreCapabilities
+	}{
+		{name: "read_only", caps: esv1.SecretStoreReadOnly, expectedPB: pb.SecretStoreCapabilities_READ_ONLY},
+		{name: "write_only", caps: esv1.SecretStoreWriteOnly, expectedPB: pb.SecretStoreCapabilities_WRITE_ONLY},
+		{name: "read_write", caps: esv1.SecretStoreReadWrite, expectedPB: pb.SecretStoreCapabilities_READ_WRITE},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			server := NewServer(nil, ProviderMapping{
+				schema.GroupVersionKind{Group: "provider.external-secrets.io", Version: "v2alpha1", Kind: "Fake"}: &fakeProviderInterface{
+					caps: tc.caps,
+					newClient: func(context.Context, esv1.GenericStore, client.Client, string) (esv1.SecretsClient, error) {
+						return &fakeSecretsClient{}, nil
+					},
+				},
+			}, (&specMapperRecorder{}).mapRef)
+
+			resp, err := server.Capabilities(context.Background(), &pb.CapabilitiesRequest{
+				ProviderRef: &pb.ProviderReference{
+					ApiVersion: "provider.external-secrets.io/v2alpha1",
+					Kind:       "Fake",
+					Name:       "backend",
+				},
+			})
+			if err != nil {
+				t.Fatalf("Capabilities() error = %v", err)
+			}
+			if resp.Capabilities != tc.expectedPB {
+				t.Fatalf("expected %v, got %v", tc.expectedPB, resp.Capabilities)
+			}
+		})
+	}
+}
+
+func TestServerRejectsInvalidRequests(t *testing.T) {
+	server := NewServer(nil, ProviderMapping{}, (&specMapperRecorder{}).mapRef)
+
+	testCases := []struct {
+		name string
+		call func() error
+		want string
+	}{
+		{
+			name: "get_secret_nil_request",
+			call: func() error {
+				_, err := server.GetSecret(context.Background(), nil)
+				return err
+			},
+			want: "request or remote ref is nil",
+		},
+		{
+			name: "get_secret_empty_source_namespace",
+			call: func() error {
+				_, err := server.GetSecret(context.Background(), &pb.GetSecretRequest{
+					RemoteRef: &pb.ExternalSecretDataRemoteRef{Key: "sample"},
+				})
+				return err
+			},
+			want: "source namespace is required",
+		},
+		{
+			name: "push_secret_nil_payload",
+			call: func() error {
+				_, err := server.PushSecret(context.Background(), &pb.PushSecretRequest{
+					SourceNamespace: "tenant-a",
+				})
+				return err
+			},
+			want: "request or push secret data is nil",
+		},
+		{
+			name: "validate_nil_request",
+			call: func() error {
+				_, err := server.Validate(context.Background(), nil)
+				return err
+			},
+			want: "request is nil",
+		},
+		{
+			name: "capabilities_nil_request",
+			call: func() error {
+				_, err := server.Capabilities(context.Background(), nil)
+				return err
+			},
+			want: "request is nil",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			err := tc.call()
+			if err == nil || err.Error() != tc.want {
+				t.Fatalf("expected %q, got %v", tc.want, err)
+			}
+		})
+	}
+}
+
+func runValidateTest(t *testing.T, result esv1.ValidationResult, validateErr error) *pb.ValidateResponse {
+	t.Helper()
+
+	server := NewServer(nil, ProviderMapping{
+		schema.GroupVersionKind{Group: "provider.external-secrets.io", Version: "v2alpha1", Kind: "Fake"}: &fakeProviderInterface{
+			caps: esv1.SecretStoreReadWrite,
+			newClient: func(context.Context, esv1.GenericStore, client.Client, string) (esv1.SecretsClient, error) {
+				return &fakeSecretsClient{
+					validateResult: result,
+					validateErr:    validateErr,
+				}, nil
+			},
+		},
+	}, (&specMapperRecorder{
+		spec: &esv1.SecretStoreSpec{Provider: &esv1.SecretStoreProvider{Fake: &esv1.FakeProvider{}}},
+	}).mapRef)
+
+	resp, err := server.Validate(context.Background(), &pb.ValidateRequest{
+		ProviderRef: &pb.ProviderReference{
+			ApiVersion: "provider.external-secrets.io/v2alpha1",
+			Kind:       "Fake",
+			Name:       "backend",
+		},
+		SourceNamespace: "tenant-a",
+	})
+	if err != nil {
+		t.Fatalf("Validate() error = %v", err)
+	}
+	return resp
+}

+ 0 - 451
providers/v2/adapter/v1_to_v2.go

@@ -1,451 +0,0 @@
-/*
-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 adapter adapts v1 provider implementations to the v2 gRPC interface.
-package adapter
-
-import (
-	"context"
-	"fmt"
-	"strings"
-
-	corev1 "k8s.io/api/core/v1"
-	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
-	"k8s.io/apimachinery/pkg/runtime"
-	"k8s.io/apimachinery/pkg/runtime/schema"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-
-	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
-	genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1"
-	genpb "github.com/external-secrets/external-secrets/proto/generator"
-	pb "github.com/external-secrets/external-secrets/proto/provider"
-	"github.com/external-secrets/external-secrets/providers/v2/adapter/store"
-)
-
-// V1AdapterServer wraps v1 providers and generators and exposes them as v2 gRPC services.
-// This allows existing v1 provider and generator implementations to be used in the v2 architecture.
-type V1AdapterServer struct {
-	pb.UnimplementedSecretStoreProviderServer
-	genpb.UnimplementedGeneratorProviderServer
-	kubeClient client.Client
-	scheme     *runtime.Scheme
-
-	// we support multiple v1 providers, so we need to map the v2 provider
-	// with apiVersion+kind to the corresponding v1 provider
-	resourceMapping ProviderMapping
-	specMapper      SpecMapper
-
-	// we support multiple v1 generators, so we need to map the v2 generator
-	// with apiVersion+kind to the corresponding v1 generator
-	generatorMapping GeneratorMapping
-}
-
-// ProviderMapping maps Kubernetes resources to their provider implementations.
-type ProviderMapping map[schema.GroupVersionKind]esv1.ProviderInterface
-
-// GeneratorMapping maps Kubernetes resources to their generator implementations.
-type GeneratorMapping map[schema.GroupVersionKind]genv1alpha1.Generator
-
-// SpecMapper maps a provider reference to a SecretStoreSpec.
-// This is used to create a synthetic store for the v1 provider.
-type SpecMapper func(ref *pb.ProviderReference, sourceNamespace string) (*esv1.SecretStoreSpec, error)
-
-// NewAdapterServer creates a new V1AdapterServer that wraps v1 providers and generators.
-func NewAdapterServer(kubeClient client.Client, scheme *runtime.Scheme, resourceMapping ProviderMapping, specMapping SpecMapper, generatorMapping GeneratorMapping) *V1AdapterServer {
-	return &V1AdapterServer{
-		kubeClient:       kubeClient,
-		scheme:           scheme,
-		resourceMapping:  resourceMapping,
-		specMapper:       specMapping,
-		generatorMapping: generatorMapping,
-	}
-}
-
-func (s *V1AdapterServer) resolveProvider(ref *pb.ProviderReference) (esv1.ProviderInterface, error) {
-	if ref == nil {
-		return nil, fmt.Errorf("provider reference is nil")
-	}
-
-	splitted := strings.Split(ref.ApiVersion, "/")
-	if len(splitted) != 2 {
-		return nil, fmt.Errorf("invalid api version: %s", ref.ApiVersion)
-	}
-	group := splitted[0]
-	version := splitted[1]
-
-	key := schema.GroupVersionKind{
-		Group:   group,
-		Version: version,
-		Kind:    ref.Kind,
-	}
-	v1Provider, ok := s.resourceMapping[key]
-	if !ok {
-		return nil, fmt.Errorf("resource mapping not found for %q", key)
-	}
-	return v1Provider, nil
-}
-
-func (s *V1AdapterServer) getClient(ctx context.Context, ref *pb.ProviderReference, namespace string) (esv1.SecretsClient, error) {
-	if ref == nil {
-		return nil, fmt.Errorf("request or remote ref is nil")
-	}
-
-	spec, err := s.specMapper(ref, namespace)
-	if err != nil {
-		return nil, fmt.Errorf("failed to map provider reference to spec: %w", err)
-	}
-	// TODO: support cluster scoped Provider
-	syntheticStore, err := store.NewSyntheticStore(spec, namespace)
-	if err != nil {
-		return nil, fmt.Errorf("failed to create synthetic store: %w", err)
-	}
-	provider, err := s.resolveProvider(ref)
-	if err != nil {
-		return nil, fmt.Errorf("failed to resolve provider: %w", err)
-	}
-	return provider.NewClient(ctx, syntheticStore, s.kubeClient, namespace)
-}
-
-// GetSecret retrieves a single secret from the provider.
-func (s *V1AdapterServer) GetSecret(ctx context.Context, req *pb.GetSecretRequest) (*pb.GetSecretResponse, error) {
-	if req == nil || req.RemoteRef == nil {
-		return nil, fmt.Errorf("request or remote ref is nil")
-	}
-	if err := validateSourceNamespace(req.SourceNamespace); err != nil {
-		return nil, err
-	}
-	client, err := s.getClient(ctx, req.ProviderRef, req.SourceNamespace)
-	if err != nil {
-		return nil, fmt.Errorf("failed to get client: %w", err)
-	}
-	defer func() { _ = client.Close(ctx) }()
-
-	// Convert protobuf remote ref to v1 remote ref
-	ref := esv1.ExternalSecretDataRemoteRef{
-		Key:      req.RemoteRef.Key,
-		Version:  req.RemoteRef.Version,
-		Property: req.RemoteRef.Property,
-	}
-	if req.RemoteRef.DecodingStrategy != "" {
-		ref.DecodingStrategy = esv1.ExternalSecretDecodingStrategy(req.RemoteRef.DecodingStrategy)
-	}
-	if req.RemoteRef.MetadataPolicy != "" {
-		ref.MetadataPolicy = esv1.ExternalSecretMetadataPolicy(req.RemoteRef.MetadataPolicy)
-	}
-
-	value, err := client.GetSecret(ctx, ref)
-	if err != nil {
-		return nil, fmt.Errorf("failed to get secret: %w", err)
-	}
-
-	return &pb.GetSecretResponse{
-		Value: value,
-	}, nil
-}
-
-// GetSecretMap retrieves multiple key/value pairs from a single secret object.
-func (s *V1AdapterServer) GetSecretMap(ctx context.Context, req *pb.GetSecretMapRequest) (*pb.GetSecretMapResponse, error) {
-	if req == nil || req.RemoteRef == nil {
-		return nil, fmt.Errorf("request or remote ref is nil")
-	}
-	if err := validateSourceNamespace(req.SourceNamespace); err != nil {
-		return nil, err
-	}
-
-	client, err := s.getClient(ctx, req.ProviderRef, req.SourceNamespace)
-	if err != nil {
-		return nil, fmt.Errorf("failed to get client: %w", err)
-	}
-	defer func() { _ = client.Close(ctx) }()
-
-	ref := esv1.ExternalSecretDataRemoteRef{
-		Key:      req.RemoteRef.Key,
-		Version:  req.RemoteRef.Version,
-		Property: req.RemoteRef.Property,
-	}
-	if req.RemoteRef.DecodingStrategy != "" {
-		ref.DecodingStrategy = esv1.ExternalSecretDecodingStrategy(req.RemoteRef.DecodingStrategy)
-	}
-	if req.RemoteRef.MetadataPolicy != "" {
-		ref.MetadataPolicy = esv1.ExternalSecretMetadataPolicy(req.RemoteRef.MetadataPolicy)
-	}
-
-	secrets, err := client.GetSecretMap(ctx, ref)
-	if err != nil {
-		return nil, fmt.Errorf("failed to get secret map: %w", err)
-	}
-
-	return &pb.GetSecretMapResponse{
-		Secrets: secrets,
-	}, nil
-}
-
-// PushSecret writes a secret to the provider.
-func (s *V1AdapterServer) PushSecret(ctx context.Context, req *pb.PushSecretRequest) (*pb.PushSecretResponse, error) {
-	if req == nil || req.PushSecretData == nil {
-		return nil, fmt.Errorf("request or push secret data is nil")
-	}
-	if err := validateSourceNamespace(req.SourceNamespace); err != nil {
-		return nil, err
-	}
-
-	client, err := s.getClient(ctx, req.ProviderRef, req.SourceNamespace)
-	if err != nil {
-		return nil, fmt.Errorf("failed to get client: %w", err)
-	}
-	defer func() { _ = client.Close(ctx) }()
-
-	// Convert map[string][]byte to *corev1.Secret
-	secret := &corev1.Secret{
-		Data: req.SecretData,
-		Type: corev1.SecretTypeOpaque,
-	}
-
-	// Convert protobuf PushSecretData to v1 PushSecretData
-	pushData := &pushSecretData{
-		property:  req.PushSecretData.Property,
-		secretKey: req.PushSecretData.SecretKey,
-		remoteKey: req.PushSecretData.RemoteKey,
-		metadata:  req.PushSecretData.Metadata,
-	}
-
-	// Call v1 PushSecret
-	if err := client.PushSecret(ctx, secret, pushData); err != nil {
-		return nil, fmt.Errorf("failed to push secret: %w", err)
-	}
-
-	return &pb.PushSecretResponse{}, nil
-}
-
-// DeleteSecret deletes a secret from the provider.
-func (s *V1AdapterServer) DeleteSecret(ctx context.Context, req *pb.DeleteSecretRequest) (*pb.DeleteSecretResponse, error) {
-	if req == nil || req.RemoteRef == nil {
-		return nil, fmt.Errorf("request or remote ref is nil")
-	}
-	if err := validateSourceNamespace(req.SourceNamespace); err != nil {
-		return nil, err
-	}
-
-	client, err := s.getClient(ctx, req.ProviderRef, req.SourceNamespace)
-	if err != nil {
-		return nil, fmt.Errorf("failed to get client: %w", err)
-	}
-	defer func() { _ = client.Close(ctx) }()
-
-	// Convert protobuf remote ref to v1 PushSecretRemoteRef
-	remoteRef := &pushSecretRemoteRef{
-		remoteKey: req.RemoteRef.RemoteKey,
-		property:  req.RemoteRef.Property,
-	}
-
-	// Call v1 DeleteSecret
-	if err := client.DeleteSecret(ctx, remoteRef); err != nil {
-		return nil, fmt.Errorf("failed to delete secret: %w", err)
-	}
-
-	return &pb.DeleteSecretResponse{}, nil
-}
-
-// SecretExists checks if a secret exists in the provider.
-func (s *V1AdapterServer) SecretExists(ctx context.Context, req *pb.SecretExistsRequest) (*pb.SecretExistsResponse, error) {
-	if req == nil || req.RemoteRef == nil {
-		return nil, fmt.Errorf("request or remote ref is nil")
-	}
-	if err := validateSourceNamespace(req.SourceNamespace); err != nil {
-		return nil, err
-	}
-
-	client, err := s.getClient(ctx, req.ProviderRef, req.SourceNamespace)
-	if err != nil {
-		return nil, fmt.Errorf("failed to get client: %w", err)
-	}
-	defer func() { _ = client.Close(ctx) }()
-
-	// Convert protobuf remote ref to v1 PushSecretRemoteRef
-	remoteRef := &pushSecretRemoteRef{
-		remoteKey: req.RemoteRef.RemoteKey,
-		property:  req.RemoteRef.Property,
-	}
-
-	// Call v1 SecretExists
-	exists, err := client.SecretExists(ctx, remoteRef)
-	if err != nil {
-		return nil, fmt.Errorf("failed to check if secret exists: %w", err)
-	}
-
-	return &pb.SecretExistsResponse{
-		Exists: exists,
-	}, nil
-}
-
-// GetAllSecrets retrieves multiple secrets from the provider.
-func (s *V1AdapterServer) GetAllSecrets(ctx context.Context, req *pb.GetAllSecretsRequest) (*pb.GetAllSecretsResponse, error) {
-	if req == nil || req.Find == nil {
-		return nil, fmt.Errorf("request or find criteria is nil")
-	}
-	if err := validateSourceNamespace(req.SourceNamespace); err != nil {
-		return nil, err
-	}
-
-	client, err := s.getClient(ctx, req.ProviderRef, req.SourceNamespace)
-	if err != nil {
-		return nil, fmt.Errorf("failed to get client: %w", err)
-	}
-	defer func() { _ = client.Close(ctx) }()
-
-	// Convert protobuf ExternalSecretFind to v1 ExternalSecretFind
-	find := esv1.ExternalSecretFind{
-		Tags:               req.Find.Tags,
-		ConversionStrategy: esv1.ExternalSecretConversionStrategy(req.Find.ConversionStrategy),
-		DecodingStrategy:   esv1.ExternalSecretDecodingStrategy(req.Find.DecodingStrategy),
-	}
-
-	// Convert Path from string to *string
-	if req.Find.Path != "" {
-		path := req.Find.Path
-		find.Path = &path
-	}
-
-	if req.Find.Name != nil {
-		find.Name = &esv1.FindName{
-			RegExp: req.Find.Name.Regexp,
-		}
-	}
-
-	// Call v1 GetAllSecrets
-	secrets, err := client.GetAllSecrets(ctx, find)
-	if err != nil {
-		return nil, fmt.Errorf("failed to get all secrets: %w", err)
-	}
-
-	return &pb.GetAllSecretsResponse{
-		Secrets: secrets,
-	}, nil
-}
-
-// Validate checks if the provider configuration is valid.
-func (s *V1AdapterServer) Validate(ctx context.Context, req *pb.ValidateRequest) (*pb.ValidateResponse, error) {
-	if req == nil {
-		return nil, fmt.Errorf("request is nil")
-	}
-
-	client, err := s.getClient(ctx, req.ProviderRef, req.SourceNamespace)
-	if err != nil {
-		return nil, fmt.Errorf("failed to get client: %w", err)
-	}
-	defer func() { _ = client.Close(ctx) }()
-
-	result, err := client.Validate()
-	if err != nil {
-		return &pb.ValidateResponse{
-			Valid: false,
-			Error: err.Error(),
-		}, nil
-	}
-
-	var valid bool
-	switch result {
-	case esv1.ValidationResultReady:
-		valid = true
-	case esv1.ValidationResultUnknown:
-		valid = true // Unknown is treated as valid but warns
-	case esv1.ValidationResultError:
-		valid = false
-	}
-
-	return &pb.ValidateResponse{
-		Valid:    valid,
-		Warnings: []string{},
-	}, nil
-}
-
-// Capabilities returns the capabilities of the provider.
-// TODO: remove / rewrite capabilities:
-// the provider should advertise what providers/generators it supports.
-func (s *V1AdapterServer) Capabilities(_ context.Context, req *pb.CapabilitiesRequest) (*pb.CapabilitiesResponse, error) {
-	if req == nil {
-		return nil, fmt.Errorf("request is nil")
-	}
-
-	provider, err := s.resolveProvider(req.ProviderRef)
-	if err != nil {
-		return nil, fmt.Errorf("failed to resolve provider: %w", err)
-	}
-	caps := provider.Capabilities()
-	var pbCaps pb.SecretStoreCapabilities
-	switch caps {
-	case esv1.SecretStoreReadOnly:
-		pbCaps = pb.SecretStoreCapabilities_READ_ONLY
-	case esv1.SecretStoreWriteOnly:
-		pbCaps = pb.SecretStoreCapabilities_WRITE_ONLY
-	case esv1.SecretStoreReadWrite:
-		pbCaps = pb.SecretStoreCapabilities_READ_WRITE
-	default:
-		pbCaps = pb.SecretStoreCapabilities_READ_ONLY
-	}
-
-	return &pb.CapabilitiesResponse{
-		Capabilities: pbCaps,
-	}, nil
-}
-
-func validateSourceNamespace(sourceNamespace string) error {
-	if sourceNamespace == "" {
-		return fmt.Errorf("source namespace is required")
-	}
-	return nil
-}
-
-// pushSecretData implements esv1.PushSecretData.
-type pushSecretData struct {
-	property  string
-	secretKey string
-	remoteKey string
-	metadata  []byte
-}
-
-func (p *pushSecretData) GetProperty() string {
-	return p.property
-}
-
-func (p *pushSecretData) GetSecretKey() string {
-	return p.secretKey
-}
-
-func (p *pushSecretData) GetRemoteKey() string {
-	return p.remoteKey
-}
-
-func (p *pushSecretData) GetMetadata() *apiextensionsv1.JSON {
-	if len(p.metadata) == 0 {
-		return nil
-	}
-	return &apiextensionsv1.JSON{
-		Raw: p.metadata,
-	}
-}
-
-// pushSecretRemoteRef implements esv1.PushSecretRemoteRef.
-type pushSecretRemoteRef struct {
-	remoteKey string
-	property  string
-}
-
-func (p *pushSecretRemoteRef) GetRemoteKey() string {
-	return p.remoteKey
-}
-
-func (p *pushSecretRemoteRef) GetProperty() string {
-	return p.property
-}

+ 255 - 64
providers/v2/common/grpc/client_test.go

@@ -29,47 +29,96 @@ import (
 
 const bufSize = 1024 * 1024
 
-// mockServer is a simple mock implementation of the SecretStoreProvider service
 type mockServer struct {
 	pb.UnimplementedSecretStoreProviderServer
+
 	getSecretResponse *pb.GetSecretResponse
-	getSecretMap      map[string][]byte
-	validateResponse  *pb.ValidateResponse
+	getSecretRequest  *pb.GetSecretRequest
+
+	getSecretMapResponse *pb.GetSecretMapResponse
+	getSecretMapRequest  *pb.GetSecretMapRequest
+
+	getAllSecretsResponse *pb.GetAllSecretsResponse
+	getAllSecretsRequest  *pb.GetAllSecretsRequest
+
+	pushSecretRequest *pb.PushSecretRequest
+	deleteRequest     *pb.DeleteSecretRequest
+	existsRequest     *pb.SecretExistsRequest
+	existsResponse    *pb.SecretExistsResponse
+
+	validateResponse *pb.ValidateResponse
+	validateRequest  *pb.ValidateRequest
+
+	capabilitiesResponse *pb.CapabilitiesResponse
+	capabilitiesRequest  *pb.CapabilitiesRequest
 }
 
-func (m *mockServer) GetSecret(ctx context.Context, req *pb.GetSecretRequest) (*pb.GetSecretResponse, error) {
+func (m *mockServer) GetSecret(_ context.Context, req *pb.GetSecretRequest) (*pb.GetSecretResponse, error) {
+	m.getSecretRequest = req
 	if m.getSecretResponse != nil {
 		return m.getSecretResponse, nil
 	}
-	return &pb.GetSecretResponse{
-		Value: []byte("test-secret-value"),
-	}, nil
+	return &pb.GetSecretResponse{Value: []byte("test-secret-value")}, nil
 }
 
-func (m *mockServer) GetSecretMap(ctx context.Context, req *pb.GetSecretMapRequest) (*pb.GetSecretMapResponse, error) {
-	if m.getSecretMap != nil {
-		return &pb.GetSecretMapResponse{Secrets: m.getSecretMap}, nil
+func (m *mockServer) GetSecretMap(_ context.Context, req *pb.GetSecretMapRequest) (*pb.GetSecretMapResponse, error) {
+	m.getSecretMapRequest = req
+	if m.getSecretMapResponse != nil {
+		return m.getSecretMapResponse, nil
 	}
 	return &pb.GetSecretMapResponse{
-		Secrets: map[string][]byte{
-			"foo": []byte("bar"),
-		},
+		Secrets: map[string][]byte{"foo": []byte("bar")},
 	}, nil
 }
 
-func (m *mockServer) Validate(ctx context.Context, req *pb.ValidateRequest) (*pb.ValidateResponse, error) {
+func (m *mockServer) GetAllSecrets(_ context.Context, req *pb.GetAllSecretsRequest) (*pb.GetAllSecretsResponse, error) {
+	m.getAllSecretsRequest = req
+	if m.getAllSecretsResponse != nil {
+		return m.getAllSecretsResponse, nil
+	}
+	return &pb.GetAllSecretsResponse{
+		Secrets: map[string][]byte{"db-password": []byte("value")},
+	}, nil
+}
+
+func (m *mockServer) PushSecret(_ context.Context, req *pb.PushSecretRequest) (*pb.PushSecretResponse, error) {
+	m.pushSecretRequest = req
+	return &pb.PushSecretResponse{}, nil
+}
+
+func (m *mockServer) DeleteSecret(_ context.Context, req *pb.DeleteSecretRequest) (*pb.DeleteSecretResponse, error) {
+	m.deleteRequest = req
+	return &pb.DeleteSecretResponse{}, nil
+}
+
+func (m *mockServer) SecretExists(_ context.Context, req *pb.SecretExistsRequest) (*pb.SecretExistsResponse, error) {
+	m.existsRequest = req
+	if m.existsResponse != nil {
+		return m.existsResponse, nil
+	}
+	return &pb.SecretExistsResponse{Exists: true}, nil
+}
+
+func (m *mockServer) Validate(_ context.Context, req *pb.ValidateRequest) (*pb.ValidateResponse, error) {
+	m.validateRequest = req
 	if m.validateResponse != nil {
 		return m.validateResponse, nil
 	}
-	return &pb.ValidateResponse{
-		Valid: true,
-	}, nil
+	return &pb.ValidateResponse{Valid: true}, nil
+}
+
+func (m *mockServer) Capabilities(_ context.Context, req *pb.CapabilitiesRequest) (*pb.CapabilitiesResponse, error) {
+	m.capabilitiesRequest = req
+	if m.capabilitiesResponse != nil {
+		return m.capabilitiesResponse, nil
+	}
+	return &pb.CapabilitiesResponse{Capabilities: pb.SecretStoreCapabilities_READ_WRITE}, nil
 }
 
-// setupTestServer creates an in-memory gRPC server for testing
 func setupTestServer(t *testing.T, mock *mockServer) (*grpc.ClientConn, func()) {
-	lis := bufconn.Listen(bufSize)
+	t.Helper()
 
+	lis := bufconn.Listen(bufSize)
 	baseServer := grpc.NewServer()
 	pb.RegisterSecretStoreProviderServer(baseServer, mock)
 	go func() {
@@ -88,49 +137,208 @@ func setupTestServer(t *testing.T, mock *mockServer) (*grpc.ClientConn, func())
 	}
 
 	cleanup := func() {
-		conn.Close()
+		_ = conn.Close()
 		baseServer.Stop()
-		lis.Close()
+		_ = lis.Close()
 	}
 
 	return conn, cleanup
 }
 
-func TestClient_GetSecret(t *testing.T) {
+func TestClientGetSecretSendsProviderReferenceAndNamespace(t *testing.T) {
 	mock := &mockServer{}
 	conn, cleanup := setupTestServer(t, mock)
 	defer cleanup()
 
 	client := NewClientWithConn(conn)
-
+	providerRef := &pb.ProviderReference{Name: "provider", Namespace: "config-ns"}
 	ref := esv1.ExternalSecretDataRemoteRef{
-		Key:      "test-key",
-		Version:  "v1",
-		Property: "password",
+		Key:              "test-key",
+		Version:          "v1",
+		Property:         "password",
+		DecodingStrategy: esv1.ExternalSecretDecodeBase64,
+		MetadataPolicy:   esv1.ExternalSecretMetadataPolicyFetch,
 	}
 
-	value, err := client.GetSecret(context.Background(), ref, &pb.ProviderReference{Name: "provider"}, "default")
+	value, err := client.GetSecret(context.Background(), ref, providerRef, "tenant-a")
 	if err != nil {
 		t.Fatalf("GetSecret failed: %v", err)
 	}
 
 	if string(value) != "test-secret-value" {
-		t.Errorf("Expected 'test-secret-value', got '%s'", string(value))
+		t.Fatalf("expected test-secret-value, got %q", string(value))
+	}
+	if mock.getSecretRequest == nil {
+		t.Fatal("expected get secret request to be recorded")
+	}
+	assertProviderRefEqual(t, mock.getSecretRequest.ProviderRef, providerRef)
+	if mock.getSecretRequest.SourceNamespace != "tenant-a" {
+		t.Fatalf("unexpected source namespace: %q", mock.getSecretRequest.SourceNamespace)
+	}
+	if mock.getSecretRequest.RemoteRef.Key != "test-key" || mock.getSecretRequest.RemoteRef.Version != "v1" || mock.getSecretRequest.RemoteRef.Property != "password" {
+		t.Fatalf("unexpected remote ref: %#v", mock.getSecretRequest.RemoteRef)
+	}
+	if mock.getSecretRequest.RemoteRef.DecodingStrategy != string(esv1.ExternalSecretDecodeBase64) {
+		t.Fatalf("unexpected decoding strategy: %q", mock.getSecretRequest.RemoteRef.DecodingStrategy)
+	}
+	if mock.getSecretRequest.RemoteRef.MetadataPolicy != string(esv1.ExternalSecretMetadataPolicyFetch) {
+		t.Fatalf("unexpected metadata policy: %q", mock.getSecretRequest.RemoteRef.MetadataPolicy)
 	}
 }
 
-func TestClient_Validate(t *testing.T) {
+func TestClientGetSecretMapSendsProviderReferenceAndNamespace(t *testing.T) {
+	mock := &mockServer{
+		getSecretMapResponse: &pb.GetSecretMapResponse{
+			Secrets: map[string][]byte{"a": []byte("b")},
+		},
+	}
+	conn, cleanup := setupTestServer(t, mock)
+	defer cleanup()
+
+	client := NewClientWithConn(conn)
+	providerRef := &pb.ProviderReference{Name: "provider", Namespace: "config-ns"}
+
+	value, err := client.GetSecretMap(context.Background(), esv1.ExternalSecretDataRemoteRef{Key: "test-key"}, providerRef, "tenant-a")
+	if err != nil {
+		t.Fatalf("GetSecretMap failed: %v", err)
+	}
+
+	if string(value["a"]) != "b" {
+		t.Fatalf("expected map[a]=b, got %#v", value)
+	}
+	if mock.getSecretMapRequest == nil {
+		t.Fatal("expected get secret map request to be recorded")
+	}
+	if mock.getSecretMapRequest.SourceNamespace != "tenant-a" {
+		t.Fatalf("unexpected request: %#v", mock.getSecretMapRequest)
+	}
+	assertProviderRefEqual(t, mock.getSecretMapRequest.ProviderRef, providerRef)
+}
+
+func TestClientGetAllSecretsSendsFindCriteria(t *testing.T) {
+	mock := &mockServer{}
+	conn, cleanup := setupTestServer(t, mock)
+	defer cleanup()
+
+	client := NewClientWithConn(conn)
+	providerRef := &pb.ProviderReference{Name: "provider", Namespace: "config-ns"}
+	path := "/team-a"
+
+	secrets, err := client.GetAllSecrets(context.Background(), esv1.ExternalSecretFind{
+		Tags: map[string]string{"team": "a"},
+		Path: &path,
+		Name: &esv1.FindName{RegExp: "db-.*"},
+	}, providerRef, "tenant-a")
+	if err != nil {
+		t.Fatalf("GetAllSecrets failed: %v", err)
+	}
+
+	if string(secrets["db-password"]) != "value" {
+		t.Fatalf("unexpected secrets: %#v", secrets)
+	}
+	if mock.getAllSecretsRequest == nil {
+		t.Fatal("expected get all secrets request to be recorded")
+	}
+	if mock.getAllSecretsRequest.SourceNamespace != "tenant-a" {
+		t.Fatalf("unexpected request: %#v", mock.getAllSecretsRequest)
+	}
+	assertProviderRefEqual(t, mock.getAllSecretsRequest.ProviderRef, providerRef)
+	if mock.getAllSecretsRequest.Find.Path != "/team-a" {
+		t.Fatalf("unexpected path: %q", mock.getAllSecretsRequest.Find.Path)
+	}
+	if mock.getAllSecretsRequest.Find.Name == nil || mock.getAllSecretsRequest.Find.Name.Regexp != "db-.*" {
+		t.Fatalf("unexpected name matcher: %#v", mock.getAllSecretsRequest.Find.Name)
+	}
+}
+
+func TestClientPushDeleteExistsAndCapabilitiesSendProviderReferenceAndNamespace(t *testing.T) {
+	mock := &mockServer{}
+	conn, cleanup := setupTestServer(t, mock)
+	defer cleanup()
+
+	client := NewClientWithConn(conn)
+	providerRef := &pb.ProviderReference{Name: "provider", Namespace: "config-ns"}
+
+	err := client.PushSecret(context.Background(), map[string][]byte{"token": []byte("value")}, &pb.PushSecretData{
+		RemoteKey: "remote/path",
+		SecretKey: "token",
+		Property:  "property",
+		Metadata:  []byte(`{"owner":"eso"}`),
+	}, providerRef, "tenant-a")
+	if err != nil {
+		t.Fatalf("PushSecret failed: %v", err)
+	}
+	if mock.pushSecretRequest == nil {
+		t.Fatal("expected push secret request to be recorded")
+	}
+	if mock.pushSecretRequest.SourceNamespace != "tenant-a" {
+		t.Fatalf("unexpected push request: %#v", mock.pushSecretRequest)
+	}
+	assertProviderRefEqual(t, mock.pushSecretRequest.ProviderRef, providerRef)
+	if string(mock.pushSecretRequest.SecretData["token"]) != "value" {
+		t.Fatalf("unexpected pushed secret data: %#v", mock.pushSecretRequest.SecretData)
+	}
+
+	err = client.DeleteSecret(context.Background(), &pb.PushSecretRemoteRef{
+		RemoteKey: "remote/path",
+		Property:  "property",
+	}, providerRef, "tenant-a")
+	if err != nil {
+		t.Fatalf("DeleteSecret failed: %v", err)
+	}
+	if mock.deleteRequest == nil || mock.deleteRequest.SourceNamespace != "tenant-a" {
+		t.Fatalf("unexpected delete request: %#v", mock.deleteRequest)
+	}
+	assertProviderRefEqual(t, mock.deleteRequest.ProviderRef, providerRef)
+
+	exists, err := client.SecretExists(context.Background(), &pb.PushSecretRemoteRef{
+		RemoteKey: "remote/path",
+		Property:  "property",
+	}, providerRef, "tenant-a")
+	if err != nil {
+		t.Fatalf("SecretExists failed: %v", err)
+	}
+	if !exists {
+		t.Fatal("expected exists to be true")
+	}
+	if mock.existsRequest == nil || mock.existsRequest.SourceNamespace != "tenant-a" {
+		t.Fatalf("unexpected exists request: %#v", mock.existsRequest)
+	}
+	assertProviderRefEqual(t, mock.existsRequest.ProviderRef, providerRef)
+
+	caps, err := client.Capabilities(context.Background(), providerRef, "tenant-a")
+	if err != nil {
+		t.Fatalf("Capabilities failed: %v", err)
+	}
+	if caps != pb.SecretStoreCapabilities_READ_WRITE {
+		t.Fatalf("expected READ_WRITE, got %v", caps)
+	}
+	if mock.capabilitiesRequest == nil || mock.capabilitiesRequest.SourceNamespace != "tenant-a" {
+		t.Fatalf("unexpected capabilities request: %#v", mock.capabilitiesRequest)
+	}
+	assertProviderRefEqual(t, mock.capabilitiesRequest.ProviderRef, providerRef)
+}
+
+func TestClientValidate(t *testing.T) {
 	t.Run("success", func(t *testing.T) {
 		mock := &mockServer{}
 		conn, cleanup := setupTestServer(t, mock)
 		defer cleanup()
 
 		client := NewClientWithConn(conn)
+		providerRef := &pb.ProviderReference{Name: "provider", Namespace: "config-ns"}
 
-		err := client.Validate(context.Background(), &pb.ProviderReference{Name: "provider"}, "default")
+		err := client.Validate(context.Background(), providerRef, "tenant-a")
 		if err != nil {
 			t.Fatalf("Validate failed: %v", err)
 		}
+		if mock.validateRequest == nil {
+			t.Fatal("expected validate request to be recorded")
+		}
+		if mock.validateRequest.SourceNamespace != "tenant-a" {
+			t.Fatalf("unexpected validate request: %#v", mock.validateRequest)
+		}
+		assertProviderRefEqual(t, mock.validateRequest.ProviderRef, providerRef)
 	})
 
 	t.Run("validation_error", func(t *testing.T) {
@@ -145,59 +353,42 @@ func TestClient_Validate(t *testing.T) {
 
 		client := NewClientWithConn(conn)
 
-		err := client.Validate(context.Background(), &pb.ProviderReference{Name: "provider"}, "default")
+		err := client.Validate(context.Background(), &pb.ProviderReference{Name: "provider"}, "tenant-a")
 		if err == nil {
 			t.Fatal("Expected validation to fail, but it succeeded")
 		}
-
 		if err.Error() != "provider validation failed: invalid credentials" {
-			t.Errorf("Unexpected error message: %v", err)
+			t.Fatalf("unexpected error message: %v", err)
 		}
 	})
 }
 
-func TestClient_GetSecretMap(t *testing.T) {
-	mock := &mockServer{
-		getSecretMap: map[string][]byte{
-			"a": []byte("b"),
-		},
-	}
-	conn, cleanup := setupTestServer(t, mock)
-	defer cleanup()
-
-	client := NewClientWithConn(conn)
-
-	value, err := client.GetSecretMap(
-		context.Background(),
-		esv1.ExternalSecretDataRemoteRef{Key: "test-key"},
-		&pb.ProviderReference{Name: "provider"},
-		"default",
-	)
-	if err != nil {
-		t.Fatalf("GetSecretMap failed: %v", err)
-	}
-
-	if string(value["a"]) != "b" {
-		t.Fatalf("Expected map[a]=b, got %#v", value)
-	}
-}
-
-func TestClient_Close(t *testing.T) {
+func TestClientClose(t *testing.T) {
 	mock := &mockServer{}
 	conn, cleanup := setupTestServer(t, mock)
 	defer cleanup()
 
 	client := NewClientWithConn(conn)
 
-	err := client.Close(context.Background())
-	if err != nil {
+	if err := client.Close(context.Background()); err != nil {
 		t.Fatalf("Close failed: %v", err)
 	}
 }
 
-func TestNewClient_InvalidAddress(t *testing.T) {
+func TestNewClientInvalidAddress(t *testing.T) {
 	_, err := NewClient("", nil)
 	if err == nil {
-		t.Fatal("Expected error for empty address, got nil")
+		t.Fatal("expected error for empty address, got nil")
+	}
+}
+
+func assertProviderRefEqual(t *testing.T, got, want *pb.ProviderReference) {
+	t.Helper()
+
+	if got == nil || want == nil {
+		t.Fatalf("provider refs must not be nil: got=%#v want=%#v", got, want)
+	}
+	if got.ApiVersion != want.ApiVersion || got.Kind != want.Kind || got.Name != want.Name || got.Namespace != want.Namespace {
+		t.Fatalf("unexpected provider ref: got=%#v want=%#v", got, want)
 	}
 }

+ 187 - 0
providers/v2/common/grpc/pool_test.go

@@ -0,0 +1,187 @@
+/*
+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 grpc
+
+import (
+	"context"
+	"crypto/tls"
+	"crypto/x509"
+	"net"
+	"testing"
+	"time"
+
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/credentials"
+
+	pb "github.com/external-secrets/external-secrets/proto/provider"
+)
+
+func TestConnectionPoolGetReleaseReuse(t *testing.T) {
+	address, tlsConfig := newPoolTestServer(t)
+
+	pool := NewConnectionPool(PoolConfig{
+		MaxIdleTime:         time.Minute,
+		MaxLifetime:         time.Minute,
+		HealthCheckInterval: time.Hour,
+	})
+	defer func() {
+		_ = pool.Close()
+	}()
+
+	client1, err := pool.Get(context.Background(), address, tlsConfig)
+	if err != nil {
+		t.Fatalf("Get() error = %v", err)
+	}
+	client2, err := pool.Get(context.Background(), address, tlsConfig)
+	if err != nil {
+		t.Fatalf("second Get() error = %v", err)
+	}
+
+	if client1 != client2 {
+		t.Fatal("expected pooled client to be reused")
+	}
+
+	key := pool.connectionKey(address, tlsConfig)
+	pooled := pool.connections[key]
+	if pooled == nil {
+		t.Fatalf("expected pooled connection for key %q", key)
+	}
+	if pooled.references != 2 {
+		t.Fatalf("expected references=2, got %d", pooled.references)
+	}
+
+	pool.Release(address, tlsConfig)
+	if pooled.references != 1 {
+		t.Fatalf("expected references=1 after release, got %d", pooled.references)
+	}
+	pool.Release(address, tlsConfig)
+	if pooled.references != 0 {
+		t.Fatalf("expected references=0 after second release, got %d", pooled.references)
+	}
+}
+
+func TestConnectionPoolGetReplacesExpiredConnection(t *testing.T) {
+	address, tlsConfig := newPoolTestServer(t)
+
+	pool := NewConnectionPool(PoolConfig{
+		MaxIdleTime:         time.Minute,
+		MaxLifetime:         time.Minute,
+		HealthCheckInterval: time.Hour,
+	})
+	defer func() {
+		_ = pool.Close()
+	}()
+
+	client1, err := pool.Get(context.Background(), address, tlsConfig)
+	if err != nil {
+		t.Fatalf("Get() error = %v", err)
+	}
+	pool.Release(address, tlsConfig)
+
+	key := pool.connectionKey(address, tlsConfig)
+	pooled := pool.connections[key]
+	if pooled == nil {
+		t.Fatalf("expected pooled connection for key %q", key)
+	}
+	pooled.mu.Lock()
+	pooled.created = time.Now().Add(-2 * time.Hour)
+	pooled.mu.Unlock()
+
+	client2, err := pool.Get(context.Background(), address, tlsConfig)
+	if err != nil {
+		t.Fatalf("second Get() error = %v", err)
+	}
+
+	if client1 == client2 {
+		t.Fatal("expected expired pooled client to be replaced")
+	}
+}
+
+func TestConnectionPoolCleanupIdleConnectionsRemovesReleasedConnection(t *testing.T) {
+	address, tlsConfig := newPoolTestServer(t)
+
+	pool := NewConnectionPool(PoolConfig{
+		MaxIdleTime:         time.Second,
+		MaxLifetime:         time.Minute,
+		HealthCheckInterval: time.Hour,
+	})
+	defer func() {
+		_ = pool.Close()
+	}()
+
+	_, err := pool.Get(context.Background(), address, tlsConfig)
+	if err != nil {
+		t.Fatalf("Get() error = %v", err)
+	}
+	pool.Release(address, tlsConfig)
+
+	key := pool.connectionKey(address, tlsConfig)
+	pooled := pool.connections[key]
+	if pooled == nil {
+		t.Fatalf("expected pooled connection for key %q", key)
+	}
+	pooled.mu.Lock()
+	pooled.lastUsed = time.Now().Add(-2 * time.Second)
+	pooled.mu.Unlock()
+
+	pool.cleanupIdleConnections()
+
+	if _, ok := pool.connections[key]; ok {
+		t.Fatalf("expected idle pooled connection %q to be removed", key)
+	}
+}
+
+func newPoolTestServer(t *testing.T) (string, *TLSConfig) {
+	t.Helper()
+
+	serverCert, serverKey, clientCert, clientKey, caCert := newTLSArtifactsForTest(t, "127.0.0.1")
+
+	caPool := x509.NewCertPool()
+	if !caPool.AppendCertsFromPEM(caCert) {
+		t.Fatal("failed to append CA cert")
+	}
+	tlsCert, err := tls.X509KeyPair(serverCert, serverKey)
+	if err != nil {
+		t.Fatalf("X509KeyPair() error = %v", err)
+	}
+
+	lis, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatalf("Listen() error = %v", err)
+	}
+
+	server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{
+		MinVersion:   tls.VersionTLS12,
+		Certificates: []tls.Certificate{tlsCert},
+		ClientCAs:    caPool,
+		ClientAuth:   tls.RequireAndVerifyClientCert,
+	})))
+	pb.RegisterSecretStoreProviderServer(server, &mockServer{})
+	go func() {
+		_ = server.Serve(lis)
+	}()
+
+	t.Cleanup(func() {
+		server.Stop()
+		_ = lis.Close()
+	})
+
+	return lis.Addr().String(), &TLSConfig{
+		CACert:     caCert,
+		ClientCert: clientCert,
+		ClientKey:  clientKey,
+		ServerName: "127.0.0.1",
+	}
+}

+ 14 - 0
providers/v2/common/grpc/tls.go

@@ -107,6 +107,20 @@ func NamespaceFromAddress(address, fallbackNamespace string) string {
 	return fallbackNamespace
 }
 
+// ResolveTLSSecretNamespace determines the namespace of the TLS secret used to connect
+// to a provider. Service DNS addresses win, otherwise the namespace precedence is:
+// auth namespace, resource namespace, providerRef namespace.
+func ResolveTLSSecretNamespace(address, authNamespace, resourceNamespace, providerRefNamespace string) string {
+	fallbackNamespace := authNamespace
+	if fallbackNamespace == "" {
+		fallbackNamespace = resourceNamespace
+	}
+	if fallbackNamespace == "" {
+		fallbackNamespace = providerRefNamespace
+	}
+	return NamespaceFromAddress(address, fallbackNamespace)
+}
+
 // ToGRPCTLSConfig converts TLSConfig to a *tls.Config suitable for gRPC.
 func (t *TLSConfig) ToGRPCTLSConfig() (*tls.Config, error) {
 	// Load client certificate

+ 286 - 0
providers/v2/common/grpc/tls_test.go

@@ -0,0 +1,286 @@
+/*
+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 grpc
+
+import (
+	"context"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/tls"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/pem"
+	"math/big"
+	"net"
+	"testing"
+	"time"
+
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+	clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+	ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
+	fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"
+)
+
+func TestNamespaceFromAddress(t *testing.T) {
+	testCases := []struct {
+		name     string
+		address  string
+		fallback string
+		expected string
+	}{
+		{
+			name:     "service_dns_with_port",
+			address:  "provider.team-a.svc:9443",
+			fallback: "fallback",
+			expected: "team-a",
+		},
+		{
+			name:     "service_dns_cluster_local",
+			address:  "provider.team-b.svc.cluster.local:9443",
+			fallback: "fallback",
+			expected: "team-b",
+		},
+		{
+			name:     "non_service_address_uses_fallback",
+			address:  "127.0.0.1:9443",
+			fallback: "tenant-a",
+			expected: "tenant-a",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			if got := NamespaceFromAddress(tc.address, tc.fallback); got != tc.expected {
+				t.Fatalf("expected %q, got %q", tc.expected, got)
+			}
+		})
+	}
+}
+
+func TestResolveTLSSecretNamespace(t *testing.T) {
+	testCases := []struct {
+		name                 string
+		address              string
+		authNamespace        string
+		resourceNamespace    string
+		providerRefNamespace string
+		expected             string
+	}{
+		{
+			name:                 "service_dns_takes_precedence",
+			address:              "provider.service-ns.svc:9443",
+			authNamespace:        "auth-ns",
+			resourceNamespace:    "resource-ns",
+			providerRefNamespace: "provider-ref-ns",
+			expected:             "service-ns",
+		},
+		{
+			name:                 "auth_namespace_used_before_other_fallbacks",
+			address:              "127.0.0.1:9443",
+			authNamespace:        "auth-ns",
+			resourceNamespace:    "resource-ns",
+			providerRefNamespace: "provider-ref-ns",
+			expected:             "auth-ns",
+		},
+		{
+			name:                 "resource_namespace_used_before_provider_ref_namespace",
+			address:              "127.0.0.1:9443",
+			resourceNamespace:    "resource-ns",
+			providerRefNamespace: "provider-ref-ns",
+			expected:             "resource-ns",
+		},
+		{
+			name:                 "provider_ref_namespace_is_final_fallback",
+			address:              "127.0.0.1:9443",
+			providerRefNamespace: "provider-ref-ns",
+			expected:             "provider-ref-ns",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			if got := ResolveTLSSecretNamespace(tc.address, tc.authNamespace, tc.resourceNamespace, tc.providerRefNamespace); got != tc.expected {
+				t.Fatalf("expected %q, got %q", tc.expected, got)
+			}
+		})
+	}
+}
+
+func TestLoadClientTLSConfig(t *testing.T) {
+	t.Run("success", func(t *testing.T) {
+		serverName := "127.0.0.1"
+		_, _, clientCertPEM, clientKeyPEM, caCertPEM := newTLSArtifactsForTest(t, serverName)
+		kubeClient := newTLSSecretClient(t, map[string][]byte{
+			"ca.crt":     caCertPEM,
+			"client.crt": clientCertPEM,
+			"client.key": clientKeyPEM,
+		})
+
+		cfg, err := LoadClientTLSConfig(context.Background(), kubeClient, "127.0.0.1:9443", "tenant-a")
+		if err != nil {
+			t.Fatalf("LoadClientTLSConfig() error = %v", err)
+		}
+
+		if string(cfg.CACert) != string(caCertPEM) || string(cfg.ClientCert) != string(clientCertPEM) || string(cfg.ClientKey) != string(clientKeyPEM) {
+			t.Fatalf("unexpected tls config: %#v", cfg)
+		}
+		if cfg.ServerName != serverName {
+			t.Fatalf("expected server name %q, got %q", serverName, cfg.ServerName)
+		}
+	})
+
+	t.Run("missing_secret_data", func(t *testing.T) {
+		kubeClient := newTLSSecretClient(t, map[string][]byte{
+			"ca.crt": []byte("ca"),
+		})
+
+		_, err := LoadClientTLSConfig(context.Background(), kubeClient, "127.0.0.1:9443", "tenant-a")
+		if err == nil || err.Error() != "client.crt not found or empty in secret external-secrets-provider-tls" {
+			t.Fatalf("unexpected error: %v", err)
+		}
+	})
+}
+
+func TestTLSConfigToGRPCTLSConfig(t *testing.T) {
+	t.Run("success", func(t *testing.T) {
+		serverName := "127.0.0.1"
+		_, _, clientCertPEM, clientKeyPEM, caCertPEM := newTLSArtifactsForTest(t, serverName)
+
+		cfg, err := (&TLSConfig{
+			CACert:     caCertPEM,
+			ClientCert: clientCertPEM,
+			ClientKey:  clientKeyPEM,
+			ServerName: serverName,
+		}).ToGRPCTLSConfig()
+		if err != nil {
+			t.Fatalf("ToGRPCTLSConfig() error = %v", err)
+		}
+
+		if cfg.MinVersion != tls.VersionTLS12 {
+			t.Fatalf("expected min version %v, got %v", tls.VersionTLS12, cfg.MinVersion)
+		}
+		if cfg.ServerName != serverName {
+			t.Fatalf("expected server name %q, got %q", serverName, cfg.ServerName)
+		}
+		if len(cfg.Certificates) != 1 {
+			t.Fatalf("expected one certificate, got %d", len(cfg.Certificates))
+		}
+		if cfg.RootCAs == nil {
+			t.Fatal("expected root CAs to be set")
+		}
+	})
+
+	t.Run("invalid_keypair", func(t *testing.T) {
+		_, err := (&TLSConfig{
+			CACert:     []byte("not-a-ca"),
+			ClientCert: []byte("not-a-cert"),
+			ClientKey:  []byte("not-a-key"),
+		}).ToGRPCTLSConfig()
+		if err == nil {
+			t.Fatal("expected invalid keypair to fail")
+		}
+	})
+}
+
+func newTLSSecretClient(t *testing.T, data map[string][]byte) ctrlclient.Client {
+	t.Helper()
+
+	scheme := runtime.NewScheme()
+	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+
+	return fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(&corev1.Secret{
+			ObjectMeta: metav1ForTLS("external-secrets-provider-tls", "tenant-a"),
+			Data:       data,
+		}).
+		Build()
+}
+
+func metav1ForTLS(name, namespace string) metav1.ObjectMeta {
+	return metav1.ObjectMeta{
+		Name:      name,
+		Namespace: namespace,
+	}
+}
+
+func newTLSArtifactsForTest(t *testing.T, host string) (serverCertPEM, serverKeyPEM, clientCertPEM, clientKeyPEM, caCertPEM []byte) {
+	t.Helper()
+
+	caKey, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		t.Fatalf("GenerateKey() error = %v", err)
+	}
+
+	caTemplate := &x509.Certificate{
+		SerialNumber:          big.NewInt(1),
+		Subject:               pkix.Name{CommonName: "grpc-test-ca"},
+		NotBefore:             time.Now().Add(-time.Hour),
+		NotAfter:              time.Now().Add(24 * time.Hour),
+		KeyUsage:              x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
+		BasicConstraintsValid: true,
+		IsCA:                  true,
+	}
+
+	caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
+	if err != nil {
+		t.Fatalf("CreateCertificate() error = %v", err)
+	}
+	caCert, err := x509.ParseCertificate(caDER)
+	if err != nil {
+		t.Fatalf("ParseCertificate() error = %v", err)
+	}
+
+	serverCertPEM, serverKeyPEM = newSignedTLSCertForTest(t, caCert, caKey, 2, host, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth})
+	clientCertPEM, clientKeyPEM = newSignedTLSCertForTest(t, caCert, caKey, 3, host, []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth})
+	caCertPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER})
+	return serverCertPEM, serverKeyPEM, clientCertPEM, clientKeyPEM, caCertPEM
+}
+
+func newSignedTLSCertForTest(t *testing.T, caCert *x509.Certificate, caKey *rsa.PrivateKey, serial int64, host string, usages []x509.ExtKeyUsage) ([]byte, []byte) {
+	t.Helper()
+
+	key, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		t.Fatalf("GenerateKey() error = %v", err)
+	}
+
+	template := &x509.Certificate{
+		SerialNumber: big.NewInt(serial),
+		Subject:      pkix.Name{CommonName: host},
+		NotBefore:    time.Now().Add(-time.Hour),
+		NotAfter:     time.Now().Add(24 * time.Hour),
+		KeyUsage:     x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
+		ExtKeyUsage:  usages,
+	}
+
+	if ip := net.ParseIP(host); ip != nil {
+		template.IPAddresses = []net.IP{ip}
+	} else {
+		template.DNSNames = []string{host}
+	}
+
+	der, err := x509.CreateCertificate(rand.Reader, template, caCert, &key.PublicKey, caKey)
+	if err != nil {
+		t.Fatalf("CreateCertificate() error = %v", err)
+	}
+
+	certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
+	keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
+	return certPEM, keyPEM
+}

+ 97 - 0
providers/v2/kubernetes/config_test.go

@@ -0,0 +1,97 @@
+/*
+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 main
+
+import (
+	"testing"
+
+	"k8s.io/apimachinery/pkg/runtime"
+	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+	fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+	v1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	k8sv2alpha1 "github.com/external-secrets/external-secrets/apis/provider/kubernetes/v2alpha1"
+	pb "github.com/external-secrets/external-secrets/proto/provider"
+)
+
+func TestGetSpecMapperUsesProviderRefNamespaceBeforeSourceNamespace(t *testing.T) {
+	scheme := runtime.NewScheme()
+	utilruntime.Must(v1.AddToScheme(scheme))
+	utilruntime.Must(k8sv2alpha1.AddToScheme(scheme))
+
+	referenced := &k8sv2alpha1.Kubernetes{}
+	referenced.Name = "backend"
+	referenced.Namespace = "provider-config-ns"
+	referenced.Spec.RemoteNamespace = "remote-from-provider-ref"
+
+	fallback := &k8sv2alpha1.Kubernetes{}
+	fallback.Name = "backend"
+	fallback.Namespace = "tenant-a"
+	fallback.Spec.RemoteNamespace = "remote-from-source-namespace"
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(referenced, fallback).
+		Build()
+
+	mapper := GetSpecMapper(kubeClient)
+
+	spec, err := mapper(&pb.ProviderReference{
+		Name:      "backend",
+		Namespace: "provider-config-ns",
+	}, "tenant-a")
+	if err != nil {
+		t.Fatalf("mapper() error = %v", err)
+	}
+
+	if spec.Provider == nil || spec.Provider.Kubernetes == nil {
+		t.Fatalf("expected kubernetes provider spec, got %#v", spec.Provider)
+	}
+	if spec.Provider.Kubernetes.RemoteNamespace != "remote-from-provider-ref" {
+		t.Fatalf("expected provider-ref namespace object, got %#v", spec.Provider.Kubernetes)
+	}
+}
+
+func TestGetSpecMapperFallsBackToSourceNamespace(t *testing.T) {
+	scheme := runtime.NewScheme()
+	utilruntime.Must(v1.AddToScheme(scheme))
+	utilruntime.Must(k8sv2alpha1.AddToScheme(scheme))
+
+	fallback := &k8sv2alpha1.Kubernetes{}
+	fallback.Name = "backend"
+	fallback.Namespace = "tenant-a"
+	fallback.Spec.RemoteNamespace = "remote-from-source-namespace"
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(fallback).
+		Build()
+
+	mapper := GetSpecMapper(kubeClient)
+
+	spec, err := mapper(&pb.ProviderReference{
+		Name: "backend",
+	}, "tenant-a")
+	if err != nil {
+		t.Fatalf("mapper() error = %v", err)
+	}
+
+	if spec.Provider == nil || spec.Provider.Kubernetes == nil {
+		t.Fatalf("expected kubernetes provider spec, got %#v", spec.Provider)
+	}
+	if spec.Provider.Kubernetes.RemoteNamespace != "remote-from-source-namespace" {
+		t.Fatalf("expected source namespace object, got %#v", spec.Provider.Kubernetes)
+	}
+}

+ 6 - 5
runtime/clientmanager/manager.go

@@ -284,11 +284,12 @@ func (m *Manager) getOrCreateV2Client(ctx context.Context, cfg v2ProviderConfig,
 		return nil, fmt.Errorf("provider address is required in %s %q", cfg.kindStr, cfg.name)
 	}
 
-	tlsSecretNamespace := cfg.resourceNamespace
-	if tlsSecretNamespace == "" {
-		tlsSecretNamespace = cfg.config.ProviderRef.Namespace
-	}
-	tlsSecretNamespace = grpc.NamespaceFromAddress(cfg.config.Address, tlsSecretNamespace)
+	tlsSecretNamespace := grpc.ResolveTLSSecretNamespace(
+		cfg.config.Address,
+		authNamespace,
+		cfg.resourceNamespace,
+		cfg.config.ProviderRef.Namespace,
+	)
 
 	// Load TLS configuration
 	tlsConfig, err := grpc.LoadClientTLSConfig(ctx, m.client, cfg.config.Address, tlsSecretNamespace)

+ 549 - 0
runtime/clientmanager/manager_test.go

@@ -18,11 +18,23 @@ package clientmanager
 
 import (
 	"context"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/tls"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/pem"
+	"math/big"
+	"net"
+	"sync"
 	"testing"
+	"time"
 
 	"github.com/go-logr/logr"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/credentials"
 	corev1 "k8s.io/api/core/v1"
 	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -34,6 +46,7 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
 
 	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	pb "github.com/external-secrets/external-secrets/proto/provider"
 )
 
 func TestManagerGet(t *testing.T) {
@@ -448,6 +461,383 @@ func TestGetV2ProviderFeatureGateFromSourceRef(t *testing.T) {
 	assert.ErrorContains(t, err, "v2 provider support is disabled")
 }
 
+func TestGetV2ClusterProviderManifestScopeUsesManifestNamespaceForTLS(t *testing.T) {
+	previous := V2ProvidersEnabled()
+	SetV2ProvidersEnabled(true)
+	t.Cleanup(func() {
+		SetV2ProvidersEnabled(previous)
+	})
+
+	resetGlobalV2ConnectionPoolForTest(t)
+
+	scheme := newManagerTestScheme(t)
+	server, address, tlsSecret := newRecordingProviderServer(t)
+
+	const manifestNamespace = "tenant-a"
+	const referencedConfigNamespace = "provider-config-ns"
+
+	clusterProvider := &esv1.ClusterProvider{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "cluster-provider",
+		},
+		Spec: esv1.ClusterProviderSpec{
+			Config: esv1.ProviderConfig{
+				Address: address,
+				ProviderRef: esv1.ProviderReference{
+					APIVersion: "provider.external-secrets.io/v2alpha1",
+					Kind:       "Kubernetes",
+					Name:       "kubernetes-backend",
+				},
+			},
+			AuthenticationScope: esv1.AuthenticationScopeManifestNamespace,
+			Conditions: []esv1.ClusterSecretStoreCondition{
+				{
+					Namespaces: []string{manifestNamespace},
+				},
+			},
+		},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(
+			&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{
+				Name: manifestNamespace,
+				Labels: map[string]string{
+					"kubernetes.io/metadata.name": manifestNamespace,
+				},
+			}},
+			&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: referencedConfigNamespace}},
+			clusterProvider,
+			&corev1.Secret{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "external-secrets-provider-tls",
+					Namespace: manifestNamespace,
+				},
+				Data: tlsSecret,
+			},
+		).
+		Build()
+
+	mgr := NewManager(kubeClient, "default", false)
+
+	client, err := mgr.Get(context.Background(), esv1.SecretStoreRef{
+		Name: clusterProvider.Name,
+		Kind: esv1.ClusterProviderKindStr,
+	}, manifestNamespace, nil)
+	require.NoError(t, err)
+
+	result, err := client.Validate()
+	require.NoError(t, err)
+	assert.Equal(t, esv1.ValidationResultReady, result)
+
+	req := server.LastValidateRequest()
+	require.NotNil(t, req)
+	assert.Equal(t, manifestNamespace, req.SourceNamespace)
+	require.NotNil(t, req.ProviderRef)
+	assert.Equal(t, "kubernetes-backend", req.ProviderRef.Name)
+	assert.Equal(t, "", req.ProviderRef.Namespace)
+
+	require.Len(t, mgr.v2PooledConnections, 1)
+}
+
+func TestGetV2ClusterProviderRejectsProviderNamespaceWithoutNamespace(t *testing.T) {
+	previous := V2ProvidersEnabled()
+	SetV2ProvidersEnabled(true)
+	t.Cleanup(func() {
+		SetV2ProvidersEnabled(previous)
+	})
+
+	resetGlobalV2ConnectionPoolForTest(t)
+
+	scheme := newManagerTestScheme(t)
+	const manifestNamespace = "tenant-a"
+
+	clusterProvider := &esv1.ClusterProvider{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "cluster-provider",
+		},
+		Spec: esv1.ClusterProviderSpec{
+			Config: esv1.ProviderConfig{
+				Address: "127.0.0.1:9443",
+				ProviderRef: esv1.ProviderReference{
+					APIVersion: "provider.external-secrets.io/v2alpha1",
+					Kind:       "Kubernetes",
+					Name:       "kubernetes-backend",
+				},
+			},
+			AuthenticationScope: esv1.AuthenticationScopeProviderNamespace,
+		},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(
+			&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{
+				Name: manifestNamespace,
+				Labels: map[string]string{
+					"kubernetes.io/metadata.name": manifestNamespace,
+				},
+			}},
+			clusterProvider,
+		).
+		Build()
+
+	mgr := NewManager(kubeClient, "default", false)
+
+	_, err := mgr.Get(context.Background(), esv1.SecretStoreRef{
+		Name: clusterProvider.Name,
+		Kind: esv1.ClusterProviderKindStr,
+	}, manifestNamespace, nil)
+	require.Error(t, err)
+	assert.ErrorContains(t, err, "authenticationScope=ProviderNamespace")
+	assert.ErrorContains(t, err, "providerRef.namespace is empty")
+}
+
+func TestGetV2ClusterProviderProviderScopeUsesProviderNamespaceForTLS(t *testing.T) {
+	previous := V2ProvidersEnabled()
+	SetV2ProvidersEnabled(true)
+	t.Cleanup(func() {
+		SetV2ProvidersEnabled(previous)
+	})
+
+	resetGlobalV2ConnectionPoolForTest(t)
+
+	scheme := newManagerTestScheme(t)
+	server, address, tlsSecret := newRecordingProviderServer(t)
+
+	const manifestNamespace = "tenant-a"
+	const referencedConfigNamespace = "provider-config-ns"
+
+	clusterProvider := &esv1.ClusterProvider{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "cluster-provider",
+		},
+		Spec: esv1.ClusterProviderSpec{
+			Config: esv1.ProviderConfig{
+				Address: address,
+				ProviderRef: esv1.ProviderReference{
+					APIVersion: "provider.external-secrets.io/v2alpha1",
+					Kind:       "Kubernetes",
+					Name:       "kubernetes-backend",
+					Namespace:  referencedConfigNamespace,
+				},
+			},
+			AuthenticationScope: esv1.AuthenticationScopeProviderNamespace,
+			Conditions: []esv1.ClusterSecretStoreCondition{
+				{
+					Namespaces: []string{manifestNamespace},
+				},
+			},
+		},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(
+			&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{
+				Name: manifestNamespace,
+				Labels: map[string]string{
+					"kubernetes.io/metadata.name": manifestNamespace,
+				},
+			}},
+			clusterProvider,
+			&corev1.Secret{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "external-secrets-provider-tls",
+					Namespace: referencedConfigNamespace,
+				},
+				Data: tlsSecret,
+			},
+		).
+		Build()
+
+	mgr := NewManager(kubeClient, "default", false)
+
+	client, err := mgr.Get(context.Background(), esv1.SecretStoreRef{
+		Name: clusterProvider.Name,
+		Kind: esv1.ClusterProviderKindStr,
+	}, manifestNamespace, nil)
+	require.NoError(t, err)
+
+	result, err := client.Validate()
+	require.NoError(t, err)
+	assert.Equal(t, esv1.ValidationResultReady, result)
+
+	req := server.LastValidateRequest()
+	require.NotNil(t, req)
+	assert.Equal(t, referencedConfigNamespace, req.SourceNamespace)
+	require.NotNil(t, req.ProviderRef)
+	assert.Equal(t, "kubernetes-backend", req.ProviderRef.Name)
+	assert.Equal(t, referencedConfigNamespace, req.ProviderRef.Namespace)
+
+	require.Len(t, mgr.v2PooledConnections, 1)
+}
+
+func TestGetV2ClusterProviderRejectsDeniedNamespace(t *testing.T) {
+	previous := V2ProvidersEnabled()
+	SetV2ProvidersEnabled(true)
+	t.Cleanup(func() {
+		SetV2ProvidersEnabled(previous)
+	})
+
+	resetGlobalV2ConnectionPoolForTest(t)
+
+	scheme := newManagerTestScheme(t)
+	const manifestNamespace = "tenant-a"
+
+	clusterProvider := &esv1.ClusterProvider{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "cluster-provider",
+		},
+		Spec: esv1.ClusterProviderSpec{
+			Config: esv1.ProviderConfig{
+				Address: "127.0.0.1:9443",
+				ProviderRef: esv1.ProviderReference{
+					APIVersion: "provider.external-secrets.io/v2alpha1",
+					Kind:       "Kubernetes",
+					Name:       "kubernetes-backend",
+					Namespace:  "provider-config-ns",
+				},
+			},
+			Conditions: []esv1.ClusterSecretStoreCondition{
+				{
+					NamespaceRegexes: []string{`other-.*`},
+				},
+			},
+		},
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(
+			&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{
+				Name: manifestNamespace,
+				Labels: map[string]string{
+					"kubernetes.io/metadata.name": manifestNamespace,
+				},
+			}},
+			clusterProvider,
+		).
+		Build()
+
+	mgr := NewManager(kubeClient, "default", false)
+
+	_, err := mgr.Get(context.Background(), esv1.SecretStoreRef{
+		Name: clusterProvider.Name,
+		Kind: esv1.ClusterProviderKindStr,
+	}, manifestNamespace, nil)
+	require.Error(t, err)
+	assert.ErrorContains(t, err, "denied by spec.conditions")
+}
+
+func TestGetV2ProviderInvalidatesGenerationCacheAndReleasesPoolReferences(t *testing.T) {
+	previous := V2ProvidersEnabled()
+	SetV2ProvidersEnabled(true)
+	t.Cleanup(func() {
+		SetV2ProvidersEnabled(previous)
+	})
+
+	resetGlobalV2ConnectionPoolForTest(t)
+
+	scheme := newManagerTestScheme(t)
+	server, address, tlsSecret := newRecordingProviderServer(t)
+
+	const manifestNamespace = "tenant-a"
+	const referencedConfigNamespace = "provider-config-ns"
+
+	provider := &esv1.Provider{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "provider",
+			Namespace: manifestNamespace,
+			Generation: 1,
+		},
+		Spec: esv1.ProviderSpec{
+			Config: esv1.ProviderConfig{
+				Address: address,
+				ProviderRef: esv1.ProviderReference{
+					APIVersion: "provider.external-secrets.io/v2alpha1",
+					Kind:       "Kubernetes",
+					Name:       "kubernetes-backend",
+					Namespace:  referencedConfigNamespace,
+				},
+			},
+		},
+	}
+
+	tlsSecretObject := &corev1.Secret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "external-secrets-provider-tls",
+			Namespace: manifestNamespace,
+		},
+		Data: tlsSecret,
+	}
+
+	kubeClient := fakeclient.NewClientBuilder().
+		WithScheme(scheme).
+		WithObjects(
+			&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{
+				Name: manifestNamespace,
+				Labels: map[string]string{
+					"kubernetes.io/metadata.name": manifestNamespace,
+				},
+			}},
+			&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: referencedConfigNamespace}},
+			provider,
+			tlsSecretObject,
+		).
+		Build()
+
+	mgr := NewManager(kubeClient, "default", false)
+
+	firstClient, err := mgr.Get(context.Background(), esv1.SecretStoreRef{
+		Name: provider.Name,
+		Kind: esv1.ProviderKindStr,
+	}, manifestNamespace, nil)
+	require.NoError(t, err)
+
+	ready, err := firstClient.Validate()
+	require.NoError(t, err)
+	assert.Equal(t, esv1.ValidationResultReady, ready)
+
+	req := server.LastValidateRequest()
+	require.NotNil(t, req)
+	assert.Equal(t, manifestNamespace, req.SourceNamespace)
+	require.NotNil(t, req.ProviderRef)
+	assert.Equal(t, "kubernetes-backend", req.ProviderRef.Name)
+	assert.Equal(t, referencedConfigNamespace, req.ProviderRef.Namespace)
+
+	cachedClient, err := mgr.Get(context.Background(), esv1.SecretStoreRef{
+		Name: provider.Name,
+		Kind: esv1.ProviderKindStr,
+	}, manifestNamespace, nil)
+	require.NoError(t, err)
+	assert.Same(t, firstClient, cachedClient)
+	require.Len(t, mgr.v2PooledConnections, 1)
+
+	provider.Generation = 2
+	require.NoError(t, kubeClient.Update(context.Background(), provider))
+
+	secondClient, err := mgr.Get(context.Background(), esv1.SecretStoreRef{
+		Name: provider.Name,
+		Kind: esv1.ProviderKindStr,
+	}, manifestNamespace, nil)
+	require.NoError(t, err)
+	assert.NotSame(t, firstClient, secondClient)
+
+	ready, err = secondClient.Validate()
+	require.NoError(t, err)
+	assert.Equal(t, esv1.ValidationResultReady, ready)
+
+	require.Len(t, mgr.v2PooledConnections, 2)
+
+	require.NoError(t, mgr.Close(context.Background()))
+	assert.Empty(t, mgr.clientMap)
+	assert.Empty(t, mgr.v2PooledConnections)
+
+	assert.Equal(t, 2, server.ValidateCallCount())
+}
+
 type WrapProvider struct {
 	newClientFunc func(
 		context.Context,
@@ -513,3 +903,162 @@ func (c *MockFakeClient) Close(_ context.Context) error {
 	c.closeCalled = true
 	return nil
 }
+
+func newManagerTestScheme(t *testing.T) *runtime.Scheme {
+	t.Helper()
+
+	scheme := runtime.NewScheme()
+	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+	utilruntime.Must(apiextensionsv1.AddToScheme(scheme))
+	utilruntime.Must(esv1.AddToScheme(scheme))
+	return scheme
+}
+
+func resetGlobalV2ConnectionPoolForTest(t *testing.T) {
+	t.Helper()
+
+	if globalV2ConnectionPool != nil {
+		_ = globalV2ConnectionPool.Close()
+	}
+	globalV2ConnectionPool = nil
+	globalV2ConnectionPoolOnce = sync.Once{}
+	t.Cleanup(func() {
+		if globalV2ConnectionPool != nil {
+			_ = globalV2ConnectionPool.Close()
+		}
+		globalV2ConnectionPool = nil
+		globalV2ConnectionPoolOnce = sync.Once{}
+	})
+}
+
+type recordingProviderServer struct {
+	pb.UnimplementedSecretStoreProviderServer
+
+	mu               sync.Mutex
+	validateRequests []*pb.ValidateRequest
+}
+
+func newRecordingProviderServer(t *testing.T) (*recordingProviderServer, string, map[string][]byte) {
+	t.Helper()
+
+	serverCert, serverKey, clientCert, clientKey, caCert := newMutualTLSArtifacts(t, "127.0.0.1")
+
+	caPool := x509.NewCertPool()
+	require.True(t, caPool.AppendCertsFromPEM(caCert))
+
+	tlsCert, err := tls.X509KeyPair(serverCert, serverKey)
+	require.NoError(t, err)
+
+	lis, err := net.Listen("tcp", "127.0.0.1:0")
+	require.NoError(t, err)
+
+	recorder := &recordingProviderServer{}
+	grpcServer := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{
+		MinVersion:   tls.VersionTLS12,
+		Certificates: []tls.Certificate{tlsCert},
+		ClientCAs:    caPool,
+		ClientAuth:   tls.RequireAndVerifyClientCert,
+	})))
+	pb.RegisterSecretStoreProviderServer(grpcServer, recorder)
+
+	go func() {
+		_ = grpcServer.Serve(lis)
+	}()
+
+	t.Cleanup(func() {
+		grpcServer.Stop()
+		_ = lis.Close()
+	})
+
+	return recorder, lis.Addr().String(), map[string][]byte{
+		"ca.crt":     caCert,
+		"client.crt": clientCert,
+		"client.key": clientKey,
+	}
+}
+
+func (s *recordingProviderServer) Validate(_ context.Context, req *pb.ValidateRequest) (*pb.ValidateResponse, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	s.validateRequests = append(s.validateRequests, req)
+	return &pb.ValidateResponse{Valid: true}, nil
+}
+
+func (s *recordingProviderServer) LastValidateRequest() *pb.ValidateRequest {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	if len(s.validateRequests) == 0 {
+		return nil
+	}
+	return s.validateRequests[len(s.validateRequests)-1]
+}
+
+func (s *recordingProviderServer) ValidateCallCount() int {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	return len(s.validateRequests)
+}
+
+func newMutualTLSArtifacts(t *testing.T, host string) (serverCertPEM, serverKeyPEM, clientCertPEM, clientKeyPEM, caCertPEM []byte) {
+	t.Helper()
+
+	caKey, err := rsa.GenerateKey(rand.Reader, 2048)
+	require.NoError(t, err)
+
+	caTemplate := &x509.Certificate{
+		SerialNumber: big.NewInt(1),
+		Subject: pkix.Name{
+			CommonName: "clientmanager-test-ca",
+		},
+		NotBefore:             time.Now().Add(-time.Hour),
+		NotAfter:              time.Now().Add(24 * time.Hour),
+		KeyUsage:              x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
+		BasicConstraintsValid: true,
+		IsCA:                  true,
+	}
+
+	caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
+	require.NoError(t, err)
+	caCert, err := x509.ParseCertificate(caDER)
+	require.NoError(t, err)
+
+	serverCertPEM, serverKeyPEM = newSignedCertificateForTest(t, caCert, caKey, 2, host, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth})
+	clientCertPEM, clientKeyPEM = newSignedCertificateForTest(t, caCert, caKey, 3, "client", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth})
+	caCertPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER})
+
+	return serverCertPEM, serverKeyPEM, clientCertPEM, clientKeyPEM, caCertPEM
+}
+
+func newSignedCertificateForTest(t *testing.T, caCert *x509.Certificate, caKey *rsa.PrivateKey, serial int64, host string, usages []x509.ExtKeyUsage) ([]byte, []byte) {
+	t.Helper()
+
+	key, err := rsa.GenerateKey(rand.Reader, 2048)
+	require.NoError(t, err)
+
+	template := &x509.Certificate{
+		SerialNumber: big.NewInt(serial),
+		Subject: pkix.Name{
+			CommonName: host,
+		},
+		NotBefore:   time.Now().Add(-time.Hour),
+		NotAfter:    time.Now().Add(24 * time.Hour),
+		KeyUsage:    x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
+		ExtKeyUsage: usages,
+	}
+
+	if ip := net.ParseIP(host); ip != nil {
+		template.IPAddresses = []net.IP{ip}
+	} else {
+		template.DNSNames = []string{host}
+	}
+
+	der, err := x509.CreateCertificate(rand.Reader, template, caCert, &key.PublicKey, caKey)
+	require.NoError(t, err)
+
+	certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
+	keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
+	return certPEM, keyPEM
+}