From 1cf8f6827654471728b1f4dd91cf6f95a81539c6 Mon Sep 17 00:00:00 2001 From: Gustavo Fernandes de Carvalho Date: Sat, 17 Feb 2024 06:49:31 -0300 Subject: [PATCH] Implements Webhook Generator (#3121) * adding webhook generators Signed-off-by: Gustavo Carvalho * bumping bundle Signed-off-by: Gustavo Carvalho * linting Signed-off-by: Gustavo Carvalho * fixing copy-paste error Signed-off-by: Gustavo Carvalho * common webhook functions Signed-off-by: Gustavo Carvalho * removing duplicates. Adding tests for generator Signed-off-by: Gustavo Carvalho * docs Signed-off-by: Gustavo Carvalho --------- Signed-off-by: Gustavo Carvalho --- apis/generators/v1alpha1/generator_webhook.go | 130 +++++++ apis/generators/v1alpha1/register.go | 9 + .../v1alpha1/zz_generated.deepcopy.go | 168 +++++++++ ...nerators.external-secrets.io_webhooks.yaml | 141 ++++++++ deploy/crds/bundle.yaml | 146 ++++++++ docs/api/generator/webhook.md | 21 ++ docs/provider/webhook.md | 2 + docs/snippets/generator-webhook-example.yaml | 14 + docs/snippets/generator-webhook.yaml | 28 ++ hack/api-docs/mkdocs.yml | 1 + pkg/common/webhook/models.go | 106 ++++++ pkg/common/webhook/webhook.go | 318 ++++++++++++++++++ pkg/generator/register/register.go | 1 + pkg/generator/webhook/webhook.go | 72 ++++ pkg/generator/webhook/webhook_test.go | 266 +++++++++++++++ pkg/provider/webhook/webhook.go | 308 ++--------------- 16 files changed, 1445 insertions(+), 286 deletions(-) create mode 100644 apis/generators/v1alpha1/generator_webhook.go create mode 100644 config/crds/bases/generators.external-secrets.io_webhooks.yaml create mode 100644 docs/api/generator/webhook.md create mode 100644 docs/snippets/generator-webhook-example.yaml create mode 100644 docs/snippets/generator-webhook.yaml create mode 100644 pkg/common/webhook/models.go create mode 100644 pkg/common/webhook/webhook.go create mode 100644 pkg/generator/webhook/webhook.go create mode 100644 pkg/generator/webhook/webhook_test.go diff --git a/apis/generators/v1alpha1/generator_webhook.go b/apis/generators/v1alpha1/generator_webhook.go new file mode 100644 index 000000000..5185704b9 --- /dev/null +++ b/apis/generators/v1alpha1/generator_webhook.go @@ -0,0 +1,130 @@ +/* +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" +) + +// WebhookSpec controls the behavior of the external generator. Any body parameters should be passed to the server through the parameters field. +type WebhookSpec 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 SecretKeySelector `json:"secretRef"` +} + +type SecretKeySelector struct { + // The name of the Secret resource being referred to. + Name string `json:"name,omitempty"` + // The key where the token is found. + Key string `json:"key,omitempty"` +} + +// Webhook connects to a third party API server to handle the secrets generation +// configuration parameters in spec. +// You can specify the server, the token, and additional body parameters. +// See documentation for the full API specification for requests and responses. +// +kubebuilder:object:root=true +// +kubebuilder:storageversion +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced,categories={webhook},shortName=webhookl +type Webhook struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec WebhookSpec `json:"spec,omitempty"` +} + +// +kubebuilder:object:root=true + +// ExternalList contains a list of Webhook Generator resources. +type WebhookList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Webhook `json:"items"` +} diff --git a/apis/generators/v1alpha1/register.go b/apis/generators/v1alpha1/register.go index cef819f4c..875a3bb02 100644 --- a/apis/generators/v1alpha1/register.go +++ b/apis/generators/v1alpha1/register.go @@ -68,6 +68,14 @@ var ( PasswordGroupVersionKind = SchemeGroupVersion.WithKind(PasswordKind) ) +// Webhook type metadata. +var ( + WebhookKind = reflect.TypeOf(Webhook{}).Name() + WebhookGroupKind = schema.GroupKind{Group: Group, Kind: WebhookKind}.String() + WebhookKindAPIVersion = WebhookKind + "." + SchemeGroupVersion.String() + WebhookGroupVersionKind = SchemeGroupVersion.WithKind(WebhookKind) +) + // Fake type metadata. var ( FakeKind = reflect.TypeOf(Fake{}).Name() @@ -91,4 +99,5 @@ func init() { SchemeBuilder.Register(&Fake{}, &FakeList{}) SchemeBuilder.Register(&VaultDynamicSecret{}, &VaultDynamicSecretList{}) SchemeBuilder.Register(&Password{}, &PasswordList{}) + SchemeBuilder.Register(&Webhook{}, &WebhookList{}) } diff --git a/apis/generators/v1alpha1/zz_generated.deepcopy.go b/apis/generators/v1alpha1/zz_generated.deepcopy.go index 3fb35bd0b..fccf79720 100644 --- a/apis/generators/v1alpha1/zz_generated.deepcopy.go +++ b/apis/generators/v1alpha1/zz_generated.deepcopy.go @@ -22,6 +22,7 @@ import ( "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1" "github.com/external-secrets/external-secrets/apis/meta/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) @@ -653,6 +654,21 @@ func (in *PasswordSpec) DeepCopy() *PasswordSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecretKeySelector) DeepCopyInto(out *SecretKeySelector) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretKeySelector. +func (in *SecretKeySelector) DeepCopy() *SecretKeySelector { + if in == nil { + return nil + } + out := new(SecretKeySelector) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *VaultDynamicSecret) DeepCopyInto(out *VaultDynamicSecret) { *out = *in @@ -735,3 +751,155 @@ func (in *VaultDynamicSecretSpec) DeepCopy() *VaultDynamicSecretSpec { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Webhook) DeepCopyInto(out *Webhook) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Webhook. +func (in *Webhook) DeepCopy() *Webhook { + if in == nil { + return nil + } + out := new(Webhook) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Webhook) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// 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 *WebhookList) DeepCopyInto(out *WebhookList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Webhook, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebhookList. +func (in *WebhookList) DeepCopy() *WebhookList { + if in == nil { + return nil + } + out := new(WebhookList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *WebhookList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// 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 + out.SecretRef = in.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 *WebhookSpec) DeepCopyInto(out *WebhookSpec) { + *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(metav1.Duration) + **out = **in + } + out.Result = in.Result + if in.Secrets != nil { + in, out := &in.Secrets, &out.Secrets + *out = make([]WebhookSecret, len(*in)) + copy(*out, *in) + } + 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 WebhookSpec. +func (in *WebhookSpec) DeepCopy() *WebhookSpec { + if in == nil { + return nil + } + out := new(WebhookSpec) + in.DeepCopyInto(out) + return out +} diff --git a/config/crds/bases/generators.external-secrets.io_webhooks.yaml b/config/crds/bases/generators.external-secrets.io_webhooks.yaml new file mode 100644 index 000000000..9f3f53213 --- /dev/null +++ b/config/crds/bases/generators.external-secrets.io_webhooks.yaml @@ -0,0 +1,141 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.14.0 + name: webhooks.generators.external-secrets.io +spec: + group: generators.external-secrets.io + names: + categories: + - webhook + kind: Webhook + listKind: WebhookList + plural: webhooks + shortNames: + - webhookl + singular: webhook + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + Webhook connects to a third party API server to handle the secrets generation + configuration parameters in spec. + You can specify the server, the token, and additional body parameters. + See documentation for the full API specification for requests and responses. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: WebhookSpec controls the behavior of the external generator. + Any body parameters should be passed to the server through the parameters + field. + 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 where the token is found. + type: string + name: + description: The name of the Secret resource being referred + to. + 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 + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/crds/bundle.yaml b/deploy/crds/bundle.yaml index 94e26dead..5f8baf885 100644 --- a/deploy/crds/bundle.yaml +++ b/deploy/crds/bundle.yaml @@ -10910,3 +10910,149 @@ spec: name: kubernetes namespace: default path: /convert +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.14.0 + name: webhooks.generators.external-secrets.io +spec: + group: generators.external-secrets.io + names: + categories: + - webhook + kind: Webhook + listKind: WebhookList + plural: webhooks + shortNames: + - webhookl + singular: webhook + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + Webhook connects to a third party API server to handle the secrets generation + configuration parameters in spec. + You can specify the server, the token, and additional body parameters. + See documentation for the full API specification for requests and responses. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: WebhookSpec controls the behavior of the external generator. Any body parameters should be passed to the server through the parameters field. + 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 where the token is found. + type: string + name: + description: The name of the Secret resource being referred to. + 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 + type: object + served: true + storage: true + subresources: + status: {} + conversion: + strategy: Webhook + webhook: + conversionReviewVersions: + - v1 + clientConfig: + service: + name: kubernetes + namespace: default + path: /convert diff --git a/docs/api/generator/webhook.md b/docs/api/generator/webhook.md new file mode 100644 index 000000000..e6a22d1f8 --- /dev/null +++ b/docs/api/generator/webhook.md @@ -0,0 +1,21 @@ +The Webhook generator is very similar to SecretStore generator, and provides a way to use external systems to generate sensitive information. + +## Output Keys and Values + +Webhook calls are expected to produce valid JSON objects. All keys within that JSON object will be exported as keys to the kubernetes Secret. + +## Example Manifest + +```yaml +{% include 'generator-webhook.yaml' %} +``` + +Example `ExternalSecret` that references the Webhook generator using an internal `Secret`: +```yaml +{% include 'generator-webhook-example.yaml' %} +``` + +This will generate a kubernetes secret with the following values: +```yaml +parameter: test +``` diff --git a/docs/provider/webhook.md b/docs/provider/webhook.md index 04caeabf1..7683fed9d 100644 --- a/docs/provider/webhook.md +++ b/docs/provider/webhook.md @@ -123,3 +123,5 @@ spec: key: ``` +### Webhook as generators +You can also leverage webhooks as generators, following the same syntax. The only difference is that the webhook generator needs its source secrets to be labeled, as opposed to webhook secretstores. Please see the [generator-webhook](../api/generator/webhook.md) documentation for more information. \ No newline at end of file diff --git a/docs/snippets/generator-webhook-example.yaml b/docs/snippets/generator-webhook-example.yaml new file mode 100644 index 000000000..1fc8b4644 --- /dev/null +++ b/docs/snippets/generator-webhook-example.yaml @@ -0,0 +1,14 @@ +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: "webhook" +spec: + refreshInterval: "30m" + target: + name: webhook-secret + dataFrom: + - sourceRef: + generatorRef: + apiVersion: generators.external-secrets.io/v1alpha1 + kind: Webhook + name: "webhook" \ No newline at end of file diff --git a/docs/snippets/generator-webhook.yaml b/docs/snippets/generator-webhook.yaml new file mode 100644 index 000000000..eb8352de7 --- /dev/null +++ b/docs/snippets/generator-webhook.yaml @@ -0,0 +1,28 @@ +{% raw %} +apiVersion: generators.external-secrets.io/v1alpha1 +kind: Webhook +metadata: + name: webhook +spec: + url: "http://httpbin.org/get?parameter={{ .auth.param }}" + result: + jsonPath: "$.args" + headers: + Content-Type: application/json + Authorization: Basic {{ print .auth.username ":" .auth.password | b64enc }} + secrets: + - name: auth + secretRef: + name: webhook-credentials +--- +apiVersion: v1 +kind: Secret +metadata: + name: webhook-credentials + labels: + generators.external-secrets.io/type: webhook #Needed to allow webhook to use this secret +data: + username: dGVzdA== # "test" + password: dGVzdA== # "test" + param: dGVzdA== # "test" +{% endraw %} \ No newline at end of file diff --git a/hack/api-docs/mkdocs.yml b/hack/api-docs/mkdocs.yml index 5d44bb1c9..a4e18f3f7 100644 --- a/hack/api-docs/mkdocs.yml +++ b/hack/api-docs/mkdocs.yml @@ -59,6 +59,7 @@ nav: - Vault Dynamic Secret: api/generator/vault.md - Password: api/generator/password.md - Fake: api/generator/fake.md + - Webhook: api/generator/webhook.md - Reference Docs: - API specification: api/spec.md - Controller Options: api/controller-options.md diff --git a/pkg/common/webhook/models.go b/pkg/common/webhook/models.go new file mode 100644 index 000000000..1101f8aa1 --- /dev/null +++ b/pkg/common/webhook/models.go @@ -0,0 +1,106 @@ +/* +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 ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type Spec 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 Result `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 []Secret `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 *CAProvider `json:"caProvider,omitempty"` +} +type CAProviderType string + +const ( + CAProviderTypeSecret CAProviderType = "Secret" + CAProviderTypeConfigMap CAProviderType = "ConfigMap" +) + +// Defines a location to fetch the cert for the webhook provider from. +type CAProvider struct { + // The type of provider to use such as "Secret", or "ConfigMap". + // +kubebuilder:validation:Enum="Secret";"ConfigMap" + Type CAProviderType `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 Result struct { + // Json path of return value + // +optional + JSONPath string `json:"jsonPath,omitempty"` +} + +type Secret struct { + // Name of this secret in templates + Name string `json:"name"` + + // Secret ref to fill in credentials + SecretRef SecretKeySelector `json:"secretRef"` +} + +type SecretKeySelector struct { + // The name of the Secret resource being referred to. + Name string `json:"name,omitempty"` + // The key where the token is found. + Key string `json:"key,omitempty"` + + Namespace *string `json:"namespace,omitempty"` +} diff --git a/pkg/common/webhook/webhook.go b/pkg/common/webhook/webhook.go new file mode 100644 index 000000000..7a32cc3eb --- /dev/null +++ b/pkg/common/webhook/webhook.go @@ -0,0 +1,318 @@ +/* +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" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + tpl "text/template" + + "github.com/PaesslerAG/jsonpath" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1" + esmeta "github.com/external-secrets/external-secrets/apis/meta/v1" + "github.com/external-secrets/external-secrets/pkg/constants" + "github.com/external-secrets/external-secrets/pkg/metrics" + "github.com/external-secrets/external-secrets/pkg/template/v2" + "github.com/external-secrets/external-secrets/pkg/utils/resolvers" +) + +type Webhook struct { + Kube client.Client + Namespace string + StoreKind string + HTTP *http.Client + EnforceLabels bool + ClusterScoped bool +} + +func (w *Webhook) getStoreSecret(ctx context.Context, ref SecretKeySelector) (*corev1.Secret, error) { + ke := client.ObjectKey{ + Name: ref.Name, + Namespace: w.Namespace, + } + if w.ClusterScoped { + if ref.Namespace == nil { + return nil, fmt.Errorf("no namespace on ClusterScoped 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) + } + if w.EnforceLabels { + expected, ok := secret.Labels["generators.external-secrets.io/type"] + if !ok { + return nil, fmt.Errorf("secret does not contain needed label to be used on webhook generator") + } + if expected != "webhook" { + return nil, fmt.Errorf("secret type is not 'webhook'") + } + } + return secret, nil +} +func (w *Webhook) GetSecretMap(ctx context.Context, provider *Spec, ref *esv1beta1.ExternalSecretDataRemoteRef) (map[string][]byte, error) { + result, err := w.GetWebhookData(ctx, provider, ref) + if err != nil { + return nil, err + } + // We always want json here, so just parse it out + jsondata := interface{}(nil) + if err := json.Unmarshal(result, &jsondata); err != nil { + return nil, fmt.Errorf("failed to parse response json: %w", err) + } + // Get subdata via jsonpath, if given + if provider.Result.JSONPath != "" { + 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) + } + } + // If the value is a string, try to parse it as json + jsonstring, ok := jsondata.(string) + if ok { + // This could also happen if the response was a single json-encoded string + // but that is an extremely unlikely scenario + if err := json.Unmarshal([]byte(jsonstring), &jsondata); err != nil { + return nil, fmt.Errorf("failed to parse response json from jsonpath: %w", err) + } + } + // Use the data as a key-value map + jsonvalue, ok := jsondata.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("failed to get response (wrong type: %T)", jsondata) + } + // Change the map of generic objects to a map of byte arrays + 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 in key '%s': %T)", rKey, rValue) + } + values[rKey] = []byte(jVal) + } + return values, nil +} + +func (w *Webhook) GetTemplateData(ctx context.Context, ref *esv1beta1.ExternalSecretDataRemoteRef, secrets []Secret) (map[string]map[string]string, error) { + data := map[string]map[string]string{} + if ref != nil { + data["remoteRef"] = map[string]string{ + "key": url.QueryEscape(ref.Key), + "version": url.QueryEscape(ref.Version), + "property": url.QueryEscape(ref.Property), + } + } + for _, secref := range 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) + } + } + return data, nil +} + +func (w *Webhook) GetWebhookData(ctx context.Context, provider *Spec, ref *esv1beta1.ExternalSecretDataRemoteRef) ([]byte, error) { + if w.HTTP == nil { + return nil, fmt.Errorf("http client not initialized") + } + data, err := w.GetTemplateData(ctx, ref, provider.Secrets) + if err != nil { + return nil, err + } + method := provider.Method + if method == "" { + method = http.MethodGet + } + 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) + } + 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) + } + + resp, err := w.HTTP.Do(req) + metrics.ObserveAPICall(constants.ProviderWebhook, constants.CallWebhookHTTPReq, err) + if err != nil { + return nil, fmt.Errorf("failed to call endpoint: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode == 404 { + return nil, esv1beta1.NoSecretError{} + } + 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(provider *Spec) (*http.Client, error) { + client := &http.Client{} + if provider.Timeout != nil { + client.Timeout = provider.Timeout.Duration + } + if len(provider.CABundle) == 0 && provider.CAProvider == nil { + // No need to process ca stuff if it is not there + return client, nil + } + caCertPool, err := w.GetCACertPool(provider) + if err != nil { + return nil, err + } + + tlsConf := &tls.Config{ + RootCAs: caCertPool, + MinVersion: tls.VersionTLS12, + } + client.Transport = &http.Transport{TLSClientConfig: tlsConf} + return client, nil +} + +func (w *Webhook) GetCACertPool(provider *Spec) (*x509.CertPool, error) { + 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 { + var cert []byte + var err error + + switch provider.CAProvider.Type { + case CAProviderTypeSecret: + cert, err = w.GetCertFromSecret(provider) + case CAProviderTypeConfigMap: + cert, err = w.GetCertFromConfigMap(provider) + default: + err = 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") + } + } + return caCertPool, nil +} + +func (w *Webhook) GetCertFromSecret(provider *Spec) ([]byte, error) { + secretRef := esmeta.SecretKeySelector{ + Name: provider.CAProvider.Name, + Namespace: &w.Namespace, + Key: provider.CAProvider.Key, + } + + if provider.CAProvider.Namespace != nil { + secretRef.Namespace = provider.CAProvider.Namespace + } + + ctx := context.Background() + cert, err := resolvers.SecretKeyRef( + ctx, + w.Kube, + w.StoreKind, + w.Namespace, + &secretRef, + ) + if err != nil { + return nil, err + } + + return []byte(cert), nil +} + +func (w *Webhook) GetCertFromConfigMap(provider *Spec) ([]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 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(template.FuncMap()).Parse(tmpl) + if err != nil { + return result, err + } + if err := urlt.Execute(&result, data); err != nil { + return result, err + } + return result, nil +} diff --git a/pkg/generator/register/register.go b/pkg/generator/register/register.go index 8d815a8af..5d06bc00a 100644 --- a/pkg/generator/register/register.go +++ b/pkg/generator/register/register.go @@ -24,4 +24,5 @@ import ( _ "github.com/external-secrets/external-secrets/pkg/generator/gcr" _ "github.com/external-secrets/external-secrets/pkg/generator/password" _ "github.com/external-secrets/external-secrets/pkg/generator/vault" + _ "github.com/external-secrets/external-secrets/pkg/generator/webhook" ) diff --git a/pkg/generator/webhook/webhook.go b/pkg/generator/webhook/webhook.go new file mode 100644 index 000000000..89b63063d --- /dev/null +++ b/pkg/generator/webhook/webhook.go @@ -0,0 +1,72 @@ +/* +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 ( + "context" + "encoding/json" + "fmt" + + apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1" + "github.com/external-secrets/external-secrets/pkg/common/webhook" +) + +type Webhook struct { + wh webhook.Webhook + url string +} + +func (w *Webhook) Generate(ctx context.Context, jsonSpec *apiextensions.JSON, kclient client.Client, ns string) (map[string][]byte, error) { + w.wh.EnforceLabels = true + w.wh.ClusterScoped = false + provider, err := parseSpec(jsonSpec.Raw) + w.wh = webhook.Webhook{} + if err != nil { + return nil, err + } + w.wh.Namespace = ns + w.url = provider.URL + w.wh.Kube = kclient + w.wh.HTTP, err = w.wh.GetHTTPClient(provider) + if err != nil { + return nil, err + } + if err != nil { + return nil, fmt.Errorf("failed to get store: %w", err) + } + return w.wh.GetSecretMap(ctx, provider, nil) +} + +func parseSpec(data []byte) (*webhook.Spec, error) { + var spec genv1alpha1.Webhook + err := json.Unmarshal(data, &spec) + if err != nil { + return nil, err + } + out := webhook.Spec{} + d, err := json.Marshal(spec.Spec) + if err != nil { + return nil, err + } + err = json.Unmarshal(d, &out) + return &out, err +} + +func init() { + genv1alpha1.Register(genv1alpha1.WebhookKind, &Webhook{}) +} diff --git a/pkg/generator/webhook/webhook_test.go b/pkg/generator/webhook/webhook_test.go new file mode 100644 index 000000000..cc9f8a4ec --- /dev/null +++ b/pkg/generator/webhook/webhook_test.go @@ -0,0 +1,266 @@ +/* +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" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "gopkg.in/yaml.v3" + apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1" + genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/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"` + Property string `json:"property,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 no secret err +args: + url: /api/getsecret?id=testkey&version=1 + key: testkey + version: 1 + statuscode: 404 + response: not found +want: + path: /api/getsecret?id=testkey&version=1 + err: ` + esv1beta1.NoSecretErr.Error() + ` +--- +case: error server error +args: + url: /api/getsecret?id=testkey&version=1 + key: testkey + version: 1 + statuscode: 500 + response: server error +want: + path: /api/getsecret?id=testkey&version=1 + err: endpoint gave error 500 +--- +case: error bad json +args: + url: /api/getsecret?id=testkey&version=1 + 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=testkey&version=1 + 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: pull data out of map +args: + url: /api/getsecret?id=testkey&version=1 + key: testkey + version: 1 + jsonpath: $.result.thesecret + response: '{"result":{"thesecret":{"one":"secret-value"}}}' +want: + path: /api/getsecret?id=testkey&version=1 + err: '' + result: '{"one":"secret-value"}' +--- +case: not valid response path +args: + url: /api/getsecret?id=testkey&version=1 + key: testkey + version: 1 + jsonpath: $.result.unexisting + response: '{"result":{"thesecret":{"one":"secret-value"}}}' +want: + path: /api/getsecret?id=testkey&version=1 + err: 'failed to get response path' + result: '' +--- +case: response path not json +args: + url: /api/getsecret?id=testkey&version=1 + 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:' + result: '' +` + +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 %v", err) + } + break + } + runTestCase(tc, t) + } +} + +func testCaseServer(tc testCase, t *testing.T) *httptest.Server { + // Start a new server for every test case because the server wants to check the expected api path + return 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)) + })) +} + +func parseTimeout(timeout string) (*metav1.Duration, error) { + if timeout == "" { + return nil, nil + } + dur, err := time.ParseDuration(timeout) + if err != nil { + return nil, err + } + return &metav1.Duration{Duration: dur}, nil +} + +func runTestCase(tc testCase, t *testing.T) { + ts := testCaseServer(tc, t) + defer ts.Close() + + testStore := makeGenerator(ts.URL, tc.Args) + jsonRes, err := json.Marshal(testStore) + if err != nil { + t.Errorf("%s: error parsing timeout '%s': %s", tc.Case, tc.Args.Timeout, err.Error()) + return + } + + genSpec := &apiextensions.JSON{Raw: jsonRes} + timeout, err := parseTimeout(tc.Args.Timeout) + + if err != nil { + t.Errorf("%s: error parsing timeout '%s': %s", tc.Case, tc.Args.Timeout, err.Error()) + return + } + testStore.Spec.Timeout = timeout + testProv := &Webhook{} + testGenerate(tc, t, testProv, genSpec) +} + +func testGenerate(tc testCase, t *testing.T, client genv1alpha1.Generator, testStore *apiextensions.JSON) { + secretmap, err := client.Generate(context.Background(), testStore, nil, "testnamespace") + 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) + } + } + } +} + +func makeGenerator(url string, args args) *genv1alpha1.Webhook { + store := &genv1alpha1.Webhook{ + TypeMeta: metav1.TypeMeta{ + Kind: "Webhook", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "wehbook-store", + Namespace: "default", + }, + Spec: genv1alpha1.WebhookSpec{ + URL: url + args.URL, + Body: args.Body, + Headers: map[string]string{ + "Content-Type": "application.json", + "X-SecretKey": "{{ .remoteRef.key }}", + }, + Result: genv1alpha1.WebhookResult{ + JSONPath: args.JSONPath, + }, + }, + } + return store +} diff --git a/pkg/provider/webhook/webhook.go b/pkg/provider/webhook/webhook.go index 40ae12540..2846d623b 100644 --- a/pkg/provider/webhook/webhook.go +++ b/pkg/provider/webhook/webhook.go @@ -15,17 +15,10 @@ limitations under the License. package webhook import ( - "bytes" "context" - "crypto/tls" - "crypto/x509" "encoding/json" "fmt" - "io" - "net/http" - "net/url" "strconv" - tpl "text/template" "time" "github.com/PaesslerAG/jsonpath" @@ -34,12 +27,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook/admission" esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1" - esmeta "github.com/external-secrets/external-secrets/apis/meta/v1" - "github.com/external-secrets/external-secrets/pkg/constants" - "github.com/external-secrets/external-secrets/pkg/metrics" - "github.com/external-secrets/external-secrets/pkg/template/v2" + "github.com/external-secrets/external-secrets/pkg/common/webhook" "github.com/external-secrets/external-secrets/pkg/utils" - "github.com/external-secrets/external-secrets/pkg/utils/resolvers" ) // https://github.com/external-secrets/external-secrets/issues/644 @@ -50,11 +39,9 @@ var _ esv1beta1.Provider = &Provider{} type Provider struct{} type WebHook struct { - kube client.Client + wh webhook.Webhook store esv1beta1.GenericStore - namespace string storeKind string - http *http.Client url string } @@ -70,19 +57,25 @@ func (p *Provider) Capabilities() esv1beta1.SecretStoreCapabilities { } func (p *Provider) NewClient(_ context.Context, store esv1beta1.GenericStore, kube client.Client, namespace string) (esv1beta1.SecretsClient, error) { + wh := webhook.Webhook{ + Kube: kube, + Namespace: namespace, + } whClient := &WebHook{ - kube: kube, store: store, - namespace: namespace, + wh: wh, storeKind: store.GetObjectKind().GroupVersionKind().Kind, } + if whClient.storeKind == esv1beta1.ClusterSecretStoreKind { + whClient.wh.ClusterScoped = true + } provider, err := getProvider(store) if err != nil { return nil, err } whClient.url = provider.URL - whClient.http, err = whClient.getHTTPClient(provider) + whClient.wh.HTTP, err = whClient.wh.GetHTTPClient(provider) if err != nil { return nil, err } @@ -93,30 +86,18 @@ func (p *Provider) ValidateStore(_ esv1beta1.GenericStore) (admission.Warnings, return nil, nil } -func getProvider(store esv1beta1.GenericStore) (*esv1beta1.WebhookProvider, error) { +func getProvider(store esv1beta1.GenericStore) (*webhook.Spec, 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, + out := webhook.Spec{} + d, err := json.Marshal(spc.Provider.Webhook) + if err != nil { + return nil, err } - if w.storeKind == esv1beta1.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 + err = json.Unmarshal(d, &out) + return &out, err } func (w *WebHook) DeleteSecret(_ context.Context, _ esv1beta1.PushSecretRemoteRef) error { @@ -139,16 +120,16 @@ func (w *WebHook) GetSecret(ctx context.Context, ref esv1beta1.ExternalSecretDat if err != nil { return nil, fmt.Errorf("failed to get store: %w", err) } - result, err := w.getWebhookData(ctx, provider, ref) + result, err := w.wh.GetWebhookData(ctx, provider, &ref) if err != nil { return nil, err } // Only parse as json if we have a jsonpath set - data, err := w.getTemplateData(ctx, ref, provider.Secrets) + data, err := w.wh.GetTemplateData(ctx, &ref, provider.Secrets) if err != nil { return nil, err } - resultJSONPath, err := executeTemplateString(provider.Result.JSONPath, data) + resultJSONPath, err := webhook.ExecuteTemplateString(provider.Result.JSONPath, data) if err != nil { return nil, err } @@ -207,229 +188,7 @@ func (w *WebHook) GetSecretMap(ctx context.Context, ref esv1beta1.ExternalSecret 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 - } - - // We always want json here, so just parse it out - jsondata := interface{}(nil) - if err := json.Unmarshal(result, &jsondata); err != nil { - return nil, fmt.Errorf("failed to parse response json: %w", err) - } - // Get subdata via jsonpath, if given - if provider.Result.JSONPath != "" { - 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) - } - } - // If the value is a string, try to parse it as json - jsonstring, ok := jsondata.(string) - if ok { - // This could also happen if the response was a single json-encoded string - // but that is an extremely unlikely scenario - if err := json.Unmarshal([]byte(jsonstring), &jsondata); err != nil { - return nil, fmt.Errorf("failed to parse response json from jsonpath: %w", err) - } - } - // Use the data as a key-value map - jsonvalue, ok := jsondata.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("failed to get response (wrong type: %T)", jsondata) - } - - // Change the map of generic objects to a map of byte arrays - 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 in key '%s': %T)", rKey, rValue) - } - values[rKey] = []byte(jVal) - } - return values, nil -} - -func (w *WebHook) getTemplateData(ctx context.Context, ref esv1beta1.ExternalSecretDataRemoteRef, secrets []esv1beta1.WebhookSecret) (map[string]map[string]string, error) { - data := map[string]map[string]string{ - "remoteRef": { - "key": url.QueryEscape(ref.Key), - "version": url.QueryEscape(ref.Version), - "property": url.QueryEscape(ref.Property), - }, - } - for _, secref := range 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) - } - } - return data, nil -} - -func (w *WebHook) getWebhookData(ctx context.Context, provider *esv1beta1.WebhookProvider, ref esv1beta1.ExternalSecretDataRemoteRef) ([]byte, error) { - if w.http == nil { - return nil, fmt.Errorf("http client not initialized") - } - data, err := w.getTemplateData(ctx, ref, provider.Secrets) - if err != nil { - return nil, err - } - method := provider.Method - if method == "" { - method = http.MethodGet - } - 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) - } - 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) - } - - resp, err := w.http.Do(req) - metrics.ObserveAPICall(constants.ProviderWebhook, constants.CallWebhookHTTPReq, err) - if err != nil { - return nil, fmt.Errorf("failed to call endpoint: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode == 404 { - return nil, esv1beta1.NoSecretError{} - } - 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(provider *esv1beta1.WebhookProvider) (*http.Client, error) { - client := &http.Client{} - if provider.Timeout != nil { - client.Timeout = provider.Timeout.Duration - } - if len(provider.CABundle) == 0 && provider.CAProvider == nil { - // No need to process ca stuff if it is not there - return client, nil - } - caCertPool, err := w.getCACertPool(provider) - if err != nil { - return nil, err - } - - tlsConf := &tls.Config{ - RootCAs: caCertPool, - MinVersion: tls.VersionTLS12, - } - client.Transport = &http.Transport{TLSClientConfig: tlsConf} - return client, nil -} - -func (w *WebHook) getCACertPool(provider *esv1beta1.WebhookProvider) (*x509.CertPool, error) { - 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 == esv1beta1.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 esv1beta1.WebhookCAProviderTypeSecret: - cert, err = w.getCertFromSecret(provider) - case esv1beta1.WebhookCAProviderTypeConfigMap: - cert, err = w.getCertFromConfigMap(provider) - default: - err = 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") - } - } - return caCertPool, nil -} - -func (w *WebHook) getCertFromSecret(provider *esv1beta1.WebhookProvider) ([]byte, error) { - secretRef := esmeta.SecretKeySelector{ - Name: provider.CAProvider.Name, - Namespace: &w.namespace, - Key: provider.CAProvider.Key, - } - - if provider.CAProvider.Namespace != nil { - secretRef.Namespace = provider.CAProvider.Namespace - } - - ctx := context.Background() - cert, err := resolvers.SecretKeyRef( - ctx, - w.kube, - w.storeKind, - w.namespace, - &secretRef, - ) - if err != nil { - return nil, err - } - - return []byte(cert), nil -} - -func (w *WebHook) getCertFromConfigMap(provider *esv1beta1.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 + return w.wh.GetSecretMap(ctx, provider, &ref) } func (w *WebHook) Close(_ context.Context) error { @@ -445,26 +204,3 @@ func (w *WebHook) Validate() (esv1beta1.ValidationResult, error) { } return esv1beta1.ValidationResultReady, 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(template.FuncMap()).Parse(tmpl) - if err != nil { - return result, err - } - if err := urlt.Execute(&result, data); err != nil { - return result, err - } - return result, nil -}