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
 ```
 
+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
 
 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"
 
 	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"
-	"github.com/external-secrets/external-secrets/pkg/utils/resolvers"
 )
 
 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 {
 		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
+			return err
 		}
 		for sKey, sVal := range secret.Data {
 			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) {
@@ -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)
 	}
 
-	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 {
 		return nil, fmt.Errorf("failed to create request: %w", err)
 	}
+
 	for hKey, hValueTpl := range provider.Headers {
 		hValue, err := ExecuteTemplateString(hValueTpl, rawData)
 		if err != nil {
@@ -251,56 +301,6 @@ func (w *Webhook) GetCACertPool(ctx context.Context, provider *Spec) (*x509.Cert
 	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 {

+ 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")
 	}
 
-	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(), "")

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

@@ -33,7 +33,8 @@ import (
 )
 
 const (
-	errNotImplemented = "not implemented"
+	errNotImplemented   = "not implemented"
+	errFailedToGetStore = "failed to get store: %w"
 )
 
 // 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)
 }
 
-// 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 .
@@ -129,7 +147,7 @@ func (w *WebHook) GetAllSecrets(_ context.Context, _ esv1beta1.ExternalSecretFin
 func (w *WebHook) GetSecret(ctx context.Context, ref esv1beta1.ExternalSecretDataRemoteRef) ([]byte, error) {
 	provider, err := getProvider(w.store)
 	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)
 	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) {
 	provider, err := getProvider(w.store)
 	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)
 }

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

@@ -26,8 +26,10 @@ import (
 	"time"
 
 	"gopkg.in/yaml.v3"
+	corev1 "k8s.io/api/core/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"
 )
 
@@ -37,16 +39,24 @@ type testCase struct {
 	Want want   `json:"want"`
 }
 
+type secret struct {
+	Name string            `json:"name"`
+	Data map[string]string `json:"data"`
+}
+
 type args struct {
 	URL        string `json:"url,omitempty"`
 	Body       string `json:"body,omitempty"`
 	Timeout    string `json:"timeout,omitempty"`
 	Key        string `json:"key,omitempty"`
+	SecretKey  string `json:"secretkey,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"`
+	PushSecret bool   `json:"pushsecret,omitempty"`
+	Secret     secret `json:"secret,omitempty"`
 }
 
 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 {
 	// 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) {
@@ -402,10 +470,12 @@ func runTestCase(tc testCase, t *testing.T) {
 		return
 	}
 
-	if tc.Want.ResultMap != nil {
+	if tc.Want.ResultMap != nil && !tc.Args.PushSecret {
 		testGetSecretMap(tc, t, client)
-	} else {
+	} else if !tc.Args.PushSecret {
 		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 {
 	store := &esv1beta1.ClusterSecretStore{
 		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))
 }
 
+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.
 type CreateCertOpts struct {
 	CABundle   []byte