Browse Source

Added generic webhook provider

This provider allows a secretstore with a generic url (templated)
which will be called with a defined method, headers (templated)
and optional body (also templated)
The response can be parsed out with a jsonPath expression
Willem Monsuwe 4 years ago
parent
commit
d04508e974

+ 4 - 0
apis/externalsecrets/v1alpha1/secretstore_types.go

@@ -77,6 +77,10 @@ type SecretStoreProvider struct {
 	// Alibaba configures this store to sync secrets using Alibaba Cloud provider
 	// +optional
 	Alibaba *AlibabaProvider `json:"alibaba,omitempty"`
+
+	// Webhook configures this store to sync secrets using a generic templated webhook
+	// +optional
+	Webhook *WebhookProvider `json:"webhook,omitempty"`
 }
 
 type SecretStoreRetrySettings struct {

+ 101 - 0
apis/externalsecrets/v1alpha1/secretstore_webhook_types.go

@@ -0,0 +1,101 @@
+/*
+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 v1alpha1
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
+)
+
+// AkeylessProvider Configures an store to sync secrets using Akeyless KV.
+type WebhookProvider struct {
+	// Webhook Method
+	// +optional, default GET
+	Method string `json:"method,omitempty"`
+
+	// Webhook url to call
+	URL string `json:"url"`
+
+	// Headers
+	// +optional
+	Headers map[string]string `json:"headers,omitempty"`
+
+	// Body
+	// +optional
+	Body string `json:"body,omitempty"`
+
+	// Timeout
+	// +optional
+	Timeout *metav1.Duration `json:"timeout,omitempty"`
+
+	// Result formatting
+	Result WebhookResult `json:"result"`
+
+	// Secrets to fill in templates
+	// These secrets will be passed to the templating function as key value pairs under the given name
+	// +optional
+	Secrets []WebhookSecret `json:"secrets,omitempty"`
+
+	// PEM encoded CA bundle used to validate webhook server certificate. Only used
+	// if the Server URL is using HTTPS protocol. This parameter is ignored for
+	// plain HTTP protocol connection. If not set the system root certificates
+	// are used to validate the TLS connection.
+	// +optional
+	CABundle []byte `json:"caBundle,omitempty"`
+
+	// The provider for the CA bundle to use to validate webhook server certificate.
+	// +optional
+	CAProvider *WebhookCAProvider `json:"caProvider,omitempty"`
+}
+
+type WebhookCAProviderType string
+
+const (
+	WebhookCAProviderTypeSecret    WebhookCAProviderType = "Secret"
+	WebhookCAProviderTypeConfigMap WebhookCAProviderType = "ConfigMap"
+)
+
+// Defines a location to fetch the cert for the webhook provider from.
+type WebhookCAProvider struct {
+	// The type of provider to use such as "Secret", or "ConfigMap".
+	// +kubebuilder:validation:Enum="Secret";"ConfigMap"
+	Type WebhookCAProviderType `json:"type"`
+
+	// The name of the object located at the provider type.
+	Name string `json:"name"`
+
+	// The key the value inside of the provider type to use, only used with "Secret" type
+	// +kubebuilder:validation:Optional
+	Key string `json:"key,omitempty"`
+
+	// The namespace the Provider type is in.
+	// +optional
+	Namespace *string `json:"namespace,omitempty"`
+}
+
+type WebhookResult struct {
+	// Json path of return value
+	// +optional
+	JSONPath string `json:"jsonPath,omitempty"`
+}
+
+type WebhookSecret struct {
+	// Name of this secret in templates
+	Name string `json:"name"`
+
+	// Secret ref to fill in credentials
+	SecretRef esmeta.SecretKeySelector `json:"secretRef"`
+}

+ 101 - 0
apis/externalsecrets/v1alpha1/zz_generated.deepcopy.go

@@ -934,6 +934,11 @@ func (in *SecretStoreProvider) DeepCopyInto(out *SecretStoreProvider) {
 		*out = new(AlibabaProvider)
 		(*in).DeepCopyInto(*out)
 	}
+	if in.Webhook != nil {
+		in, out := &in.Webhook, &out.Webhook
+		*out = new(WebhookProvider)
+		(*in).DeepCopyInto(*out)
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretStoreProvider.
@@ -1276,6 +1281,102 @@ func (in *VaultProvider) DeepCopy() *VaultProvider {
 }
 
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *WebhookCAProvider) DeepCopyInto(out *WebhookCAProvider) {
+	*out = *in
+	if in.Namespace != nil {
+		in, out := &in.Namespace, &out.Namespace
+		*out = new(string)
+		**out = **in
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebhookCAProvider.
+func (in *WebhookCAProvider) DeepCopy() *WebhookCAProvider {
+	if in == nil {
+		return nil
+	}
+	out := new(WebhookCAProvider)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *WebhookProvider) DeepCopyInto(out *WebhookProvider) {
+	*out = *in
+	if in.Headers != nil {
+		in, out := &in.Headers, &out.Headers
+		*out = make(map[string]string, len(*in))
+		for key, val := range *in {
+			(*out)[key] = val
+		}
+	}
+	if in.Timeout != nil {
+		in, out := &in.Timeout, &out.Timeout
+		*out = new(v1.Duration)
+		**out = **in
+	}
+	out.Result = in.Result
+	if in.Secrets != nil {
+		in, out := &in.Secrets, &out.Secrets
+		*out = make([]WebhookSecret, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+	if in.CABundle != nil {
+		in, out := &in.CABundle, &out.CABundle
+		*out = make([]byte, len(*in))
+		copy(*out, *in)
+	}
+	if in.CAProvider != nil {
+		in, out := &in.CAProvider, &out.CAProvider
+		*out = new(WebhookCAProvider)
+		(*in).DeepCopyInto(*out)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebhookProvider.
+func (in *WebhookProvider) DeepCopy() *WebhookProvider {
+	if in == nil {
+		return nil
+	}
+	out := new(WebhookProvider)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *WebhookResult) DeepCopyInto(out *WebhookResult) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebhookResult.
+func (in *WebhookResult) DeepCopy() *WebhookResult {
+	if in == nil {
+		return nil
+	}
+	out := new(WebhookResult)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *WebhookSecret) DeepCopyInto(out *WebhookSecret) {
+	*out = *in
+	in.SecretRef.DeepCopyInto(&out.SecretRef)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebhookSecret.
+func (in *WebhookSecret) DeepCopy() *WebhookSecret {
+	if in == nil {
+		return nil
+	}
+	out := new(WebhookSecret)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *YandexLockboxAuth) DeepCopyInto(out *YandexLockboxAuth) {
 	*out = *in
 	in.AuthorizedKey.DeepCopyInto(&out.AuthorizedKey)

+ 100 - 0
deploy/crds/external-secrets.io_clustersecretstores.yaml

@@ -914,6 +914,106 @@ spec:
                     - path
                     - server
                     type: object
+                  webhook:
+                    description: Webhook configures this store to sync secrets using
+                      a generic templated webhook
+                    properties:
+                      body:
+                        description: Body
+                        type: string
+                      caBundle:
+                        description: PEM encoded CA bundle used to validate webhook
+                          server certificate. Only used if the Server URL is using
+                          HTTPS protocol. This parameter is ignored for plain HTTP
+                          protocol connection. If not set the system root certificates
+                          are used to validate the TLS connection.
+                        format: byte
+                        type: string
+                      caProvider:
+                        description: The provider for the CA bundle to use to validate
+                          webhook server certificate.
+                        properties:
+                          key:
+                            description: The key the value inside of the provider
+                              type to use, only used with "Secret" type
+                            type: string
+                          name:
+                            description: The name of the object located at the provider
+                              type.
+                            type: string
+                          namespace:
+                            description: The namespace the Provider type is in.
+                            type: string
+                          type:
+                            description: The type of provider to use such as "Secret",
+                              or "ConfigMap".
+                            enum:
+                            - Secret
+                            - ConfigMap
+                            type: string
+                        required:
+                        - name
+                        - type
+                        type: object
+                      headers:
+                        additionalProperties:
+                          type: string
+                        description: Headers
+                        type: object
+                      method:
+                        description: Webhook Method
+                        type: string
+                      result:
+                        description: Result formatting
+                        properties:
+                          jsonPath:
+                            description: Json path of return value
+                            type: string
+                        type: object
+                      secrets:
+                        description: Secrets to fill in templates These secrets will
+                          be passed to the templating function as key value pairs
+                          under the given name
+                        items:
+                          properties:
+                            name:
+                              description: Name of this secret in templates
+                              type: string
+                            secretRef:
+                              description: Secret ref to fill in credentials
+                              properties:
+                                key:
+                                  description: The key of the entry in the Secret
+                                    resource's `data` field to be used. Some instances
+                                    of this field may be defaulted, in others it may
+                                    be required.
+                                  type: string
+                                name:
+                                  description: The name of the Secret resource being
+                                    referred to.
+                                  type: string
+                                namespace:
+                                  description: Namespace of the resource being referred
+                                    to. Ignored if referent is not cluster-scoped.
+                                    cluster-scoped defaults to the namespace of the
+                                    referent.
+                                  type: string
+                              type: object
+                          required:
+                          - name
+                          - secretRef
+                          type: object
+                        type: array
+                      timeout:
+                        description: Timeout
+                        type: string
+                      url:
+                        description: Webhook url to call
+                        type: string
+                    required:
+                    - result
+                    - url
+                    type: object
                   yandexlockbox:
                     description: YandexLockbox configures this store to sync secrets
                       using Yandex Lockbox provider

+ 100 - 0
deploy/crds/external-secrets.io_secretstores.yaml

@@ -914,6 +914,106 @@ spec:
                     - path
                     - server
                     type: object
+                  webhook:
+                    description: Webhook configures this store to sync secrets using
+                      a generic templated webhook
+                    properties:
+                      body:
+                        description: Body
+                        type: string
+                      caBundle:
+                        description: PEM encoded CA bundle used to validate webhook
+                          server certificate. Only used if the Server URL is using
+                          HTTPS protocol. This parameter is ignored for plain HTTP
+                          protocol connection. If not set the system root certificates
+                          are used to validate the TLS connection.
+                        format: byte
+                        type: string
+                      caProvider:
+                        description: The provider for the CA bundle to use to validate
+                          webhook server certificate.
+                        properties:
+                          key:
+                            description: The key the value inside of the provider
+                              type to use, only used with "Secret" type
+                            type: string
+                          name:
+                            description: The name of the object located at the provider
+                              type.
+                            type: string
+                          namespace:
+                            description: The namespace the Provider type is in.
+                            type: string
+                          type:
+                            description: The type of provider to use such as "Secret",
+                              or "ConfigMap".
+                            enum:
+                            - Secret
+                            - ConfigMap
+                            type: string
+                        required:
+                        - name
+                        - type
+                        type: object
+                      headers:
+                        additionalProperties:
+                          type: string
+                        description: Headers
+                        type: object
+                      method:
+                        description: Webhook Method
+                        type: string
+                      result:
+                        description: Result formatting
+                        properties:
+                          jsonPath:
+                            description: Json path of return value
+                            type: string
+                        type: object
+                      secrets:
+                        description: Secrets to fill in templates These secrets will
+                          be passed to the templating function as key value pairs
+                          under the given name
+                        items:
+                          properties:
+                            name:
+                              description: Name of this secret in templates
+                              type: string
+                            secretRef:
+                              description: Secret ref to fill in credentials
+                              properties:
+                                key:
+                                  description: The key of the entry in the Secret
+                                    resource's `data` field to be used. Some instances
+                                    of this field may be defaulted, in others it may
+                                    be required.
+                                  type: string
+                                name:
+                                  description: The name of the Secret resource being
+                                    referred to.
+                                  type: string
+                                namespace:
+                                  description: Namespace of the resource being referred
+                                    to. Ignored if referent is not cluster-scoped.
+                                    cluster-scoped defaults to the namespace of the
+                                    referent.
+                                  type: string
+                              type: object
+                          required:
+                          - name
+                          - secretRef
+                          type: object
+                        type: array
+                      timeout:
+                        description: Timeout
+                        type: string
+                      url:
+                        description: Webhook url to call
+                        type: string
+                    required:
+                    - result
+                    - url
+                    type: object
                   yandexlockbox:
                     description: YandexLockbox configures this store to sync secrets
                       using Yandex Lockbox provider

+ 11 - 2
go.mod

@@ -4,6 +4,7 @@ go 1.17
 
 replace (
 	github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1 => ./apis/externalsecrets/v1alpha1
+	github.com/external-secrets/external-secrets/e2e/framework/log => ./e2e/framework/log
 	github.com/external-secrets/external-secrets/pkg/provider/gitlab => ./pkg/provider/gitlab
 	google.golang.org/grpc => google.golang.org/grpc v1.27.0
 	k8s.io/api => k8s.io/api v0.21.2
@@ -38,6 +39,10 @@ require (
 	github.com/Azure/go-autorest/autorest/azure/auth v0.5.7
 	github.com/IBM/go-sdk-core/v5 v5.5.0
 	github.com/IBM/secrets-manager-go-sdk v1.0.23
+	github.com/Masterminds/goutils v1.1.1 // indirect
+	github.com/Masterminds/semver v1.5.0 // indirect
+	github.com/Masterminds/sprig v2.22.0+incompatible
+	github.com/PaesslerAG/jsonpath v0.1.1
 	github.com/akeylesslabs/akeyless-go-cloud-id v0.3.2
 	github.com/akeylesslabs/akeyless-go/v2 v2.5.11
 	github.com/aliyun/alibaba-cloud-sdk-go v1.61.1192
@@ -49,6 +54,8 @@ require (
 	github.com/google/uuid v1.2.0
 	github.com/googleapis/gax-go v1.0.3
 	github.com/hashicorp/vault/api v1.0.5-0.20210224012239-b540be4b7ec4
+	github.com/huandu/xstrings v1.3.2 // indirect
+	github.com/kr/pretty v0.2.1 // indirect
 	github.com/lestrrat-go/jwx v1.2.1
 	github.com/onsi/ginkgo v1.16.5
 	github.com/onsi/gomega v1.16.0
@@ -67,6 +74,7 @@ require (
 	google.golang.org/api v0.45.0
 	google.golang.org/genproto v0.0.0-20210413151531-c14fb6ef47c3
 	google.golang.org/grpc v1.43.0
+	gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
 	grpc.go4.org v0.0.0-20170609214715-11d0a25b4919
 	k8s.io/api v0.21.3
 	k8s.io/apimachinery v0.21.3
@@ -88,6 +96,7 @@ require (
 	github.com/Azure/go-autorest/logger v0.2.0 // indirect
 	github.com/Azure/go-autorest/tracing v0.6.0 // indirect
 	github.com/BurntSushi/toml v0.3.1 // indirect
+	github.com/PaesslerAG/gval v1.0.0 // indirect
 	github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef // indirect
 	github.com/aws/aws-sdk-go-v2 v0.23.0 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
@@ -133,7 +142,6 @@ require (
 	github.com/inconshreveable/mousetrap v1.0.0 // indirect
 	github.com/jmespath/go-jmespath v0.4.0 // indirect
 	github.com/json-iterator/go v1.1.11 // indirect
-	github.com/kr/pretty v0.2.1 // indirect
 	github.com/leodido/go-urn v1.2.0 // indirect
 	github.com/lestrrat-go/backoff/v2 v2.0.7 // indirect
 	github.com/lestrrat-go/blackmagic v1.0.0 // indirect
@@ -143,8 +151,10 @@ require (
 	github.com/mattn/go-colorable v0.1.8 // indirect
 	github.com/mattn/go-isatty v0.0.12 // indirect
 	github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect
+	github.com/mitchellh/copystructure v1.0.0 // indirect
 	github.com/mitchellh/go-homedir v1.1.0 // indirect
 	github.com/mitchellh/mapstructure v1.3.3 // indirect
+	github.com/mitchellh/reflectwalk v1.0.0 // indirect
 	github.com/moby/spdystream v0.2.0 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.1 // indirect
@@ -184,7 +194,6 @@ require (
 	gopkg.in/square/go-jose.v2 v2.5.1 // indirect
 	gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
-	gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
 	honnef.co/go/tools v0.1.4 // indirect
 	k8s.io/apiextensions-apiserver v0.21.2 // indirect
 	k8s.io/component-base v0.21.2 // indirect

+ 15 - 0
go.sum

@@ -72,9 +72,20 @@ github.com/IBM/go-sdk-core/v5 v5.5.0 h1:etP4m0kzMCxjZRI4Bu6cRTfK9YDvY3xFuagXugkC
 github.com/IBM/go-sdk-core/v5 v5.5.0/go.mod h1:Sn+z+qTDREQvCr+UFa22TqqfXNxx3o723y8GsfLV8e0=
 github.com/IBM/secrets-manager-go-sdk v1.0.23 h1:YvRB2jmCfXVwTiTozCNVIRfl6q9Qcl2JiL4x6chOSI4=
 github.com/IBM/secrets-manager-go-sdk v1.0.23/go.mod h1:ruP6eQ0/J/zHBbnMfUyWeMsTe9vgnGL4rDeLiSKhZhU=
+github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
+github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
+github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
+github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
+github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60=
+github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o=
 github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ=
 github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=
 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
+github.com/PaesslerAG/gval v1.0.0 h1:GEKnRwkWDdf9dOmKcNrar9EA1bz1z9DqPIO1+iLzhd8=
+github.com/PaesslerAG/gval v1.0.0/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v24IBN1I=
+github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8=
+github.com/PaesslerAG/jsonpath v0.1.1 h1:c1/AToHQMVsduPAa4Vh6xp2U0evy4t8SWp8imEsylIk=
+github.com/PaesslerAG/jsonpath v0.1.1/go.mod h1:lVboNxFGal/VwW6d9JzIy56bUsYAP6tH/x80vjnCseY=
 github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
 github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
 github.com/akeylesslabs/akeyless-go-cloud-id v0.3.2 h1:1h4udX3Y5KgSG0m4Th2bHfaYxZB9fbngiij9PrKEp6c=
@@ -414,6 +425,8 @@ github.com/hashicorp/vault/sdk v0.1.14-0.20200519221838-e0cfd64bc267 h1:e1ok06zG
 github.com/hashicorp/vault/sdk v0.1.14-0.20200519221838-e0cfd64bc267/go.mod h1:WX57W2PwkrOPQ6rVQk+dy5/htHIaB4aBM70EwKThu10=
 github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM=
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
+github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw=
+github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
 github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
@@ -507,6 +520,7 @@ github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182aff
 github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
 github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
 github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
+github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ=
 github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
 github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
 github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
@@ -522,6 +536,7 @@ github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh
 github.com/mitchellh/mapstructure v1.3.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
 github.com/mitchellh/mapstructure v1.3.3 h1:SzB1nHZ2Xi+17FP0zVQBHIZqvwRN9408fJO8h+eeNA8=
 github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY=
 github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
 github.com/moby/spdystream v0.2.0 h1:cjW1zVyyoiM0T7b6UoySUFqzXMoqRckQtXwGPiBhOM8=
 github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c=

+ 1 - 0
pkg/provider/register/register.go

@@ -26,5 +26,6 @@ import (
 	_ "github.com/external-secrets/external-secrets/pkg/provider/ibm"
 	_ "github.com/external-secrets/external-secrets/pkg/provider/oracle"
 	_ "github.com/external-secrets/external-secrets/pkg/provider/vault"
+	_ "github.com/external-secrets/external-secrets/pkg/provider/webhook"
 	_ "github.com/external-secrets/external-secrets/pkg/provider/yandex/lockbox"
 )

+ 382 - 0
pkg/provider/webhook/webhook.go

@@ -0,0 +1,382 @@
+/*
+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 webhook
+
+import (
+	"bytes"
+	"context"
+	"crypto/tls"
+	"crypto/x509"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strings"
+	tpl "text/template"
+
+	"github.com/Masterminds/sprig"
+	"github.com/PaesslerAG/jsonpath"
+	"gopkg.in/yaml.v3"
+	corev1 "k8s.io/api/core/v1"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+	esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
+	"github.com/external-secrets/external-secrets/pkg/provider"
+	"github.com/external-secrets/external-secrets/pkg/provider/schema"
+	"github.com/external-secrets/external-secrets/pkg/template"
+)
+
+// Provider satisfies the provider interface.
+type Provider struct{}
+
+type WebHook struct {
+	kube      client.Client
+	store     esv1alpha1.GenericStore
+	namespace string
+	storeKind string
+}
+
+func init() {
+	schema.Register(&Provider{}, &esv1alpha1.SecretStoreProvider{
+		Webhook: &esv1alpha1.WebhookProvider{},
+	})
+}
+
+func (p *Provider) NewClient(ctx context.Context, store esv1alpha1.GenericStore, kube client.Client, namespace string) (provider.SecretsClient, error) {
+	whClient := &WebHook{
+		kube:      kube,
+		store:     store,
+		namespace: namespace,
+		storeKind: store.GetObjectKind().GroupVersionKind().Kind,
+	}
+	return whClient, nil
+}
+
+func getProvider(store esv1alpha1.GenericStore) (*esv1alpha1.WebhookProvider, error) {
+	spc := store.GetSpec()
+	if spc == nil || spc.Provider == nil || spc.Provider.Webhook == nil {
+		return nil, fmt.Errorf("missing store provider webhook")
+	}
+	return spc.Provider.Webhook, nil
+}
+
+func (w *WebHook) getStoreSecret(ctx context.Context, ref esmeta.SecretKeySelector) (*corev1.Secret, error) {
+	ke := client.ObjectKey{
+		Name:      ref.Name,
+		Namespace: w.namespace,
+	}
+	if w.storeKind == esv1alpha1.ClusterSecretStoreKind {
+		if ref.Namespace == nil {
+			return nil, fmt.Errorf("no namespace on ClusterSecretStore webhook secret %s", ref.Name)
+		}
+		ke.Namespace = *ref.Namespace
+	}
+	secret := &corev1.Secret{}
+	if err := w.kube.Get(ctx, ke, secret); err != nil {
+		return nil, fmt.Errorf("failed to get clustersecretstore webhook secret %s: %w", ref.Name, err)
+	}
+	return secret, nil
+}
+
+func (w *WebHook) GetSecret(ctx context.Context, ref esv1alpha1.ExternalSecretDataRemoteRef) ([]byte, error) {
+	provider, err := getProvider(w.store)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get store: %w", err)
+	}
+	result, err := w.getWebhookData(ctx, provider, ref)
+	if err != nil {
+		return nil, err
+	}
+	if provider.Result.JSONPath != "" {
+		jsondata := interface{}(nil)
+		if err := yaml.Unmarshal(result, &jsondata); err != nil {
+			return nil, fmt.Errorf("failed to parse response json: %w", err)
+		}
+		jsondata, err = jsonpath.Get(provider.Result.JSONPath, jsondata)
+		if err != nil {
+			return nil, fmt.Errorf("failed to get response path %s: %w", provider.Result.JSONPath, err)
+		}
+		jsonvalue, ok := jsondata.(string)
+		if !ok {
+			return nil, fmt.Errorf("failed to get response (wrong type: %T)", jsondata)
+		}
+		return []byte(jsonvalue), nil
+	}
+
+	return result, nil
+}
+
+func (w *WebHook) GetSecretMap(ctx context.Context, ref esv1alpha1.ExternalSecretDataRemoteRef) (map[string][]byte, error) {
+	provider, err := getProvider(w.store)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get store: %w", err)
+	}
+	result, err := w.getWebhookData(ctx, provider, ref)
+	if err != nil {
+		return nil, err
+	}
+	jsondata := interface{}(nil)
+	var jsonvalue map[string]interface{}
+	var ok bool
+	if provider.Result.JSONPath != "" {
+		if err := yaml.Unmarshal(result, &jsondata); err != nil {
+			return nil, fmt.Errorf("failed to parse response json: %w", err)
+		}
+		jsondata, err = jsonpath.Get(provider.Result.JSONPath, jsondata)
+		if err != nil {
+			return nil, fmt.Errorf("failed to get response path %s: %w", provider.Result.JSONPath, err)
+		}
+		jsonvalue, ok = jsondata.(map[string]interface{})
+		if !ok {
+			jsonstring, ok := jsondata.(string)
+			if !ok {
+				return nil, fmt.Errorf("failed to get response (wrong type: %T)", jsondata)
+			}
+			if err := yaml.Unmarshal([]byte(jsonstring), &jsondata); err != nil {
+				return nil, fmt.Errorf("failed to parse data json: %w", err)
+			}
+			jsonvalue, ok = jsondata.(map[string]interface{})
+			if !ok {
+				return nil, fmt.Errorf("failed to get response (wrong type in data: %T)", jsondata)
+			}
+		}
+	} else {
+		if err := yaml.Unmarshal(result, &jsondata); err != nil {
+			return nil, fmt.Errorf("failed to parse data json: %w", err)
+		}
+		jsonvalue, ok = jsondata.(map[string]interface{})
+		if !ok {
+			return nil, fmt.Errorf("failed to get response (wrong type in body: %T)", jsondata)
+		}
+	}
+	values := make(map[string][]byte)
+	for rKey, rValue := range jsonvalue {
+		jVal, ok := rValue.(string)
+		if !ok {
+			return nil, fmt.Errorf("failed to get response (wrong type: %T)", rValue)
+		}
+		values[rKey] = []byte(jVal)
+	}
+	return values, nil
+}
+
+func (w *WebHook) getWebhookData(ctx context.Context, provider *esv1alpha1.WebhookProvider, ref esv1alpha1.ExternalSecretDataRemoteRef) ([]byte, error) {
+	data := map[string]map[string]string{
+		"remoteRef": {
+			"key":     url.QueryEscape(ref.Key),
+			"version": url.QueryEscape(ref.Version),
+		},
+	}
+	if provider.Secrets != nil {
+		for _, secref := range provider.Secrets {
+			if _, ok := data[secref.Name]; !ok {
+				data[secref.Name] = make(map[string]string)
+			}
+			secret, err := w.getStoreSecret(ctx, secref.SecretRef)
+			if err != nil {
+				return nil, err
+			}
+			for sKey, sVal := range secret.Data {
+				data[secref.Name][sKey] = string(sVal)
+			}
+		}
+	}
+	method := provider.Method
+	if method == "" {
+		method = "GET"
+	}
+	url, err := executeTemplateString(provider.URL, data)
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse url: %w", err)
+	}
+	body, err := executeTemplate(provider.Body, data)
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse body: %w", err)
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, url, &body)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create request: %w", err)
+	}
+	if provider.Headers != nil {
+		for hKey, hValueTpl := range provider.Headers {
+			hValue, err := executeTemplateString(hValueTpl, data)
+			if err != nil {
+				return nil, fmt.Errorf("failed to parse header %s: %w", hKey, err)
+			}
+			req.Header.Add(hKey, hValue)
+		}
+	}
+
+	client, err := w.getHTTPClient(ctx, provider)
+	if err != nil {
+		return nil, fmt.Errorf("failed to call endpoint: %w", err)
+	}
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("failed to call endpoint: %w", err)
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return nil, fmt.Errorf("endpoint gave error %s", resp.Status)
+	}
+	return io.ReadAll(resp.Body)
+}
+
+func (w *WebHook) getHTTPClient(_ context.Context, provider *esv1alpha1.WebhookProvider) (*http.Client, error) {
+	client := &http.Client{}
+	if provider.Timeout != nil {
+		client.Timeout = provider.Timeout.Duration
+	}
+	if len(provider.CABundle) != 0 || provider.CAProvider != nil {
+		caCertPool := x509.NewCertPool()
+		if len(provider.CABundle) > 0 {
+			ok := caCertPool.AppendCertsFromPEM(provider.CABundle)
+			if !ok {
+				return nil, fmt.Errorf("failed to append cabundle")
+			}
+		}
+
+		if provider.CAProvider != nil && w.storeKind == esv1alpha1.ClusterSecretStoreKind && provider.CAProvider.Namespace == nil {
+			return nil, fmt.Errorf("missing namespace on CAProvider secret")
+		}
+
+		if provider.CAProvider != nil {
+			var cert []byte
+			var err error
+
+			switch provider.CAProvider.Type {
+			case esv1alpha1.WebhookCAProviderTypeSecret:
+				cert, err = w.getCertFromSecret(provider)
+			case esv1alpha1.WebhookCAProviderTypeConfigMap:
+				cert, err = w.getCertFromConfigMap(provider)
+			default:
+				return nil, fmt.Errorf("unknown caprovider type: %s", provider.CAProvider.Type)
+			}
+
+			if err != nil {
+				return nil, err
+			}
+
+			ok := caCertPool.AppendCertsFromPEM(cert)
+			if !ok {
+				return nil, fmt.Errorf("failed to append cabundle")
+			}
+		}
+
+		tlsConf := &tls.Config{
+			RootCAs:    caCertPool,
+			MinVersion: tls.VersionTLS12,
+		}
+		client.Transport = &http.Transport{TLSClientConfig: tlsConf}
+	}
+	return client, nil
+}
+
+func (w *WebHook) getCertFromSecret(provider *esv1alpha1.WebhookProvider) ([]byte, error) {
+	secretRef := esmeta.SecretKeySelector{
+		Name: provider.CAProvider.Name,
+		Key:  provider.CAProvider.Key,
+	}
+
+	if provider.CAProvider.Namespace != nil {
+		secretRef.Namespace = provider.CAProvider.Namespace
+	}
+
+	ctx := context.Background()
+	res, err := w.secretKeyRef(ctx, &secretRef)
+	if err != nil {
+		return nil, err
+	}
+
+	return []byte(res), nil
+}
+
+func (w *WebHook) secretKeyRef(ctx context.Context, secretRef *esmeta.SecretKeySelector) (string, error) {
+	secret := &corev1.Secret{}
+	ref := client.ObjectKey{
+		Namespace: w.namespace,
+		Name:      secretRef.Name,
+	}
+	if (w.storeKind == esv1alpha1.ClusterSecretStoreKind) &&
+		(secretRef.Namespace != nil) {
+		ref.Namespace = *secretRef.Namespace
+	}
+	err := w.kube.Get(ctx, ref, secret)
+	if err != nil {
+		return "", err
+	}
+
+	keyBytes, ok := secret.Data[secretRef.Key]
+	if !ok {
+		return "", err
+	}
+
+	value := string(keyBytes)
+	valueStr := strings.TrimSpace(value)
+	return valueStr, nil
+}
+
+func (w *WebHook) getCertFromConfigMap(provider *esv1alpha1.WebhookProvider) ([]byte, error) {
+	objKey := client.ObjectKey{
+		Name: provider.CAProvider.Name,
+	}
+
+	if provider.CAProvider.Namespace != nil {
+		objKey.Namespace = *provider.CAProvider.Namespace
+	}
+
+	configMapRef := &corev1.ConfigMap{}
+	ctx := context.Background()
+	err := w.kube.Get(ctx, objKey, configMapRef)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get caprovider secret %s: %w", objKey.Name, err)
+	}
+
+	val, ok := configMapRef.Data[provider.CAProvider.Key]
+	if !ok {
+		return nil, fmt.Errorf("failed to get caprovider configmap %s -> %s", objKey.Name, provider.CAProvider.Key)
+	}
+
+	return []byte(val), nil
+}
+
+func (w *WebHook) Close(ctx context.Context) error {
+	return nil
+}
+
+func executeTemplateString(tmpl string, data map[string]map[string]string) (string, error) {
+	result, err := executeTemplate(tmpl, data)
+	if err != nil {
+		return "", err
+	}
+	return result.String(), nil
+}
+
+func executeTemplate(tmpl string, data map[string]map[string]string) (bytes.Buffer, error) {
+	var result bytes.Buffer
+	if tmpl == "" {
+		return result, nil
+	}
+	urlt, err := tpl.New("webhooktemplate").Funcs(sprig.TxtFuncMap()).Funcs(template.FuncMap()).Parse(tmpl)
+	if err != nil {
+		return result, err
+	}
+	if err := urlt.Execute(&result, data); err != nil {
+		return result, err
+	}
+	return result, nil
+}

+ 313 - 0
pkg/provider/webhook/webhook_test.go

@@ -0,0 +1,313 @@
+/*
+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 webhook
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+	"time"
+
+	"gopkg.in/yaml.v3"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+)
+
+type testCase struct {
+	Case string `json:"case,omitempty"`
+	Args args   `json:"args"`
+	Want want   `json:"want"`
+}
+
+type args struct {
+	URL        string `json:"url,omitempty"`
+	Body       string `json:"body,omitempty"`
+	Timeout    string `json:"timeout,omitempty"`
+	Key        string `json:"key,omitempty"`
+	Version    string `json:"version,omitempty"`
+	JSONPath   string `json:"jsonpath,omitempty"`
+	Response   string `json:"response,omitempty"`
+	StatusCode int    `json:"statuscode,omitempty"`
+}
+
+type want struct {
+	Path      string            `json:"path,omitempty"`
+	Err       string            `json:"err,omitempty"`
+	Result    string            `json:"result,omitempty"`
+	ResultMap map[string]string `json:"resultmap,omitempty"`
+}
+
+var testCases = `
+case: error url
+args:
+  url: /api/getsecret?id={{ .unclosed.template
+want:
+  err: failed to parse url
+---
+case: error body
+args:
+  url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
+  body: Body error {{ .unclosed.template
+want:
+  err: failed to parse body
+---
+case: error connection
+args:
+  url: 1/api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
+want:
+  err: failed to call endpoint
+---
+case: error not found
+args:
+  url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
+  key: testkey
+  version: 1
+  statuscode: 404
+  response: not found
+want:
+  path: /api/getsecret?id=testkey&version=1
+  err: endpoint gave error 404
+---
+case: error bad json
+args:
+  url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
+  key: testkey
+  version: 1
+  jsonpath: $.result.thesecret
+  response: '{"result":{"thesecret":"secret-value"}'
+want:
+  path: /api/getsecret?id=testkey&version=1
+  err: failed to parse response json
+---
+case: error bad jsonpath
+args:
+  url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
+  key: testkey
+  version: 1
+  jsonpath: $.result.thesecret
+  response: '{"result":{"nosecret":"secret-value"}}'
+want:
+  path: /api/getsecret?id=testkey&version=1
+  err: failed to get response path
+---
+case: error bad json data
+args:
+  url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
+  key: testkey
+  version: 1
+  jsonpath: $.result.thesecret
+  response: '{"result":{"thesecret":{"one":"secret-value"}}}'
+want:
+  path: /api/getsecret?id=testkey&version=1
+  err: failed to get response (wrong type
+---
+case: error timeout
+args:
+  url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
+  key: testkey
+  version: 1
+  response: secret-value
+  timeout: 0.01ms
+want:
+  path: /api/getsecret?id=testkey&version=1
+  err: context deadline exceeded
+---
+case: good plaintext
+args:
+  url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
+  key: testkey
+  version: 1
+  response: secret-value
+want:
+  path: /api/getsecret?id=testkey&version=1
+  result: secret-value
+---
+case: good json
+args:
+  url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
+  key: testkey
+  version: 1
+  jsonpath: $.result.thesecret
+  response: '{"result":{"thesecret":"secret-value"}}'
+want:
+  path: /api/getsecret?id=testkey&version=1
+  result: secret-value
+---
+case: good json map
+args:
+  url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
+  key: testkey
+  version: 1
+  jsonpath: $.result
+  response: '{"result":{"thesecret":"secret-value","alsosecret":"another-value"}}'
+want:
+  path: /api/getsecret?id=testkey&version=1
+  resultmap:
+    thesecret: secret-value
+    alsosecret: another-value
+---
+case: good json map string
+args:
+  url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
+  key: testkey
+  version: 1
+  response: '{"thesecret":"secret-value","alsosecret":"another-value"}'
+want:
+  path: /api/getsecret?id=testkey&version=1
+  resultmap:
+    thesecret: secret-value
+    alsosecret: another-value
+---
+case: error json map string
+args:
+  url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
+  key: testkey
+  version: 1
+  response: 'some simple string'
+want:
+  path: /api/getsecret?id=testkey&version=1
+  err: failed to get response (wrong type in body
+  resultmap:
+    thesecret: secret-value
+    alsosecret: another-value
+---
+case: error json map
+args:
+  url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
+  key: testkey
+  version: 1
+  jsonpath: $.result.thesecret
+  response: '{"result":{"thesecret":"secret-value","alsosecret":"another-value"}}'
+want:
+  path: /api/getsecret?id=testkey&version=1
+  err: failed to get response (wrong type in data
+  resultmap:
+    thesecret: secret-value
+    alsosecret: another-value
+`
+
+func TestWebhookGetSecret(t *testing.T) {
+	ydec := yaml.NewDecoder(bytes.NewReader([]byte(testCases)))
+	for {
+		var tc testCase
+		if err := ydec.Decode(&tc); err != nil {
+			if !errors.Is(err, io.EOF) {
+				t.Errorf("testcase decode error %w", err)
+			}
+			break
+		}
+		runTestCase(tc, t)
+	}
+}
+
+func runTestCase(tc testCase, t *testing.T) {
+	// Start a new server for every test case because the server wants to check the expected api path
+	ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+		if tc.Want.Path != "" && req.URL.String() != tc.Want.Path {
+			t.Errorf("%s: unexpected api path: %s, expected %s", tc.Case, req.URL.String(), tc.Want.Path)
+		}
+		if tc.Args.StatusCode != 0 {
+			rw.WriteHeader(tc.Args.StatusCode)
+		}
+		rw.Write([]byte(tc.Args.Response))
+	}))
+	defer ts.Close()
+
+	testStore := makeClusterSecretStore(ts.URL, tc.Args)
+	if tc.Args.Timeout != "" {
+		dur, err := time.ParseDuration(tc.Args.Timeout)
+		if err != nil {
+			t.Errorf("%s: error parsing timeout '%s': %s", tc.Case, tc.Args.Timeout, err.Error())
+			return
+		}
+		testStore.Spec.Provider.Webhook.Timeout = &metav1.Duration{Duration: dur}
+	}
+	testProv := &Provider{}
+	client, err := testProv.NewClient(context.Background(), testStore, nil, "testnamespace")
+	if err != nil {
+		t.Errorf("%s: error creating client: %s", tc.Case, err.Error())
+		return
+	}
+
+	testRef := esv1alpha1.ExternalSecretDataRemoteRef{
+		Key:     tc.Args.Key,
+		Version: tc.Args.Version,
+	}
+	if tc.Want.ResultMap != nil {
+		secretmap, err := client.GetSecretMap(context.Background(), testRef)
+		errStr := ""
+		if err != nil {
+			errStr = err.Error()
+		}
+		if (tc.Want.Err == "") != (errStr == "") || !strings.Contains(errStr, tc.Want.Err) {
+			t.Errorf("%s: unexpected error: '%s' (expected '%s')", tc.Case, errStr, tc.Want.Err)
+		}
+		if err == nil {
+			for wantkey, wantval := range tc.Want.ResultMap {
+				gotval, ok := secretmap[wantkey]
+				if !ok {
+					t.Errorf("%s: unexpected response: wanted key '%s' not found", tc.Case, wantkey)
+				} else if string(gotval) != wantval {
+					t.Errorf("%s: unexpected response: key '%s' = '%s' (expected '%s')", tc.Case, wantkey, wantval, gotval)
+				}
+			}
+		}
+	} else {
+		secret, err := client.GetSecret(context.Background(), testRef)
+		errStr := ""
+		if err != nil {
+			errStr = err.Error()
+		}
+		if !strings.Contains(errStr, tc.Want.Err) {
+			t.Errorf("%s: unexpected error: '%s' (expected '%s')", tc.Case, errStr, tc.Want.Err)
+		}
+		if err == nil && string(secret) != tc.Want.Result {
+			t.Errorf("%s: unexpected response: '%s' (expected '%s')", tc.Case, secret, tc.Want.Result)
+		}
+	}
+}
+
+func makeClusterSecretStore(url string, args args) *esv1alpha1.ClusterSecretStore {
+	store := &esv1alpha1.ClusterSecretStore{
+		TypeMeta: metav1.TypeMeta{
+			Kind: "ClusterSecretStore",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "wehbook-store",
+			Namespace: "default",
+		},
+		Spec: esv1alpha1.SecretStoreSpec{
+			Provider: &esv1alpha1.SecretStoreProvider{
+				Webhook: &esv1alpha1.WebhookProvider{
+					URL:  url + args.URL,
+					Body: args.Body,
+					Headers: map[string]string{
+						"Content-Type": "application.json",
+						"X-SecretKey":  "{{ .remoteRef.key }}",
+					},
+					Result: esv1alpha1.WebhookResult{
+						JSONPath: args.JSONPath,
+					},
+				},
+			},
+		},
+	}
+	return store
+}

+ 5 - 0
pkg/template/template.go

@@ -51,6 +51,11 @@ var tplFuncs = tpl.FuncMap{
 	"lower":    strings.ToLower,
 }
 
+// So other templating calls can use the same extra functions.
+func FuncMap() tpl.FuncMap {
+	return tplFuncs
+}
+
 const (
 	errParse                = "unable to parse template at key %s: %s"
 	errExecute              = "unable to execute template at key %s: %s"