Browse Source

test(crd): add e2e suite and CI leg for the CRD provider (#6742)

* test(crd): add e2e suite and CI leg for the CRD provider

Signed-off-by: Alexander Chernov <alexander@chernov.it>

* docs(e2e): correct the crd leg comment about the required gate

Signed-off-by: Alexander Chernov <alexander@chernov.it>

* fix(deps): downgrade klauspost/compress to v1.18.5

Signed-off-by: Alexander Chernov <alexander@chernov.it>

---------

Signed-off-by: Alexander Chernov <alexander@chernov.it>
Alexander Chernov 6 days ago
parent
commit
8552ed8d11

+ 3 - 2
e2e/README.md

@@ -109,8 +109,9 @@ aws:        groups=['aws'] -> AWS_OIDC_ROLE_ARN, AWS_SA_NAME, AWS_SA_NAMESPACE
 ```
 
 Which providers actually need external credentials: `fake`, `kubernetes`,
-`template`, `vault`, `openbao`, `conjur`, and `infisical` run against in-cluster
-addons and need none. The rest hit real APIs and are scoped to their group.
+`template`, `crd`, `vault`, `openbao`, `conjur`, and `infisical` run against
+in-cluster addons (or, for `crd` and `kubernetes`, the cluster's own API) and
+need none. The rest hit real APIs and are scoped to their group.
 
 The `generator` suite is split across two legs by label. The `generator` leg
 runs every generator except grafana (`!managed && !grafana`) and is scoped to

+ 1 - 1
e2e/go.mod

@@ -64,7 +64,7 @@ require (
 	github.com/grafana/grafana-openapi-client-go v0.0.0-20250925215610-d92957c70d5c
 	github.com/hashicorp/vault/api v1.22.0
 	github.com/infisical/go-sdk v0.5.100
-	github.com/onsi/ginkgo/v2 v2.28.0
+	github.com/onsi/ginkgo/v2 v2.28.1
 	github.com/onsi/gomega v1.39.1
 	github.com/oracle/oci-go-sdk/v65 v65.103.0
 	github.com/scaleway/scaleway-sdk-go v1.0.0-beta.35

+ 2 - 2
e2e/go.sum

@@ -378,8 +378,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
 github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
 github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
-github.com/onsi/ginkgo/v2 v2.28.0 h1:Rrf+lVLmtlBIKv6KrIGJCjyY8N36vDVcutbGJkyqjJc=
-github.com/onsi/ginkgo/v2 v2.28.0/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
+github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
+github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
 github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
 github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
 github.com/oracle/oci-go-sdk/v65 v65.103.0 h1:HfyZx+JefCPK3At0Xt45q+wr914jDXuoyzOFX3XCbno=

+ 19 - 0
e2e/matrix.yaml

@@ -53,6 +53,25 @@ areas:
       - "e2e/suites/provider/cases/common/**"
     enabled: true
 
+  # The CRD provider reads arbitrary custom resources from the local cluster
+  # API, so it needs no credentials and no addon. It gets its own leg rather
+  # than joining core-smoke because its specs install a cluster-scoped test CRD
+  # and cluster-wide RBAC, heavier setup than the rest of that leg carries.
+  # Like every enabled area it feeds e2e-required, so a failure here does turn
+  # the gate red; the split buys attribution (the failing leg names the
+  # provider) and keeps a crd break from also failing the fake, kubernetes, and
+  # template specs.
+  - name: crd
+    suite: provider
+    labels: "crd && !managed"
+    providers: [crd]
+    secret_groups: []
+    needs_secrets: false
+    paths:
+      - "providers/v1/crd/**"
+      - "e2e/suites/provider/cases/crd/**"
+    enabled: true
+
   # Self-hosted secret backends, installed as in-cluster addons (no cloud
   # creds). One leg per provider so a broken addon (e.g. conjur) fails only its
   # own leg, not the others, and the leg name matches what it runs.

+ 101 - 0
e2e/suites/provider/cases/crd/admission.go

@@ -0,0 +1,101 @@
+/*
+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 crd
+
+import (
+	// nolint
+	. "github.com/onsi/ginkgo/v2"
+
+	// nolint
+	. "github.com/onsi/gomega"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+)
+
+// ValidateStore runs behind the SecretStore validating webhook, so the rules it
+// enforces are only really proven once a store round-trips through a live API
+// server: the webhook has to be reachable, the provider has to be registered in
+// the running controller, and the CRD schema has to carry the CEL rules. These
+// specs create deliberately invalid stores and expect the API to refuse them.
+// No RBAC or ServiceAccount is needed, because a rejected store never connects.
+var _ = Describe("[crd] store admission ", Label("crd"), func() {
+	f := framework.New("eso-crd-admission")
+
+	It("[crd] should reject a store that targets the core v1 Secret", func() {
+		res := esv1.CRDProviderResource{Group: "", Version: "v1", Kind: "Secret"}
+		err := f.CRClient.Create(GinkgoT().Context(), crdSecretStore(f, "reject-secret-kind", inClusterProviderSpec("default", res)))
+		Expect(err).To(HaveOccurred())
+		Expect(err.Error()).To(ContainSubstring("use the Kubernetes provider"))
+	})
+
+	It("[crd] should reject a SecretStore whitelist rule that constrains the namespace", func() {
+		prov := inClusterProviderSpec("default", namespacedResource())
+		// A SecretStore only ever reads its own namespace, so a namespace rule
+		// can never match and would silently deny every read.
+		prov.Whitelist = &esv1.CRDProviderWhitelist{
+			Rules: []esv1.CRDProviderWhitelistRule{{Namespace: "^prod$"}},
+		}
+		err := f.CRClient.Create(GinkgoT().Context(), crdSecretStore(f, "reject-ns-rule", prov))
+		Expect(err).To(HaveOccurred())
+		Expect(err.Error()).To(ContainSubstring("not supported for a SecretStore"))
+	})
+
+	It("[crd] should reject an empty whitelist rule", func() {
+		prov := inClusterProviderSpec("default", namespacedResource())
+		// A rule with no name, namespace, or properties matches everything, which
+		// looks like a restriction but silently widens access.
+		prov.Whitelist = &esv1.CRDProviderWhitelist{
+			Rules: []esv1.CRDProviderWhitelistRule{{}},
+		}
+		err := f.CRClient.Create(GinkgoT().Context(), crdSecretStore(f, "reject-empty-rule", prov))
+		Expect(err).To(HaveOccurred())
+		Expect(err.Error()).To(ContainSubstring("whitelist rule must define name, namespace, or properties"))
+	})
+
+	It("[crd] should reject an invalid whitelist regex", func() {
+		prov := inClusterProviderSpec("default", namespacedResource())
+		prov.Whitelist = &esv1.CRDProviderWhitelist{
+			Rules: []esv1.CRDProviderWhitelistRule{{Name: "^[unterminated"}},
+		}
+		err := f.CRClient.Create(GinkgoT().Context(), crdSecretStore(f, "reject-bad-regex", prov))
+		Expect(err).To(HaveOccurred())
+		Expect(err.Error()).To(ContainSubstring("invalid whitelist.rules[0].name regex"))
+	})
+
+	It("[crd] should reject server.url without auth or authRef", func() {
+		// Enforced by the CEL rule on the CRD schema rather than by the webhook,
+		// so this also proves the generated schema shipped with the rule intact.
+		prov := &esv1.CRDProvider{
+			Server:   esv1.KubernetesServer{URL: "https://remote-api.example.com"},
+			Resource: namespacedResource(),
+		}
+		err := f.CRClient.Create(GinkgoT().Context(), crdSecretStore(f, "reject-url-no-auth", prov))
+		Expect(err).To(HaveOccurred())
+		Expect(err.Error()).To(ContainSubstring("one of auth or authRef is required"))
+	})
+})
+
+func crdSecretStore(f *framework.Framework, name string, prov *esv1.CRDProvider) *esv1.SecretStore {
+	return &esv1.SecretStore{
+		ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: f.Namespace.Name},
+		Spec: esv1.SecretStoreSpec{
+			Provider: &esv1.SecretStoreProvider{CRD: prov},
+		},
+	}
+}

+ 201 - 0
e2e/suites/provider/cases/crd/cluster_scope.go

@@ -0,0 +1,201 @@
+/*
+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 crd
+
+import (
+	"time"
+
+	// nolint
+	. "github.com/onsi/ginkgo/v2"
+
+	// nolint
+	. "github.com/onsi/gomega"
+	corev1 "k8s.io/api/core/v1"
+	rbac "k8s.io/api/rbac/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+)
+
+// A cluster-scoped target kind takes different branches all the way down: the
+// RESTMapper reports a cluster scope, getObject drops the namespace (and
+// rejects a '/' in the key), and GetAllSecrets lists without a namespace
+// selector and keys results by bare object name even for a ClusterSecretStore.
+// None of that is reachable with the namespaced kind, so it gets its own suite.
+var clusterCRDGVK = schema.GroupVersionKind{Group: crdGroup, Version: crdVersion, Kind: clusterCRDKind}
+
+var _ = Describe("[crd] cluster-scoped kind ", Label("crd"), func() {
+	f := framework.New("eso-crd-cluster")
+	prov := NewClusterScopedProvider(f)
+
+	DescribeTable("sync secrets",
+		framework.TableFuncWithExternalSecret(f, prov),
+		Entry(syncClusterScopedViaSecretStore(f, prov)),
+		Entry(syncClusterScopedViaClusterStore(f, prov)),
+		Entry(findClusterScoped(f, prov)),
+	)
+})
+
+// ClusterScopedProvider drives the cluster-scoped test kind. Object names are
+// derived from the test namespace because the objects share one cluster-wide
+// name space with every parallel spec.
+type ClusterScopedProvider struct {
+	framework *framework.Framework
+}
+
+func NewClusterScopedProvider(f *framework.Framework) *ClusterScopedProvider {
+	prov := &ClusterScopedProvider{framework: f}
+	BeforeEach(prov.BeforeEach)
+	AfterEach(prov.AfterEach)
+	return prov
+}
+
+func (s *ClusterScopedProvider) BeforeEach() {
+	ensureCRD(s.framework, clusterScopedTestCRD())
+	grantClusterRead(s.framework, crdGroup, clusterCRDPlural, "default", []string{"get", "list", "watch"})
+	s.createStores()
+}
+
+func (s *ClusterScopedProvider) AfterEach() {
+	ctx := GinkgoT().Context()
+	ns := s.framework.Namespace.Name
+	_ = s.framework.CRClient.Delete(ctx, &esv1.ClusterSecretStore{
+		ObjectMeta: metav1.ObjectMeta{Name: referentStoreName(s.framework)},
+	})
+	_ = s.framework.CRClient.Delete(ctx, &rbac.ClusterRoleBinding{
+		ObjectMeta: metav1.ObjectMeta{Name: clusterRoleName(ns)},
+	})
+	_ = s.framework.CRClient.Delete(ctx, &rbac.ClusterRole{
+		ObjectMeta: metav1.ObjectMeta{Name: clusterRoleName(ns)},
+	})
+}
+
+// objectName qualifies a base name with the test namespace. Cluster-scoped
+// objects have no namespace to isolate them, so without this two parallel specs
+// would fight over the same object.
+func (s *ClusterScopedProvider) objectName(base string) string {
+	return s.framework.Namespace.Name + "-" + base
+}
+
+// CreateSecret seeds a cluster-scoped CR. The framework passes the key through
+// verbatim, so specs build it with objectName.
+func (s *ClusterScopedProvider) CreateSecret(key string, val framework.SecretEntry) {
+	createTestResource(s.framework, clusterCRDGVK, "", key, val)
+}
+
+func (s *ClusterScopedProvider) DeleteSecret(key string) {
+	deleteTestResource(s.framework, clusterCRDGVK, "", key)
+}
+
+func (s *ClusterScopedProvider) createStores() {
+	ctx := GinkgoT().Context()
+	ns := s.framework.Namespace.Name
+	res := esv1.CRDProviderResource{Group: crdGroup, Version: crdVersion, Kind: clusterCRDKind}
+
+	store := &esv1.SecretStore{
+		ObjectMeta: metav1.ObjectMeta{Name: ns, Namespace: ns},
+		Spec: esv1.SecretStoreSpec{
+			Provider: &esv1.SecretStoreProvider{CRD: inClusterProviderSpec("default", res)},
+		},
+	}
+	Expect(s.framework.CRClient.Create(ctx, store)).To(Succeed())
+
+	prov := inClusterProviderSpec("default", res)
+	prov.Server.CAProvider.Namespace = &ns
+	css := &esv1.ClusterSecretStore{
+		ObjectMeta: metav1.ObjectMeta{Name: referentStoreName(s.framework)},
+		Spec: esv1.SecretStoreSpec{
+			Provider: &esv1.SecretStoreProvider{CRD: prov},
+		},
+	}
+	Expect(s.framework.CRClient.Create(ctx, css)).To(Succeed())
+}
+
+// syncClusterScopedViaSecretStore reads a cluster-scoped CR through a
+// SecretStore. The store's own namespace is irrelevant here: the RESTMapper
+// reports a cluster scope, so the read must not be namespaced.
+func syncClusterScopedViaSecretStore(_ *framework.Framework, prov *ClusterScopedProvider) (string, func(*framework.TestCase)) {
+	return "[crd] should sync a property from a cluster-scoped CR", func(tc *framework.TestCase) {
+		key := prov.objectName("e2e-crd-cluster-a")
+		tc.Secrets = map[string]framework.SecretEntry{
+			key: {Value: `{"password":"cluster-pass"}`},
+		}
+		tc.ExpectedSecret = &corev1.Secret{
+			Type: corev1.SecretTypeOpaque,
+			Data: map[string][]byte{"pw": []byte("cluster-pass")},
+		}
+		tc.ExternalSecret.Spec.Data = []esv1.ExternalSecretData{
+			{
+				SecretKey: "pw",
+				RemoteRef: esv1.ExternalSecretDataRemoteRef{Key: key, Property: "spec.password"},
+			},
+		}
+	}
+}
+
+// syncClusterScopedViaClusterStore covers the ClusterSecretStore key form for a
+// cluster-scoped kind: a bare object name with no '/' separator, which is only
+// legal because the resource has no namespace.
+func syncClusterScopedViaClusterStore(f *framework.Framework, prov *ClusterScopedProvider) (string, func(*framework.TestCase)) {
+	return "[crd] should sync a cluster-scoped CR via a ClusterSecretStore with a bare key", func(tc *framework.TestCase) {
+		key := prov.objectName("e2e-crd-cluster-b")
+		tc.Secrets = map[string]framework.SecretEntry{
+			key: {Value: `{"token":"cluster-token"}`},
+		}
+		tc.ExpectedSecret = &corev1.Secret{
+			Type: corev1.SecretTypeOpaque,
+			Data: map[string][]byte{"token": []byte("cluster-token")},
+		}
+		tc.ExternalSecret.Spec.SecretStoreRef.Name = referentStoreName(f)
+		tc.ExternalSecret.Spec.SecretStoreRef.Kind = esv1.ClusterSecretStoreKind
+		tc.ExternalSecret.Spec.Data = []esv1.ExternalSecretData{
+			{
+				SecretKey: "token",
+				RemoteRef: esv1.ExternalSecretDataRemoteRef{Key: key, Property: "spec.token"},
+			},
+		}
+	}
+}
+
+// findClusterScoped lists cluster-scoped CRs. Keys stay bare object names even
+// through a ClusterSecretStore, because there is no namespace to prefix.
+func findClusterScoped(f *framework.Framework, prov *ClusterScopedProvider) (string, func(*framework.TestCase)) {
+	return "[crd] should find cluster-scoped CRs via dataFrom.find", func(tc *framework.TestCase) {
+		hit := prov.objectName("e2e-crd-cluster-find")
+		miss := prov.objectName("e2e-crd-cluster-skip")
+		tc.Secrets = map[string]framework.SecretEntry{
+			hit:  {Value: `{"marker":"hit"}`},
+			miss: {Value: `{"marker":"miss"}`},
+		}
+		tc.ExternalSecret.Spec.SecretStoreRef.Name = referentStoreName(f)
+		tc.ExternalSecret.Spec.SecretStoreRef.Kind = esv1.ClusterSecretStoreKind
+		tc.ExternalSecret.Spec.DataFrom = []esv1.ExternalSecretDataFromRemoteRef{
+			{Find: &esv1.ExternalSecretFind{Name: &esv1.FindName{RegExp: "^" + hit + "$"}}},
+		}
+		tc.ExpectedSecret = nil
+		tc.AfterSync = func(_ framework.SecretStoreProvider, _ *corev1.Secret) {
+			Eventually(func(g Gomega) {
+				sec := targetSecret(g, f)
+				g.Expect(sec.Data).To(HaveKey(hit))
+				g.Expect(sec.Data).ToNot(HaveKey(miss))
+				g.Expect(string(sec.Data[hit])).To(ContainSubstring(`"marker":"hit"`))
+			}, time.Minute, time.Second).Should(Succeed())
+		}
+	}
+}

+ 148 - 0
e2e/suites/provider/cases/crd/core_resource.go

@@ -0,0 +1,148 @@
+/*
+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 crd
+
+import (
+	"encoding/json"
+
+	// nolint
+	. "github.com/onsi/ginkgo/v2"
+
+	// nolint
+	. "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"
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+)
+
+// The provider is not limited to custom resources: resource.group: "" addresses
+// the core API group, which is a distinct discovery path (no CRD, empty group
+// string in both the RESTMapper lookup and the SelfSubjectAccessReview). A
+// ConfigMap is the natural stand-in; the core v1 Secret is deliberately blocked
+// and is covered in the admission suite.
+var _ = Describe("[crd] core api resource ", Label("crd"), func() {
+	f := framework.New("eso-crd-core")
+	prov := NewCoreResourceProvider(f)
+
+	DescribeTable("sync secrets",
+		framework.TableFuncWithExternalSecret(f, prov),
+		Entry(syncConfigMapProperty(f)),
+		Entry(syncConfigMapDataAsMap(f)),
+	)
+})
+
+// CoreResourceProvider drives a store whose target resource is a core
+// ConfigMap.
+type CoreResourceProvider struct {
+	framework *framework.Framework
+}
+
+func NewCoreResourceProvider(f *framework.Framework) *CoreResourceProvider {
+	prov := &CoreResourceProvider{framework: f}
+	BeforeEach(prov.BeforeEach)
+	return prov
+}
+
+func (s *CoreResourceProvider) BeforeEach() {
+	s.createStore()
+}
+
+// CreateSecret seeds a ConfigMap whose data is the parsed JSON value. Unlike
+// the CRD kinds there is no spec envelope: a ConfigMap carries its payload
+// under the top-level "data" field, so property paths read "data.<key>".
+func (s *CoreResourceProvider) CreateSecret(key string, val framework.SecretEntry) {
+	body := map[string]string{}
+	Expect(json.Unmarshal([]byte(val.Value), &body)).To(Succeed())
+
+	cm := &corev1.ConfigMap{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      key,
+			Namespace: s.framework.Namespace.Name,
+			Labels:    val.Tags,
+		},
+		Data: body,
+	}
+	Expect(s.framework.CRClient.Create(GinkgoT().Context(), cm)).To(Succeed())
+}
+
+func (s *CoreResourceProvider) DeleteSecret(key string) {
+	cm := &corev1.ConfigMap{
+		ObjectMeta: metav1.ObjectMeta{Name: key, Namespace: s.framework.Namespace.Name},
+	}
+	Expect(s.framework.CRClient.Delete(GinkgoT().Context(), cm)).To(Succeed())
+}
+
+func (s *CoreResourceProvider) createStore() {
+	ctx := GinkgoT().Context()
+	ns := s.framework.Namespace.Name
+
+	role := readRole("eso-crd-core-read", ns, "", "configmaps", []string{"get", "list", "watch"})
+	rb := bindRole("eso-crd-core-rb", ns, role.Name, "default")
+	Expect(s.framework.CRClient.Create(ctx, role)).To(Succeed())
+	Expect(s.framework.CRClient.Create(ctx, rb)).To(Succeed())
+
+	res := esv1.CRDProviderResource{Group: "", Version: "v1", Kind: "ConfigMap"}
+	store := &esv1.SecretStore{
+		ObjectMeta: metav1.ObjectMeta{Name: ns, Namespace: ns},
+		Spec: esv1.SecretStoreSpec{
+			Provider: &esv1.SecretStoreProvider{CRD: inClusterProviderSpec("default", res)},
+		},
+	}
+	Expect(s.framework.CRClient.Create(ctx, store)).To(Succeed())
+}
+
+// syncConfigMapProperty reads one key out of a ConfigMap addressed through the
+// core API group.
+func syncConfigMapProperty(_ *framework.Framework) (string, func(*framework.TestCase)) {
+	return "[crd] should sync a property from a core ConfigMap", func(tc *framework.TestCase) {
+		tc.Secrets = map[string]framework.SecretEntry{
+			"e2e-crd-cm": {Value: `{"username":"cm-user","password":"cm-pass"}`},
+		}
+		tc.ExpectedSecret = &corev1.Secret{
+			Type: corev1.SecretTypeOpaque,
+			Data: map[string][]byte{"pw": []byte("cm-pass")},
+		}
+		tc.ExternalSecret.Spec.Data = []esv1.ExternalSecretData{
+			{
+				SecretKey: "pw",
+				RemoteRef: esv1.ExternalSecretDataRemoteRef{Key: "e2e-crd-cm", Property: "data.password"},
+			},
+		}
+	}
+}
+
+// syncConfigMapDataAsMap projects the whole ConfigMap data block into the
+// target Secret via dataFrom.extract.
+func syncConfigMapDataAsMap(_ *framework.Framework) (string, func(*framework.TestCase)) {
+	return "[crd] should extract a core ConfigMap data block into a map", func(tc *framework.TestCase) {
+		tc.Secrets = map[string]framework.SecretEntry{
+			"e2e-crd-cm-map": {Value: `{"host":"db.example","port":"5432"}`},
+		}
+		tc.ExpectedSecret = &corev1.Secret{
+			Type: corev1.SecretTypeOpaque,
+			Data: map[string][]byte{
+				"host": []byte("db.example"),
+				"port": []byte("5432"),
+			},
+		}
+		tc.ExternalSecret.Spec.DataFrom = []esv1.ExternalSecretDataFromRemoteRef{
+			{Extract: &esv1.ExternalSecretDataRemoteRef{Key: "e2e-crd-cm-map", Property: "data"}},
+		}
+	}
+}

+ 487 - 0
e2e/suites/provider/cases/crd/crd.go

@@ -0,0 +1,487 @@
+/*
+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 crd
+
+import (
+	"time"
+
+	// nolint
+	. "github.com/onsi/ginkgo/v2"
+
+	// nolint
+	. "github.com/onsi/gomega"
+	corev1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+)
+
+var _ = Describe("[crd] ", Label("crd"), func() {
+	f := framework.New("eso-crd")
+	prov := NewProvider(f)
+
+	DescribeTable("sync secrets",
+		framework.TableFuncWithExternalSecret(f, prov),
+		Entry(syncProperty(f)),
+		Entry(syncGJSONQuery(f)),
+		Entry(syncRawJSONProperty(f)),
+		Entry(syncWholeObject(f)),
+		Entry(syncMapFromExtract(f)),
+		Entry(syncReferentClusterStore(f)),
+		Entry(syncWithWhitelist(f)),
+		Entry(denyByWhitelist(f)),
+		Entry(findByName(f)),
+		Entry(syncFromStatusArray(f)),
+		Entry(findAcrossNamespaces(f, prov)),
+		Entry(syncWithNamespaceWhitelist(f, prov)),
+		Entry(denyByNamespaceWhitelist(f)),
+		Entry(syncWithGetOnlyServiceAccount(f)),
+		Entry(denyFindWithGetOnlyServiceAccount(f)),
+		Entry(denyMissingObject(f)),
+		Entry(denyMissingProperty(f)),
+	)
+})
+
+// syncProperty reads a single scalar property out of a namespaced CR via the
+// default SecretStore.
+func syncProperty(_ *framework.Framework) (string, func(*framework.TestCase)) {
+	return "[crd] should sync a property from a namespaced CR", func(tc *framework.TestCase) {
+		tc.Secrets = map[string]framework.SecretEntry{
+			"e2e-crd-a": {Value: `{"user":"app-user","password":"s3cr3t"}`},
+		}
+		tc.ExpectedSecret = &corev1.Secret{
+			Type: corev1.SecretTypeOpaque,
+			Data: map[string][]byte{"pw": []byte("s3cr3t")},
+		}
+		tc.ExternalSecret.Spec.Data = []esv1.ExternalSecretData{
+			{
+				SecretKey: "pw",
+				RemoteRef: esv1.ExternalSecretDataRemoteRef{Key: "e2e-crd-a", Property: "spec.password"},
+			},
+		}
+	}
+}
+
+// syncGJSONQuery proves the GJSON path dialect works end to end, including
+// array queries.
+func syncGJSONQuery(_ *framework.Framework) (string, func(*framework.TestCase)) {
+	return "[crd] should evaluate a gjson query property", func(tc *framework.TestCase) {
+		tc.Secrets = map[string]framework.SecretEntry{
+			"e2e-crd-b": {Value: `{"targets":[{"name":"db","val":"db:5432"},{"name":"cache","val":"c:6379"}]}`},
+		}
+		tc.ExpectedSecret = &corev1.Secret{
+			Type: corev1.SecretTypeOpaque,
+			Data: map[string][]byte{"endpoint": []byte("db:5432")},
+		}
+		tc.ExternalSecret.Spec.Data = []esv1.ExternalSecretData{
+			{
+				SecretKey: "endpoint",
+				RemoteRef: esv1.ExternalSecretDataRemoteRef{Key: "e2e-crd-b", Property: `spec.targets.#(name=="db").val`},
+			},
+		}
+	}
+}
+
+// syncRawJSONProperty covers the non-string leaves of extractValue: a string is
+// returned unquoted, while a number, a boolean, and an object keep their raw
+// JSON form.
+func syncRawJSONProperty(_ *framework.Framework) (string, func(*framework.TestCase)) {
+	return "[crd] should return non-string properties as raw json", func(tc *framework.TestCase) {
+		tc.Secrets = map[string]framework.SecretEntry{
+			"e2e-crd-types": {Value: `{"name":"widget","replicas":3,"enabled":true,"nested":{"a":"b"}}`},
+		}
+		tc.ExpectedSecret = &corev1.Secret{
+			Type: corev1.SecretTypeOpaque,
+			Data: map[string][]byte{
+				"name":     []byte("widget"),
+				"replicas": []byte("3"),
+				"enabled":  []byte("true"),
+				"nested":   []byte(`{"a":"b"}`),
+			},
+		}
+		tc.ExternalSecret.Spec.Data = []esv1.ExternalSecretData{
+			{SecretKey: "name", RemoteRef: esv1.ExternalSecretDataRemoteRef{Key: "e2e-crd-types", Property: "spec.name"}},
+			{SecretKey: "replicas", RemoteRef: esv1.ExternalSecretDataRemoteRef{Key: "e2e-crd-types", Property: "spec.replicas"}},
+			{SecretKey: "enabled", RemoteRef: esv1.ExternalSecretDataRemoteRef{Key: "e2e-crd-types", Property: "spec.enabled"}},
+			{SecretKey: "nested", RemoteRef: esv1.ExternalSecretDataRemoteRef{Key: "e2e-crd-types", Property: "spec.nested"}},
+		}
+	}
+}
+
+// syncWholeObject omits remoteRef.property, so the provider returns the entire
+// object as JSON. Server-set metadata makes an exact match impossible, so the
+// payload is asserted by content.
+func syncWholeObject(f *framework.Framework) (string, func(*framework.TestCase)) {
+	return "[crd] should return the whole object when no property is set", func(tc *framework.TestCase) {
+		tc.Secrets = map[string]framework.SecretEntry{
+			"e2e-crd-whole": {Value: `{"marker":"whole"}`},
+		}
+		tc.ExpectedSecret = nil
+		tc.ExternalSecret.Spec.Data = []esv1.ExternalSecretData{
+			{
+				SecretKey: "object",
+				RemoteRef: esv1.ExternalSecretDataRemoteRef{Key: "e2e-crd-whole"},
+			},
+		}
+		tc.AfterSync = func(_ framework.SecretStoreProvider, _ *corev1.Secret) {
+			Eventually(func(g Gomega) {
+				sec := targetSecret(g, f)
+				g.Expect(string(sec.Data["object"])).To(ContainSubstring(`"marker":"whole"`))
+				g.Expect(string(sec.Data["object"])).To(ContainSubstring(`"kind":"` + crdKind + `"`))
+			}, time.Minute, time.Second).Should(Succeed())
+		}
+	}
+}
+
+// syncMapFromExtract extracts a sub-object into a flat map via dataFrom.extract
+// (GetSecretMap).
+func syncMapFromExtract(_ *framework.Framework) (string, func(*framework.TestCase)) {
+	return "[crd] should extract a sub-object into a map via dataFrom", func(tc *framework.TestCase) {
+		tc.Secrets = map[string]framework.SecretEntry{
+			"e2e-crd-c": {Value: `{"creds":{"username":"admin","password":"p@ss","port":5432}}`},
+		}
+		tc.ExpectedSecret = &corev1.Secret{
+			Type: corev1.SecretTypeOpaque,
+			Data: map[string][]byte{
+				"username": []byte("admin"),
+				"password": []byte("p@ss"),
+				// Non-string map values keep their raw JSON form.
+				"port": []byte("5432"),
+			},
+		}
+		tc.ExternalSecret.Spec.DataFrom = []esv1.ExternalSecretDataFromRemoteRef{
+			{Extract: &esv1.ExternalSecretDataRemoteRef{Key: "e2e-crd-c", Property: "spec.creds"}},
+		}
+	}
+}
+
+// syncReferentClusterStore exercises referent authentication: the referent
+// ClusterSecretStore resolves the ServiceAccount in the ExternalSecret's own
+// namespace, and the key uses the ClusterSecretStore namespace/name form.
+func syncReferentClusterStore(f *framework.Framework) (string, func(*framework.TestCase)) {
+	return "[crd] should sync via a referent ClusterSecretStore with namespace/name key", func(tc *framework.TestCase) {
+		tc.Secrets = map[string]framework.SecretEntry{
+			"e2e-crd-d": {Value: `{"token":"abc123"}`},
+		}
+		tc.ExpectedSecret = &corev1.Secret{
+			Type: corev1.SecretTypeOpaque,
+			Data: map[string][]byte{"token": []byte("abc123")},
+		}
+		tc.ExternalSecret.Spec.SecretStoreRef.Name = referentStoreName(f)
+		tc.ExternalSecret.Spec.SecretStoreRef.Kind = esv1.ClusterSecretStoreKind
+		tc.ExternalSecret.Spec.Data = []esv1.ExternalSecretData{
+			{
+				SecretKey: "token",
+				RemoteRef: esv1.ExternalSecretDataRemoteRef{Key: f.Namespace.Name + "/e2e-crd-d", Property: "spec.token"},
+			},
+		}
+	}
+}
+
+// syncWithWhitelist proves the whitelist wiring: the store only allows names
+// matching "^e2e-crd-.*$" and the property "spec.password", and this request
+// satisfies both.
+func syncWithWhitelist(f *framework.Framework) (string, func(*framework.TestCase)) {
+	return "[crd] should honor an allowing whitelist", func(tc *framework.TestCase) {
+		tc.Secrets = map[string]framework.SecretEntry{
+			"e2e-crd-e": {Value: `{"password":"wl-pass"}`},
+		}
+		tc.ExpectedSecret = &corev1.Secret{
+			Type: corev1.SecretTypeOpaque,
+			Data: map[string][]byte{"password": []byte("wl-pass")},
+		}
+		tc.ExternalSecret.Spec.SecretStoreRef.Name = whitelistStoreName(f)
+		tc.ExternalSecret.Spec.Data = []esv1.ExternalSecretData{
+			{
+				SecretKey: "password",
+				RemoteRef: esv1.ExternalSecretDataRemoteRef{Key: "e2e-crd-e", Property: "spec.password"},
+			},
+		}
+	}
+}
+
+// findByName lists CRs by a name regex via dataFrom.find (GetAllSecrets).
+// Because GetAllSecrets returns the full object JSON (with server-set metadata)
+// an exact ExpectedSecret match is not possible, so the outcome is asserted
+// leniently: the matching keys are present, the non-matching one is absent, and
+// a spec marker is carried through.
+func findByName(f *framework.Framework) (string, func(*framework.TestCase)) {
+	return "[crd] should find CRs by name via dataFrom.find", func(tc *framework.TestCase) {
+		tc.Secrets = map[string]framework.SecretEntry{
+			"e2e-crd-find-one": {Value: `{"marker":"one"}`},
+			"e2e-crd-find-two": {Value: `{"marker":"two"}`},
+			"e2e-crd-other":    {Value: `{"marker":"other"}`},
+		}
+		tc.ExternalSecret.Spec.DataFrom = []esv1.ExternalSecretDataFromRemoteRef{
+			{Find: &esv1.ExternalSecretFind{Name: &esv1.FindName{RegExp: "e2e-crd-find-.+"}}},
+		}
+		tc.ExpectedSecret = nil
+		tc.AfterSync = func(_ framework.SecretStoreProvider, _ *corev1.Secret) {
+			Eventually(func(g Gomega) {
+				sec := targetSecret(g, f)
+				g.Expect(sec.Data).To(HaveKey("e2e-crd-find-one"))
+				g.Expect(sec.Data).To(HaveKey("e2e-crd-find-two"))
+				g.Expect(sec.Data).ToNot(HaveKey("e2e-crd-other"))
+				g.Expect(string(sec.Data["e2e-crd-find-one"])).To(ContainSubstring(`"marker":"one"`))
+			}, time.Minute, time.Second).Should(Succeed())
+		}
+	}
+}
+
+// findAcrossNamespaces lists through a ClusterSecretStore, where a namespaced
+// kind is listed cluster-wide and result keys carry a namespace/name prefix.
+// Those keys are not valid Secret data keys, so the conversion strategy
+// rewrites the separator; the assertion pins the converted form.
+func findAcrossNamespaces(f *framework.Framework, prov *Provider) (string, func(*framework.TestCase)) {
+	return "[crd] should find CRs across namespaces via a ClusterSecretStore", func(tc *framework.TestCase) {
+		local := "e2e-crd-xns-local"
+		remote := "e2e-crd-xns-remote"
+		tc.Secrets = map[string]framework.SecretEntry{
+			local: {Value: `{"marker":"local"}`},
+		}
+		// The alt-namespace CR is seeded directly: prov.CreateSecret always writes
+		// to the test namespace. It is removed with that namespace in AfterEach.
+		createTestResource(f, crdGVK, prov.altNamespace, remote, framework.SecretEntry{Value: `{"marker":"remote"}`})
+
+		tc.ExternalSecret.Spec.SecretStoreRef.Name = referentStoreName(f)
+		tc.ExternalSecret.Spec.SecretStoreRef.Kind = esv1.ClusterSecretStoreKind
+		// Anchor on both namespace names so a parallel spec's objects, which are
+		// visible to a cluster-wide list, cannot match.
+		tc.ExternalSecret.Spec.DataFrom = []esv1.ExternalSecretDataFromRemoteRef{
+			{Find: &esv1.ExternalSecretFind{
+				Name:               &esv1.FindName{RegExp: "^(" + f.Namespace.Name + "|" + prov.altNamespace + ")/e2e-crd-xns-"},
+				ConversionStrategy: esv1.ExternalSecretConversionDefault,
+			}},
+		}
+		tc.ExpectedSecret = nil
+		tc.AfterSync = func(_ framework.SecretStoreProvider, _ *corev1.Secret) {
+			// The Default conversion strategy replaces every character that is
+			// invalid in a Secret data key (here the '/' separator) with '_'.
+			localKey := f.Namespace.Name + "_" + local
+			remoteKey := prov.altNamespace + "_" + remote
+			Eventually(func(g Gomega) {
+				sec := targetSecret(g, f)
+				g.Expect(sec.Data).To(HaveKey(localKey))
+				g.Expect(sec.Data).To(HaveKey(remoteKey))
+				g.Expect(string(sec.Data[remoteKey])).To(ContainSubstring(`"marker":"remote"`))
+			}, time.Minute, time.Second).Should(Succeed())
+		}
+	}
+}
+
+// syncWithNamespaceWhitelist reads an object in the alt namespace through a
+// ClusterSecretStore whose only whitelist rule constrains the namespace.
+func syncWithNamespaceWhitelist(f *framework.Framework, prov *Provider) (string, func(*framework.TestCase)) {
+	return "[crd] should honor a whitelist namespace rule on a ClusterSecretStore", func(tc *framework.TestCase) {
+		name := "e2e-crd-nswl"
+		createTestResource(f, crdGVK, prov.altNamespace, name, framework.SecretEntry{Value: `{"token":"ns-ok"}`})
+
+		tc.ExpectedSecret = &corev1.Secret{
+			Type: corev1.SecretTypeOpaque,
+			Data: map[string][]byte{"token": []byte("ns-ok")},
+		}
+		tc.ExternalSecret.Spec.SecretStoreRef.Name = nsWhitelistStoreName(f)
+		tc.ExternalSecret.Spec.SecretStoreRef.Kind = esv1.ClusterSecretStoreKind
+		tc.ExternalSecret.Spec.Data = []esv1.ExternalSecretData{
+			{
+				SecretKey: "token",
+				RemoteRef: esv1.ExternalSecretDataRemoteRef{Key: prov.altNamespace + "/" + name, Property: "spec.token"},
+			},
+		}
+	}
+}
+
+// denyByNamespaceWhitelist proves the same store refuses an object in a
+// namespace the rule does not match, even though the ServiceAccount is allowed
+// to read it.
+func denyByNamespaceWhitelist(f *framework.Framework) (string, func(*framework.TestCase)) {
+	return "[crd] should deny an object outside the whitelisted namespace", func(tc *framework.TestCase) {
+		tc.Secrets = map[string]framework.SecretEntry{
+			"e2e-crd-nswl-denied": {Value: `{"token":"nope"}`},
+		}
+		tc.ExpectedSecret = nil
+		tc.ExternalSecret.Spec.SecretStoreRef.Name = nsWhitelistStoreName(f)
+		tc.ExternalSecret.Spec.SecretStoreRef.Kind = esv1.ClusterSecretStoreKind
+		tc.ExternalSecret.Spec.Data = []esv1.ExternalSecretData{
+			{
+				SecretKey: "token",
+				RemoteRef: esv1.ExternalSecretDataRemoteRef{
+					Key:      f.Namespace.Name + "/e2e-crd-nswl-denied",
+					Property: "spec.token",
+				},
+			},
+		}
+		tc.AfterSync = expectNoSecretAndNotReady(f, tc)
+	}
+}
+
+// denyByWhitelist proves the whitelist denies a request it does not allow. The
+// whitelist store permits name "^e2e-crd-.*$" only for property spec.password;
+// this request has a matching name but asks for spec.username, so it must be
+// refused: no target Secret is produced and the ExternalSecret goes not-ready.
+func denyByWhitelist(f *framework.Framework) (string, func(*framework.TestCase)) {
+	return "[crd] should deny a request the whitelist does not allow", func(tc *framework.TestCase) {
+		tc.Secrets = map[string]framework.SecretEntry{
+			"e2e-crd-denied": {Value: `{"username":"nope","password":"ok"}`},
+		}
+		tc.ExpectedSecret = nil
+		tc.ExternalSecret.Spec.SecretStoreRef.Name = whitelistStoreName(f)
+		tc.ExternalSecret.Spec.Data = []esv1.ExternalSecretData{
+			{
+				SecretKey: "username",
+				RemoteRef: esv1.ExternalSecretDataRemoteRef{Key: "e2e-crd-denied", Property: "spec.username"},
+			},
+		}
+		tc.AfterSync = expectNoSecretAndNotReady(f, tc)
+	}
+}
+
+// syncWithGetOnlyServiceAccount proves the store bootstrap preflight only
+// demands "get": an identity without "list" still serves a remoteRef read.
+func syncWithGetOnlyServiceAccount(f *framework.Framework) (string, func(*framework.TestCase)) {
+	return "[crd] should read a property with a get-only ServiceAccount", func(tc *framework.TestCase) {
+		tc.Secrets = map[string]framework.SecretEntry{
+			"e2e-crd-getonly": {Value: `{"password":"get-only"}`},
+		}
+		tc.ExpectedSecret = &corev1.Secret{
+			Type: corev1.SecretTypeOpaque,
+			Data: map[string][]byte{"password": []byte("get-only")},
+		}
+		tc.ExternalSecret.Spec.SecretStoreRef.Name = getOnlyStoreName(f)
+		tc.ExternalSecret.Spec.Data = []esv1.ExternalSecretData{
+			{
+				SecretKey: "password",
+				RemoteRef: esv1.ExternalSecretDataRemoteRef{Key: "e2e-crd-getonly", Property: "spec.password"},
+			},
+		}
+	}
+}
+
+// denyFindWithGetOnlyServiceAccount is the other half of the lazy permission
+// check: dataFrom.find calls GetAllSecrets, which verifies "list" and must fail
+// for the same identity that syncWithGetOnlyServiceAccount reads with.
+func denyFindWithGetOnlyServiceAccount(f *framework.Framework) (string, func(*framework.TestCase)) {
+	return "[crd] should deny dataFrom.find with a get-only ServiceAccount", func(tc *framework.TestCase) {
+		tc.Secrets = map[string]framework.SecretEntry{
+			"e2e-crd-getonly-find": {Value: `{"marker":"nope"}`},
+		}
+		tc.ExpectedSecret = nil
+		tc.ExternalSecret.Spec.SecretStoreRef.Name = getOnlyStoreName(f)
+		tc.ExternalSecret.Spec.DataFrom = []esv1.ExternalSecretDataFromRemoteRef{
+			{Find: &esv1.ExternalSecretFind{Name: &esv1.FindName{RegExp: "e2e-crd-getonly-find"}}},
+		}
+		tc.AfterSync = expectNoSecretAndNotReady(f, tc)
+	}
+}
+
+// denyMissingObject covers the not-found path: the provider maps a 404 to
+// NoSecretError, which the controller surfaces as a not-ready ExternalSecret.
+func denyMissingObject(f *framework.Framework) (string, func(*framework.TestCase)) {
+	return "[crd] should report a missing object as not-ready", func(tc *framework.TestCase) {
+		tc.ExpectedSecret = nil
+		tc.ExternalSecret.Spec.Data = []esv1.ExternalSecretData{
+			{
+				SecretKey: "pw",
+				RemoteRef: esv1.ExternalSecretDataRemoteRef{Key: "e2e-crd-absent", Property: "spec.password"},
+			},
+		}
+		tc.AfterSync = expectNoSecretAndNotReady(f, tc)
+	}
+}
+
+// denyMissingProperty covers the other read failure: the object exists but the
+// GJSON path resolves to nothing.
+func denyMissingProperty(f *framework.Framework) (string, func(*framework.TestCase)) {
+	return "[crd] should report an unresolvable property as not-ready", func(tc *framework.TestCase) {
+		tc.Secrets = map[string]framework.SecretEntry{
+			"e2e-crd-noprop": {Value: `{"user":"only-user"}`},
+		}
+		tc.ExpectedSecret = nil
+		tc.ExternalSecret.Spec.Data = []esv1.ExternalSecretData{
+			{
+				SecretKey: "pw",
+				RemoteRef: esv1.ExternalSecretDataRemoteRef{Key: "e2e-crd-noprop", Property: "spec.password"},
+			},
+		}
+		tc.AfterSync = expectNoSecretAndNotReady(f, tc)
+	}
+}
+
+// syncFromStatusArray reads a value out of a status array of objects via a
+// GJSON array query. The CR is seeded with a spec/status envelope so status
+// carries condition-like entries; the request selects one by a field match.
+func syncFromStatusArray(_ *framework.Framework) (string, func(*framework.TestCase)) {
+	return "[crd] should read a value from a status array of objects", func(tc *framework.TestCase) {
+		tc.Secrets = map[string]framework.SecretEntry{
+			"e2e-crd-status": {Value: `{"spec":{"noop":true},"status":{"conditions":[{"type":"Ready","value":"synced-at-12:00"},{"type":"Degraded","value":"n/a"}]}}`},
+		}
+		tc.ExpectedSecret = &corev1.Secret{
+			Type: corev1.SecretTypeOpaque,
+			Data: map[string][]byte{"ready": []byte("synced-at-12:00")},
+		}
+		tc.ExternalSecret.Spec.Data = []esv1.ExternalSecretData{
+			{
+				SecretKey: "ready",
+				RemoteRef: esv1.ExternalSecretDataRemoteRef{Key: "e2e-crd-status", Property: `status.conditions.#(type=="Ready").value`},
+			},
+		}
+	}
+}
+
+// targetSecret fetches the Secret that the ExternalSecret under test targets.
+func targetSecret(g Gomega, f *framework.Framework) *corev1.Secret {
+	sec := &corev1.Secret{}
+	g.Expect(f.CRClient.Get(GinkgoT().Context(), client.ObjectKey{
+		Namespace: f.Namespace.Name,
+		Name:      framework.TargetSecretName,
+	}, sec)).To(Succeed())
+	return sec
+}
+
+// expectNoSecretAndNotReady is the shared assertion for every refused read: the
+// target Secret must never appear, and the ExternalSecret must surface the
+// refusal as a not-ready condition rather than failing silently.
+func expectNoSecretAndNotReady(f *framework.Framework, tc *framework.TestCase) func(framework.SecretStoreProvider, *corev1.Secret) {
+	return func(_ framework.SecretStoreProvider, _ *corev1.Secret) {
+		Consistently(func(g Gomega) {
+			sec := &corev1.Secret{}
+			err := f.CRClient.Get(GinkgoT().Context(), client.ObjectKey{
+				Namespace: f.Namespace.Name,
+				Name:      framework.TargetSecretName,
+			}, sec)
+			g.Expect(apierrors.IsNotFound(err)).To(BeTrue())
+		}, 15*time.Second, 3*time.Second).Should(Succeed())
+
+		Eventually(func(g Gomega) {
+			es := &esv1.ExternalSecret{}
+			g.Expect(f.CRClient.Get(GinkgoT().Context(), client.ObjectKey{
+				Namespace: tc.ExternalSecret.Namespace,
+				Name:      tc.ExternalSecret.Name,
+			}, es)).To(Succeed())
+			var ready *esv1.ExternalSecretStatusCondition
+			for i := range es.Status.Conditions {
+				if es.Status.Conditions[i].Type == esv1.ExternalSecretReady {
+					ready = &es.Status.Conditions[i]
+				}
+			}
+			g.Expect(ready).ToNot(BeNil(), "expected a Ready condition on the ExternalSecret")
+			g.Expect(ready.Status).To(Equal(corev1.ConditionFalse))
+		}, time.Minute, 2*time.Second).Should(Succeed())
+	}
+}

+ 468 - 0
e2e/suites/provider/cases/crd/provider.go

@@ -0,0 +1,468 @@
+/*
+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 crd
+
+import (
+	"encoding/json"
+	"time"
+
+	// nolint
+	. "github.com/onsi/ginkgo/v2"
+
+	// nolint
+	. "github.com/onsi/gomega"
+	corev1 "k8s.io/api/core/v1"
+	rbac "k8s.io/api/rbac/v1"
+	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	"github.com/external-secrets/external-secrets-e2e/framework/util"
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
+)
+
+// The CRD provider reads arbitrary Kubernetes resources as unstructured
+// objects. These e2e tests target dedicated test CRDs so the scenarios exercise
+// the real custom-resource path (discovery, RESTMapper, unstructured read)
+// rather than a built-in kind, plus one suite over a core resource (ConfigMap)
+// that covers the resource.group: "" path. Remote-cluster connection is
+// intentionally out of scope: it needs a second API server, and the connection
+// code itself is shared with (and covered by) the Kubernetes provider.
+const (
+	crdGroup   = "e2e.external-secrets.io"
+	crdVersion = "v1alpha1"
+
+	// Namespaced test kind, used by the main suite.
+	crdKind   = "E2ETestResource"
+	crdPlural = "e2etestresources"
+
+	// Cluster-scoped test kind, used by the cluster-scope suite. A separate kind
+	// is the only way to exercise the cluster-scoped branches of getObject and
+	// GetAllSecrets and the ClusterSecretStore key form without a '/' separator.
+	clusterCRDKind   = "E2EClusterTestResource"
+	clusterCRDPlural = "e2eclustertestresources"
+
+	// ServiceAccount granted "get" but not "list" on the test CRD. The provider
+	// checks "get" at store bootstrap and "list" lazily in GetAllSecrets, so this
+	// identity must be able to read a single object but not run dataFrom.find.
+	getOnlySAName = "crd-get-only"
+)
+
+var crdGVK = schema.GroupVersionKind{Group: crdGroup, Version: crdVersion, Kind: crdKind}
+
+type Provider struct {
+	framework *framework.Framework
+	// altNamespace is a second namespace holding CRs that only a
+	// ClusterSecretStore can reach. It backs the cross-namespace find and the
+	// whitelist namespace rule, neither of which a SecretStore can express.
+	altNamespace string
+}
+
+func NewProvider(f *framework.Framework) *Provider {
+	prov := &Provider{
+		framework: f,
+	}
+	BeforeEach(prov.BeforeEach)
+	AfterEach(prov.AfterEach)
+	return prov
+}
+
+func (s *Provider) BeforeEach() {
+	ensureCRD(s.framework, namespacedTestCRD())
+	s.createAltNamespace()
+	s.CreateStore()
+	s.CreateWhitelistStore()
+	s.CreateReferentStore()
+	s.CreateGetOnlyStore()
+	s.CreateNamespaceWhitelistStore()
+}
+
+// AfterEach removes the cluster-scoped objects created for the referent
+// ClusterSecretStores. Namespace-scoped objects (Role, RoleBinding,
+// SecretStore, the CRs) are garbage-collected with the test namespace by the
+// framework; the ClusterRole, ClusterRoleBinding, ClusterSecretStores, and the
+// alt namespace are not, so they are cleaned up here to avoid leaking across
+// specs. The shared test CRDs are left in place (the kind cluster is
+// ephemeral).
+func (s *Provider) AfterEach() {
+	ctx := GinkgoT().Context()
+	ns := s.framework.Namespace.Name
+	for _, name := range []string{referentStoreName(s.framework), nsWhitelistStoreName(s.framework)} {
+		_ = s.framework.CRClient.Delete(ctx, &esv1.ClusterSecretStore{
+			ObjectMeta: metav1.ObjectMeta{Name: name},
+		})
+	}
+	_ = s.framework.CRClient.Delete(ctx, &rbac.ClusterRoleBinding{
+		ObjectMeta: metav1.ObjectMeta{Name: clusterRoleName(ns)},
+	})
+	_ = s.framework.CRClient.Delete(ctx, &rbac.ClusterRole{
+		ObjectMeta: metav1.ObjectMeta{Name: clusterRoleName(ns)},
+	})
+	if s.altNamespace != "" {
+		_ = util.DeleteKubeNamespace(s.altNamespace, s.framework.KubeClientSet)
+	}
+}
+
+// CreateSecret seeds an E2ETestResource CR from the parsed JSON value. The
+// framework calls this for every entry in tc.Secrets.
+func (s *Provider) CreateSecret(key string, val framework.SecretEntry) {
+	createTestResource(s.framework, crdGVK, s.framework.Namespace.Name, key, val)
+}
+
+func (s *Provider) DeleteSecret(key string) {
+	deleteTestResource(s.framework, crdGVK, s.framework.Namespace.Name, key)
+}
+
+// createTestResource builds a test CR from the parsed JSON value. A value with
+// a top-level "spec" key is applied as a spec/status envelope (so a test can
+// also seed status); any other value is taken as the spec body. Passing an
+// empty namespace creates a cluster-scoped object.
+func createTestResource(f *framework.Framework, gvk schema.GroupVersionKind, namespace, name string, val framework.SecretEntry) {
+	body := map[string]any{}
+	err := json.Unmarshal([]byte(val.Value), &body)
+	Expect(err).ToNot(HaveOccurred())
+
+	obj := &unstructured.Unstructured{}
+	obj.SetGroupVersionKind(gvk)
+	obj.SetName(name)
+	if namespace != "" {
+		obj.SetNamespace(namespace)
+	}
+	if len(val.Tags) > 0 {
+		obj.SetLabels(val.Tags)
+	}
+	if spec, ok := body["spec"]; ok {
+		obj.Object["spec"] = spec
+		if status, ok := body["status"]; ok {
+			obj.Object["status"] = status
+		}
+	} else {
+		obj.Object["spec"] = body
+	}
+
+	Expect(f.CRClient.Create(GinkgoT().Context(), obj)).To(Succeed())
+}
+
+func deleteTestResource(f *framework.Framework, gvk schema.GroupVersionKind, namespace, name string) {
+	obj := &unstructured.Unstructured{}
+	obj.SetGroupVersionKind(gvk)
+	obj.SetName(name)
+	if namespace != "" {
+		obj.SetNamespace(namespace)
+	}
+	Expect(f.CRClient.Delete(GinkgoT().Context(), obj)).To(Succeed())
+}
+
+// createAltNamespace provisions the second namespace used by the
+// ClusterSecretStore scenarios and remembers its name for cleanup.
+func (s *Provider) createAltNamespace() {
+	ns, err := util.CreateKubeNamespace("eso-crd-alt", s.framework.KubeClientSet)
+	Expect(err).ToNot(HaveOccurred())
+	s.altNamespace = ns.Name
+}
+
+// ensureCRD installs a test CRD if it does not exist and waits until it is
+// Established. It is idempotent so parallel specs can call it safely.
+func ensureCRD(f *framework.Framework, crd *apiextensionsv1.CustomResourceDefinition) {
+	ctx := GinkgoT().Context()
+	err := f.CRClient.Create(ctx, crd)
+	if err != nil && !apierrors.IsAlreadyExists(err) {
+		Expect(err).ToNot(HaveOccurred())
+	}
+	Eventually(func(g Gomega) {
+		got := &apiextensionsv1.CustomResourceDefinition{}
+		g.Expect(f.CRClient.Get(ctx, client.ObjectKey{Name: crd.Name}, got)).To(Succeed())
+		established := false
+		for _, c := range got.Status.Conditions {
+			if c.Type == apiextensionsv1.Established && c.Status == apiextensionsv1.ConditionTrue {
+				established = true
+			}
+		}
+		g.Expect(established).To(BeTrue(), "CRD %s is not Established yet", crd.Name)
+	}, time.Minute, time.Second).Should(Succeed())
+}
+
+func namespacedTestCRD() *apiextensionsv1.CustomResourceDefinition {
+	return testCRD(crdPlural, "e2etestresource", crdKind, apiextensionsv1.NamespaceScoped)
+}
+
+func clusterScopedTestCRD() *apiextensionsv1.CustomResourceDefinition {
+	return testCRD(clusterCRDPlural, "e2eclustertestresource", clusterCRDKind, apiextensionsv1.ClusterScoped)
+}
+
+func testCRD(plural, singular, kind string, scope apiextensionsv1.ResourceScope) *apiextensionsv1.CustomResourceDefinition {
+	return &apiextensionsv1.CustomResourceDefinition{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: plural + "." + crdGroup,
+		},
+		Spec: apiextensionsv1.CustomResourceDefinitionSpec{
+			Group: crdGroup,
+			Names: apiextensionsv1.CustomResourceDefinitionNames{
+				Plural:   plural,
+				Singular: singular,
+				Kind:     kind,
+				ListKind: kind + "List",
+			},
+			Scope: scope,
+			Versions: []apiextensionsv1.CustomResourceDefinitionVersion{
+				{
+					Name:    crdVersion,
+					Served:  true,
+					Storage: true,
+					Schema: &apiextensionsv1.CustomResourceValidation{
+						OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{
+							Type: "object",
+							Properties: map[string]apiextensionsv1.JSONSchemaProps{
+								// spec and status carry arbitrary content: the
+								// provider reads fields out of either via a GJSON
+								// path. No status subresource is declared, so a test
+								// can seed status on create.
+								"spec": {
+									Type:                   "object",
+									XPreserveUnknownFields: new(true),
+								},
+								"status": {
+									Type:                   "object",
+									XPreserveUnknownFields: new(true),
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+}
+
+func (s *Provider) storeName() string {
+	return s.framework.Namespace.Name
+}
+
+func whitelistStoreName(f *framework.Framework) string {
+	return f.Namespace.Name + "-wl"
+}
+
+func referentStoreName(f *framework.Framework) string {
+	return f.Namespace.Name + "-referent"
+}
+
+func nsWhitelistStoreName(f *framework.Framework) string {
+	return f.Namespace.Name + "-nswl"
+}
+
+func getOnlyStoreName(f *framework.Framework) string {
+	return f.Namespace.Name + "-getonly"
+}
+
+func clusterRoleName(ns string) string {
+	return "eso-crd-e2e-" + ns
+}
+
+// namespacedResource is the API coordinate set of the namespaced test kind.
+func namespacedResource() esv1.CRDProviderResource {
+	return esv1.CRDProviderResource{Group: crdGroup, Version: crdVersion, Kind: crdKind}
+}
+
+// inClusterProviderSpec returns a CRD provider that reads the local cluster,
+// authenticating as the named ServiceAccount via auth.serviceAccount.
+// Server.URL is omitted, so it defaults to the in-cluster API
+// (kubernetes.default). The API server's CA is published in every namespace as
+// the kube-root-ca.crt ConfigMap; reference it the same way the Kubernetes
+// provider does, otherwise the TLS handshake to the API falls back to system
+// roots and fails. The ServiceAccount selector carries no namespace, so for a
+// ClusterSecretStore it resolves as referent auth (in the consuming
+// ExternalSecret's namespace); for a SecretStore it resolves in the store's own
+// namespace.
+func inClusterProviderSpec(saName string, res esv1.CRDProviderResource) *esv1.CRDProvider {
+	return &esv1.CRDProvider{
+		Server: esv1.KubernetesServer{
+			CAProvider: &esv1.CAProvider{
+				Type: esv1.CAProviderTypeConfigMap,
+				Name: "kube-root-ca.crt",
+				Key:  "ca.crt",
+			},
+		},
+		Auth: &esv1.KubernetesAuth{
+			ServiceAccount: &esmeta.ServiceAccountSelector{Name: saName},
+		},
+		Resource: res,
+	}
+}
+
+// readRole builds a namespaced Role granting the given verbs on one resource.
+func readRole(name, namespace, apiGroup, plural string, verbs []string) *rbac.Role {
+	return &rbac.Role{
+		ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
+		Rules: []rbac.PolicyRule{
+			{APIGroups: []string{apiGroup}, Resources: []string{plural}, Verbs: verbs},
+		},
+	}
+}
+
+// bindRole binds a Role to a ServiceAccount in the same namespace.
+func bindRole(name, namespace, roleName, saName string) *rbac.RoleBinding {
+	return &rbac.RoleBinding{
+		ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
+		Subjects: []rbac.Subject{
+			{Kind: "ServiceAccount", Name: saName, Namespace: namespace},
+		},
+		RoleRef: rbac.RoleRef{
+			Kind:     "Role",
+			Name:     roleName,
+			APIGroup: "rbac.authorization.k8s.io",
+		},
+	}
+}
+
+// grantClusterRead grants the test namespace's ServiceAccount cluster-wide read
+// access to one resource. A ClusterSecretStore over a namespaced kind runs a
+// cluster-wide SelfSubjectAccessReview and may list across namespaces, so a
+// namespaced Role is not enough even when a single namespace is read.
+func grantClusterRead(f *framework.Framework, apiGroup, plural, saName string, verbs []string) {
+	ns := f.Namespace.Name
+	cr := &rbac.ClusterRole{
+		ObjectMeta: metav1.ObjectMeta{Name: clusterRoleName(ns)},
+		Rules: []rbac.PolicyRule{
+			{APIGroups: []string{apiGroup}, Resources: []string{plural}, Verbs: verbs},
+		},
+	}
+	crb := &rbac.ClusterRoleBinding{
+		ObjectMeta: metav1.ObjectMeta{Name: clusterRoleName(ns)},
+		Subjects: []rbac.Subject{
+			{Kind: "ServiceAccount", Name: saName, Namespace: ns},
+		},
+		RoleRef: rbac.RoleRef{
+			Kind:     "ClusterRole",
+			Name:     clusterRoleName(ns),
+			APIGroup: "rbac.authorization.k8s.io",
+		},
+	}
+	Expect(f.CRClient.Create(GinkgoT().Context(), cr)).To(Succeed())
+	Expect(f.CRClient.Create(GinkgoT().Context(), crb)).To(Succeed())
+}
+
+// CreateStore creates the namespaced RBAC granting the default ServiceAccount
+// read access to the test CRD, plus the default SecretStore. The same Role and
+// RoleBinding also serve the whitelist SecretStore.
+func (s *Provider) CreateStore() {
+	ctx := GinkgoT().Context()
+	ns := s.framework.Namespace.Name
+
+	role := readRole("eso-crd-read", ns, crdGroup, crdPlural, []string{"get", "list", "watch"})
+	rb := bindRole("eso-crd-rb", ns, role.Name, "default")
+	Expect(s.framework.CRClient.Create(ctx, role)).To(Succeed())
+	Expect(s.framework.CRClient.Create(ctx, rb)).To(Succeed())
+
+	store := &esv1.SecretStore{
+		ObjectMeta: metav1.ObjectMeta{Name: s.storeName(), Namespace: ns},
+		Spec: esv1.SecretStoreSpec{
+			Provider: &esv1.SecretStoreProvider{CRD: inClusterProviderSpec("default", namespacedResource())},
+		},
+	}
+	Expect(s.framework.CRClient.Create(ctx, store)).To(Succeed())
+}
+
+// CreateWhitelistStore creates a second SecretStore with a whitelist that
+// allows only names matching "^e2e-crd-.*$" and the property "spec.password".
+// It reuses the default-SA Role/RoleBinding created by CreateStore.
+func (s *Provider) CreateWhitelistStore() {
+	ns := s.framework.Namespace.Name
+	prov := inClusterProviderSpec("default", namespacedResource())
+	prov.Whitelist = &esv1.CRDProviderWhitelist{
+		Rules: []esv1.CRDProviderWhitelistRule{
+			{Name: "^e2e-crd-.*$", Properties: []string{`^spec\.password$`}},
+		},
+	}
+	store := &esv1.SecretStore{
+		ObjectMeta: metav1.ObjectMeta{Name: whitelistStoreName(s.framework), Namespace: ns},
+		Spec: esv1.SecretStoreSpec{
+			Provider: &esv1.SecretStoreProvider{CRD: prov},
+		},
+	}
+	Expect(s.framework.CRClient.Create(GinkgoT().Context(), store)).To(Succeed())
+}
+
+// CreateGetOnlyStore creates a SecretStore backed by a ServiceAccount that
+// holds only the "get" verb. The provider checks "get" at store bootstrap and
+// defers the "list" check to GetAllSecrets, so this store must serve remoteRef
+// reads while refusing dataFrom.find.
+func (s *Provider) CreateGetOnlyStore() {
+	ctx := GinkgoT().Context()
+	ns := s.framework.Namespace.Name
+
+	sa := &corev1.ServiceAccount{
+		ObjectMeta: metav1.ObjectMeta{Name: getOnlySAName, Namespace: ns},
+	}
+	role := readRole("eso-crd-get-only", ns, crdGroup, crdPlural, []string{"get"})
+	rb := bindRole("eso-crd-get-only-rb", ns, role.Name, getOnlySAName)
+	Expect(s.framework.CRClient.Create(ctx, sa)).To(Succeed())
+	Expect(s.framework.CRClient.Create(ctx, role)).To(Succeed())
+	Expect(s.framework.CRClient.Create(ctx, rb)).To(Succeed())
+
+	store := &esv1.SecretStore{
+		ObjectMeta: metav1.ObjectMeta{Name: getOnlyStoreName(s.framework), Namespace: ns},
+		Spec: esv1.SecretStoreSpec{
+			Provider: &esv1.SecretStoreProvider{CRD: inClusterProviderSpec(getOnlySAName, namespacedResource())},
+		},
+	}
+	Expect(s.framework.CRClient.Create(ctx, store)).To(Succeed())
+}
+
+// CreateReferentStore creates a referent ClusterSecretStore: its
+// auth.serviceAccount carries no namespace, so the SA is resolved in the
+// consuming ExternalSecret's namespace. It also creates the ClusterRole and
+// ClusterRoleBinding shared by every ClusterSecretStore in this suite.
+func (s *Provider) CreateReferentStore() {
+	grantClusterRead(s.framework, crdGroup, crdPlural, "default", []string{"get", "list", "watch"})
+	s.createClusterStore(referentStoreName(s.framework), nil)
+}
+
+// CreateNamespaceWhitelistStore creates a referent ClusterSecretStore whose
+// whitelist admits objects from the alt namespace only. A namespace rule is
+// valid on a ClusterSecretStore alone, so this is the only store shape that can
+// exercise the namespace branch of the whitelist matcher.
+func (s *Provider) CreateNamespaceWhitelistStore() {
+	s.createClusterStore(nsWhitelistStoreName(s.framework), &esv1.CRDProviderWhitelist{
+		Rules: []esv1.CRDProviderWhitelistRule{
+			{Namespace: "^" + s.altNamespace + "$"},
+		},
+	})
+}
+
+// createClusterStore creates a referent ClusterSecretStore over the namespaced
+// test kind, optionally carrying a whitelist.
+func (s *Provider) createClusterStore(name string, wl *esv1.CRDProviderWhitelist) {
+	ns := s.framework.Namespace.Name
+	prov := inClusterProviderSpec("default", namespacedResource())
+	prov.Whitelist = wl
+	// A ClusterSecretStore requires CAProvider.namespace (a SecretStore must
+	// leave it empty), so pin it to the namespace holding the CA ConfigMap.
+	prov.Server.CAProvider.Namespace = &ns
+
+	css := &esv1.ClusterSecretStore{
+		ObjectMeta: metav1.ObjectMeta{Name: name},
+		Spec: esv1.SecretStoreSpec{
+			Provider: &esv1.SecretStoreProvider{CRD: prov},
+		},
+	}
+	Expect(s.framework.CRClient.Create(GinkgoT().Context(), css)).To(Succeed())
+}

+ 1 - 0
e2e/suites/provider/cases/import.go

@@ -24,6 +24,7 @@ import (
 	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/aws/secretsmanager"
 	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/azure"
 	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/conjur"
+	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/crd"
 	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/delinea"
 	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/fake"
 	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/gcp"