Browse Source

feat: add PushSecret ability to the webhook provider (#4360)

Gergely Brautigam 1 year ago
parent
commit
c004dba316

+ 47 - 0
docs/provider/webhook.md

@@ -67,6 +67,53 @@ data:
   foobar: c2VjcmV0
   foobar: c2VjcmV0
 ```
 ```
 
 
+To push a secret, create the following store:
+
+```yaml
+{% raw %}
+apiVersion: external-secrets.io/v1beta1
+kind: SecretStore
+metadata:
+  name: webhook-backend
+spec:
+  provider:
+    webhook:
+      url: "http://httpbin.org/push?id={{ .remoteRef.remoteKey }}&secret={{ .remoteRef.secretKey }}"
+      headers:
+        Content-Type: application/json
+        Authorization: Basic {{ print .auth.username ":" .auth.password | b64enc }}
+      secrets:
+      - name: auth
+        secretRef:
+          name: webhook-credentials
+{%- endraw %}
+```
+
+Then create a push secret:
+
+```yaml
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: pushsecret-example # Customisable
+spec:
+  refreshInterval: 1h # Refresh interval for which push secret will reconcile
+  secretStoreRefs: # A list of secret stores to push secrets to
+    - name: webhook-backend
+      kind: SecretStore
+  selector:
+    secret:
+      name: test-secret
+  data:
+    - conversionStrategy: 
+      match:
+        secretKey: testsecret
+        remoteRef:
+          remoteKey: remotekey
+```
+
+If `secretKey` is not provided, the whole secret is pushed JSON encoded.
+
 #### Limitations
 #### Limitations
 
 
 Webhook does not support authorization, other than what can be sent by generating http headers
 Webhook does not support authorization, other than what can be sent by generating http headers

+ 55 - 55
pkg/common/webhook/webhook.go

@@ -32,12 +32,10 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
 
 	esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
 	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/constants"
 	"github.com/external-secrets/external-secrets/pkg/metrics"
 	"github.com/external-secrets/external-secrets/pkg/metrics"
 	"github.com/external-secrets/external-secrets/pkg/template/v2"
 	"github.com/external-secrets/external-secrets/pkg/template/v2"
 	"github.com/external-secrets/external-secrets/pkg/utils"
 	"github.com/external-secrets/external-secrets/pkg/utils"
-	"github.com/external-secrets/external-secrets/pkg/utils/resolvers"
 )
 )
 
 
 type Webhook struct {
 type Webhook struct {
@@ -134,19 +132,29 @@ func (w *Webhook) GetTemplateData(ctx context.Context, ref *esv1beta1.ExternalSe
 			}
 			}
 		}
 		}
 	}
 	}
+
+	if err := w.getTemplatedSecrets(ctx, secrets, data); err != nil {
+		return nil, err
+	}
+
+	return data, nil
+}
+
+func (w *Webhook) getTemplatedSecrets(ctx context.Context, secrets []Secret, data map[string]map[string]string) error {
 	for _, secref := range secrets {
 	for _, secref := range secrets {
 		if _, ok := data[secref.Name]; !ok {
 		if _, ok := data[secref.Name]; !ok {
 			data[secref.Name] = make(map[string]string)
 			data[secref.Name] = make(map[string]string)
 		}
 		}
 		secret, err := w.getStoreSecret(ctx, secref.SecretRef)
 		secret, err := w.getStoreSecret(ctx, secref.SecretRef)
 		if err != nil {
 		if err != nil {
-			return nil, err
+			return err
 		}
 		}
 		for sKey, sVal := range secret.Data {
 		for sKey, sVal := range secret.Data {
 			data[secref.Name][sKey] = string(sVal)
 			data[secref.Name][sKey] = string(sVal)
 		}
 		}
 	}
 	}
-	return data, nil
+
+	return nil
 }
 }
 
 
 func (w *Webhook) GetWebhookData(ctx context.Context, provider *Spec, ref *esv1beta1.ExternalSecretDataRemoteRef) ([]byte, error) {
 func (w *Webhook) GetWebhookData(ctx context.Context, provider *Spec, ref *esv1beta1.ExternalSecretDataRemoteRef) ([]byte, error) {
@@ -176,10 +184,52 @@ func (w *Webhook) GetWebhookData(ctx context.Context, provider *Spec, ref *esv1b
 		return nil, fmt.Errorf("failed to parse body: %w", err)
 		return nil, fmt.Errorf("failed to parse body: %w", err)
 	}
 	}
 
 
-	req, err := http.NewRequestWithContext(ctx, method, url, &body)
+	return w.executeRequest(ctx, provider, body.Bytes(), url, method, rawData)
+}
+
+func (w *Webhook) PushWebhookData(ctx context.Context, provider *Spec, data []byte, remoteKey esv1beta1.PushSecretData) error {
+	if w.HTTP == nil {
+		return errors.New("http client not initialized")
+	}
+
+	method := provider.Method
+	if method == "" {
+		method = http.MethodPost
+	}
+
+	rawData := map[string]map[string]string{
+		"remoteRef": {
+			"remoteKey": url.QueryEscape(remoteKey.GetRemoteKey()),
+		},
+	}
+	if remoteKey.GetSecretKey() != "" {
+		rawData["remoteRef"]["secretKey"] = url.QueryEscape(remoteKey.GetSecretKey())
+	}
+
+	turl, err := ExecuteTemplateString(provider.URL, rawData)
+	if err != nil {
+		return fmt.Errorf("failed to parse url: %w", err)
+	}
+
+	dataMap := make(map[string]map[string]string)
+	if err := w.getTemplatedSecrets(ctx, provider.Secrets, dataMap); err != nil {
+		return err
+	}
+
+	// read the body into the void to prevent remaining garbage to be present
+	if _, err := w.executeRequest(ctx, provider, data, turl, method, dataMap); err != nil {
+		return fmt.Errorf("failed to push webhook data: %w", err)
+	}
+
+	return nil
+}
+
+func (w *Webhook) executeRequest(ctx context.Context, provider *Spec, data []byte, url, method string, rawData map[string]map[string]string) ([]byte, error) {
+	req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(data))
 	if err != nil {
 	if err != nil {
 		return nil, fmt.Errorf("failed to create request: %w", err)
 		return nil, fmt.Errorf("failed to create request: %w", err)
 	}
 	}
+
 	for hKey, hValueTpl := range provider.Headers {
 	for hKey, hValueTpl := range provider.Headers {
 		hValue, err := ExecuteTemplateString(hValueTpl, rawData)
 		hValue, err := ExecuteTemplateString(hValueTpl, rawData)
 		if err != nil {
 		if err != nil {
@@ -251,56 +301,6 @@ func (w *Webhook) GetCACertPool(ctx context.Context, provider *Spec) (*x509.Cert
 	return caCertPool, nil
 	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) {
 func ExecuteTemplateString(tmpl string, data map[string]map[string]string) (string, error) {
 	result, err := ExecuteTemplate(tmpl, data)
 	result, err := ExecuteTemplate(tmpl, data)
 	if err != nil {
 	if err != nil {

+ 3 - 21
pkg/provider/bitwarden/client.go

@@ -61,27 +61,9 @@ func (p *Provider) PushSecret(ctx context.Context, secret *corev1.Secret, data e
 		return errors.New("remote key must be defined")
 		return errors.New("remote key must be defined")
 	}
 	}
 
 
-	var (
-		value []byte
-		err   error
-		ok    bool
-	)
-	if data.GetSecretKey() == "" {
-		decodedMap := make(map[string]string)
-		for k, v := range secret.Data {
-			decodedMap[k] = string(v)
-		}
-		value, err = utils.JSONMarshal(decodedMap)
-
-		if err != nil {
-			return fmt.Errorf("failed to marshal secret data: %w", err)
-		}
-	} else {
-		value, ok = secret.Data[data.GetSecretKey()]
-
-		if !ok {
-			return fmt.Errorf("failed to find secret key in secret with key: %s", data.GetSecretKey())
-		}
+	value, err := utils.ExtractSecretData(data, secret)
+	if err != nil {
+		return fmt.Errorf("failed to extract secret data: %w", err)
 	}
 	}
 
 
 	note, err := utils.FetchValueFromMetadata(NoteMetadataKey, data.GetMetadata(), "")
 	note, err := utils.FetchValueFromMetadata(NoteMetadataKey, data.GetMetadata(), "")

+ 24 - 6
pkg/provider/webhook/webhook.go

@@ -33,7 +33,8 @@ import (
 )
 )
 
 
 const (
 const (
-	errNotImplemented = "not implemented"
+	errNotImplemented   = "not implemented"
+	errFailedToGetStore = "failed to get store: %w"
 )
 )
 
 
 // https://github.com/external-secrets/external-secrets/issues/644
 // https://github.com/external-secrets/external-secrets/issues/644
@@ -115,9 +116,26 @@ func (w *WebHook) SecretExists(_ context.Context, _ esv1beta1.PushSecretRemoteRe
 	return false, errors.New(errNotImplemented)
 	return false, errors.New(errNotImplemented)
 }
 }
 
 
-// PushSecret not implement.
-func (w *WebHook) PushSecret(_ context.Context, _ *corev1.Secret, _ esv1beta1.PushSecretData) error {
-	return errors.New(errNotImplemented)
+func (w *WebHook) PushSecret(ctx context.Context, secret *corev1.Secret, data esv1beta1.PushSecretData) error {
+	if data.GetRemoteKey() == "" {
+		return errors.New("remote key must be defined")
+	}
+
+	provider, err := getProvider(w.store)
+	if err != nil {
+		return fmt.Errorf(errFailedToGetStore, err)
+	}
+
+	value, err := utils.ExtractSecretData(data, secret)
+	if err != nil {
+		return err
+	}
+
+	if err := w.wh.PushWebhookData(ctx, provider, value, data); err != nil {
+		return fmt.Errorf("failed to push webhook data: %w", err)
+	}
+
+	return nil
 }
 }
 
 
 // GetAllSecrets Empty .
 // GetAllSecrets Empty .
@@ -129,7 +147,7 @@ func (w *WebHook) GetAllSecrets(_ context.Context, _ esv1beta1.ExternalSecretFin
 func (w *WebHook) GetSecret(ctx context.Context, ref esv1beta1.ExternalSecretDataRemoteRef) ([]byte, error) {
 func (w *WebHook) GetSecret(ctx context.Context, ref esv1beta1.ExternalSecretDataRemoteRef) ([]byte, error) {
 	provider, err := getProvider(w.store)
 	provider, err := getProvider(w.store)
 	if err != nil {
 	if err != nil {
-		return nil, fmt.Errorf("failed to get store: %w", err)
+		return nil, fmt.Errorf(errFailedToGetStore, err)
 	}
 	}
 	result, err := w.wh.GetWebhookData(ctx, provider, &ref)
 	result, err := w.wh.GetWebhookData(ctx, provider, &ref)
 	if err != nil {
 	if err != nil {
@@ -197,7 +215,7 @@ func extractSecretData(jsondata any) ([]byte, error) {
 func (w *WebHook) GetSecretMap(ctx context.Context, ref esv1beta1.ExternalSecretDataRemoteRef) (map[string][]byte, error) {
 func (w *WebHook) GetSecretMap(ctx context.Context, ref esv1beta1.ExternalSecretDataRemoteRef) (map[string][]byte, error) {
 	provider, err := getProvider(w.store)
 	provider, err := getProvider(w.store)
 	if err != nil {
 	if err != nil {
-		return nil, fmt.Errorf("failed to get store: %w", err)
+		return nil, fmt.Errorf(errFailedToGetStore, err)
 	}
 	}
 	return w.wh.GetSecretMap(ctx, provider, &ref)
 	return w.wh.GetSecretMap(ctx, provider, &ref)
 }
 }

+ 108 - 2
pkg/provider/webhook/webhook_test.go

@@ -26,8 +26,10 @@ import (
 	"time"
 	"time"
 
 
 	"gopkg.in/yaml.v3"
 	"gopkg.in/yaml.v3"
+	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 
 
+	"github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
 	esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
 	esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
 )
 )
 
 
@@ -37,16 +39,24 @@ type testCase struct {
 	Want want   `json:"want"`
 	Want want   `json:"want"`
 }
 }
 
 
+type secret struct {
+	Name string            `json:"name"`
+	Data map[string]string `json:"data"`
+}
+
 type args struct {
 type args struct {
 	URL        string `json:"url,omitempty"`
 	URL        string `json:"url,omitempty"`
 	Body       string `json:"body,omitempty"`
 	Body       string `json:"body,omitempty"`
 	Timeout    string `json:"timeout,omitempty"`
 	Timeout    string `json:"timeout,omitempty"`
 	Key        string `json:"key,omitempty"`
 	Key        string `json:"key,omitempty"`
+	SecretKey  string `json:"secretkey,omitempty"`
 	Property   string `json:"property,omitempty"`
 	Property   string `json:"property,omitempty"`
 	Version    string `json:"version,omitempty"`
 	Version    string `json:"version,omitempty"`
 	JSONPath   string `json:"jsonpath,omitempty"`
 	JSONPath   string `json:"jsonpath,omitempty"`
 	Response   string `json:"response,omitempty"`
 	Response   string `json:"response,omitempty"`
 	StatusCode int    `json:"statuscode,omitempty"`
 	StatusCode int    `json:"statuscode,omitempty"`
+	PushSecret bool   `json:"pushsecret,omitempty"`
+	Secret     secret `json:"secret,omitempty"`
 }
 }
 
 
 type want struct {
 type want struct {
@@ -353,6 +363,64 @@ func TestWebhookGetSecret(t *testing.T) {
 	}
 	}
 }
 }
 
 
+var testCasesPushSecret = `
+case: good json
+args:
+  url: /api/pushsecret?id={{ .remoteRef.remoteKey }}&secret={{ .remoteRef.secretKey }}
+  key: testkey
+  secretkey: secretkey
+  pushsecret: true
+  secret:
+    name: test-secret
+    data:
+      secretkey: value
+want:
+  path: /api/pushsecret?id=testkey&secret=secretkey
+  err: ''
+---
+case: secret key not found
+args:
+  url: /api/pushsecret?id={{ .remoteRef.remoteKey }}&secret={{ .remoteRef.secretKey }}
+  key: testkey
+  secretkey: not-found
+  pushsecret: true
+  secret:
+    name: test-secret
+    data:
+      secretkey: value
+want:
+  path: /api/pushsecret?id=testkey&secret=not-found
+  err: 'failed to find secret key in secret with key: not-found'
+---
+case: pushing without secret key
+args:
+  url: /api/pushsecret?id={{ .remoteRef.remoteKey }}
+  key: testkey
+  pushsecret: true
+  secret:
+    name: test-secret
+    data:
+      secretkey: value
+want:
+  path: /api/pushsecret?id=testkey
+  err: ''
+---
+`
+
+func TestWebhookPushSecret(t *testing.T) {
+	ydec := yaml.NewDecoder(bytes.NewReader([]byte(testCasesPushSecret)))
+	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 {
 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
 	// 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) {
 	return httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
@@ -402,10 +470,12 @@ func runTestCase(tc testCase, t *testing.T) {
 		return
 		return
 	}
 	}
 
 
-	if tc.Want.ResultMap != nil {
+	if tc.Want.ResultMap != nil && !tc.Args.PushSecret {
 		testGetSecretMap(tc, t, client)
 		testGetSecretMap(tc, t, client)
-	} else {
+	} else if !tc.Args.PushSecret {
 		testGetSecret(tc, t, client)
 		testGetSecret(tc, t, client)
+	} else {
+		testPushSecret(tc, t, client)
 	}
 	}
 }
 }
 
 
@@ -455,6 +525,42 @@ func testGetSecret(tc testCase, t *testing.T, client esv1beta1.SecretsClient) {
 	}
 	}
 }
 }
 
 
+func testPushSecret(tc testCase, t *testing.T, client esv1beta1.SecretsClient) {
+	testRef := v1alpha1.PushSecretData{
+		Match: v1alpha1.PushSecretMatch{
+			SecretKey: tc.Args.SecretKey,
+			RemoteRef: v1alpha1.PushSecretRemoteRef{
+				RemoteKey: tc.Args.Key,
+			},
+		},
+	}
+
+	data := map[string][]byte{}
+	for k, v := range tc.Args.Secret.Data {
+		data[k] = []byte(v)
+	}
+	sec := &corev1.Secret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: tc.Args.Secret.Name,
+		},
+		Data: data,
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+	defer cancel()
+	err := client.PushSecret(ctx, sec, testRef)
+	errStr := ""
+	if err != nil {
+		errStr = err.Error()
+	}
+	if tc.Want.Err == "" && errStr != "" {
+		t.Errorf("%s: unexpected error: '%s' (expected '%s')", tc.Case, errStr, tc.Want.Err)
+	}
+
+	if !strings.Contains(errStr, tc.Want.Err) {
+		t.Errorf("%s: unexpected error: '%s' (expected '%s')", tc.Case, errStr, tc.Want.Err)
+	}
+}
+
 func makeClusterSecretStore(url string, args args) *esv1beta1.ClusterSecretStore {
 func makeClusterSecretStore(url string, args args) *esv1beta1.ClusterSecretStore {
 	store := &esv1beta1.ClusterSecretStore{
 	store := &esv1beta1.ClusterSecretStore{
 		TypeMeta: metav1.TypeMeta{
 		TypeMeta: metav1.TypeMeta{

+ 26 - 0
pkg/utils/utils.go

@@ -526,6 +526,32 @@ func CompareStringAndByteSlices(valueString *string, valueByte []byte) bool {
 	return bytes.Equal(valueByte, []byte(*valueString))
 	return bytes.Equal(valueByte, []byte(*valueString))
 }
 }
 
 
+func ExtractSecretData(data esv1beta1.PushSecretData, secret *corev1.Secret) ([]byte, error) {
+	var (
+		err   error
+		value []byte
+		ok    bool
+	)
+	if data.GetSecretKey() == "" {
+		decodedMap := make(map[string]string)
+		for k, v := range secret.Data {
+			decodedMap[k] = string(v)
+		}
+		value, err = JSONMarshal(decodedMap)
+
+		if err != nil {
+			return nil, fmt.Errorf("failed to marshal secret data: %w", err)
+		}
+	} else {
+		value, ok = secret.Data[data.GetSecretKey()]
+
+		if !ok {
+			return nil, fmt.Errorf("failed to find secret key in secret with key: %s", data.GetSecretKey())
+		}
+	}
+	return value, nil
+}
+
 // CreateCertOpts contains options for a cert pool creation.
 // CreateCertOpts contains options for a cert pool creation.
 type CreateCertOpts struct {
 type CreateCertOpts struct {
 	CABundle   []byte
 	CABundle   []byte