Browse Source

feat: push secret metadata (#3600)

Signed-off-by: Moritz Johner <beller.moritz@googlemail.com>
Moritz Johner 1 year ago
parent
commit
9f7533867d

+ 68 - 0
docs/provider/kubernetes.md

@@ -298,6 +298,74 @@ rules:
   - create
 ```
 
+#### PushSecret Metadata
+
+The Kubernetes provider is able to manage both `metadata.labels` and `metadata.annotations` of the secret on the target cluster.
+
+Users have different preferences on what metadata should be pushed. ESO by default pushes both labels and annotations to the target secret and merges them with the existing metadata.
+
+You can specify the metadata in the `spec.template.metadata` section if you want to decouple it from the existing secret.
+
+```yaml
+{% raw %}
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: example
+spec:
+  # ...
+  template:
+    metadata:
+      labels:
+        app.kubernetes.io/part-of: argocd
+    data:
+      mysql_connection_string: "mysql://{{ .hostname }}:3306/{{ .database }}"
+  data:
+  - match:
+      secretKey: mysql_connection_string
+      remoteRef:
+        remoteKey: backend_secrets
+        property: mysql_connection_string
+{% endraw %}
+```
+
+Further, you can leverage the `.data[].metadata` section to fine-tine the behaviour of the metadata merge strategy. The metadata section is a versioned custom-resource _alike_ structure, the behaviour is detailed below.
+
+```yaml
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: example
+spec:
+  # ...
+  data:
+  - match:
+      secretKey: example-1
+      remoteRef:
+        remoteKey: example-remote-secret
+        property: url
+        
+    metadata:
+      apiVersion: kubernetes.external-secrets.io/v1alpha1
+      kind: PushSecretMetadata
+      spec:
+        sourceMergePolicy: Merge # or Replace
+        targetMergePolicy: Merge # or Replace / Ignore
+        labels:
+          color: red
+        annotations:
+          yes: please
+
+```
+
+
+| Field             | Type                                 | Description                                                                                                                                                                                                                                                                                                                                       |
+| ----------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| sourceMergePolicy | string: `Merge`, `Replace`           | The sourceMergePolicy defines how the metadata of the source secret is merged. `Merge` will merge the metadata of the source secret with the  metadata defined in `.data[].metadata`. With `Replace`, the metadata in `.data[].metadata` replaces the source metadata.                                                                            |
+| targetMergePolicy | string: `Merge`, `Replace`, `Ignore` | The targetMergePolicy defines how ESO merges the metadata produced by the sourceMergePolicy with the target secret. With `Merge`, the source metadata is merged with the existing metadata from the target secret. `Replace` will replace the target metadata with the metadata defined in the source. `Ignore` leaves the target metadata as is. |
+| labels            | `map[string]string`                  | The labels.                                                                                                                                                                                                                                                                                                                                       |
+| annotations       | `map[string]string`                  | The annotations.                                                                                                                                                                                                                                                                                                                                  |
+
 #### Implementation Considerations
 
 When utilizing the PushSecret feature and configuring the permissions for the SecretStore, consider the following:

+ 87 - 93
pkg/provider/kubernetes/client.go

@@ -15,17 +15,16 @@ limitations under the License.
 package kubernetes
 
 import (
-	"bytes"
 	"context"
 	"encoding/base64"
 	"encoding/json"
 	"errors"
 	"fmt"
-	"reflect"
 	"strings"
 
 	"github.com/tidwall/gjson"
 	v1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/api/equality"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/labels"
@@ -109,50 +108,107 @@ func (c *Client) PushSecret(ctx context.Context, secret *v1.Secret, data esv1bet
 	if data.GetProperty() == "" && data.GetSecretKey() != "" {
 		return errors.New("requires property in RemoteRef to push secret value if secret key is defined")
 	}
+	remoteSecret := &v1.Secret{
+		ObjectMeta: metav1.ObjectMeta{
+			Namespace: c.store.RemoteNamespace,
+			Name:      data.GetRemoteKey(),
+		},
+	}
 
-	extSecret, getErr := c.userSecretClient.Get(ctx, data.GetRemoteKey(), metav1.GetOptions{})
-	metrics.ObserveAPICall(constants.ProviderKubernetes, constants.CallKubernetesGetSecret, getErr)
-	if getErr != nil {
-		// create if it not exists
-		if apierrors.IsNotFound(getErr) {
-			typ := v1.SecretTypeOpaque
-			if secret.Type != "" {
-				typ = secret.Type
-			}
+	return c.createOrUpdate(ctx, remoteSecret, func() error {
+		return c.mergePushSecretData(data, remoteSecret, secret)
+	})
+}
 
-			return c.createSecret(ctx, secret, typ, data)
-		}
-		return getErr
+func (c *Client) mergePushSecretData(remoteRef esv1beta1.PushSecretData, remoteSecret, localSecret *v1.Secret) error {
+	// apply secret type
+	secretType := v1.SecretTypeOpaque
+	if localSecret.Type != "" {
+		secretType = localSecret.Type
 	}
+	remoteSecret.Type = secretType
 
-	// the whole secret was pushed to the provider
-	if data.GetSecretKey() == "" {
-		if data.GetProperty() != "" {
-			value, err := c.marshalData(secret)
-			if err != nil {
-				return err
-			}
+	// merge secret data with existing secret data
+	if remoteSecret.Data == nil {
+		remoteSecret.Data = make(map[string][]byte)
+	}
 
-			if v, ok := extSecret.Data[data.GetProperty()]; ok && bytes.Equal(v, value) {
-				return nil
-			}
+	pushMeta, err := parseMetadataParameters(remoteRef.GetMetadata())
+	if err != nil {
+		return fmt.Errorf("unable to parse metadata parameters: %w", err)
+	}
+
+	// merge metadata based on the policy
+	var targetLabels, targetAnnotations map[string]string
+	sourceLabels, sourceAnnotations, err := mergeSourceMetadata(localSecret, pushMeta)
+	if err != nil {
+		return fmt.Errorf("failed to merge source metadata: %w", err)
+	}
+	targetLabels, targetAnnotations, err = mergeTargetMetadata(remoteSecret, pushMeta, sourceLabels, sourceAnnotations)
+	if err != nil {
+		return fmt.Errorf("failed to merge target metadata: %w", err)
+	}
+	remoteSecret.ObjectMeta.Labels = targetLabels
+	remoteSecret.ObjectMeta.Annotations = targetAnnotations
 
-			return c.updateProperty(ctx, extSecret, data, value)
+	// case 1: push the whole secret
+	if remoteRef.GetProperty() == "" {
+		for k, v := range localSecret.Data {
+			remoteSecret.Data[k] = v
 		}
+		return nil
+	}
 
-		if reflect.DeepEqual(extSecret.Data, secret.Data) {
-			return nil
+	// cases 2a + 2b: push into a property.
+	// if secret key is empty, we will marshal the whole secret and put it into
+	// the property defined in the remoteRef.
+	if remoteRef.GetSecretKey() == "" {
+		value, err := c.marshalData(localSecret)
+		if err != nil {
+			return err
+		}
+		remoteSecret.Data[remoteRef.GetProperty()] = value
+	} else {
+		// if secret key is defined, we will push that key from the local secret
+		remoteSecret.Data[remoteRef.GetProperty()] = localSecret.Data[remoteRef.GetSecretKey()]
+	}
+	return nil
+}
+
+func (c *Client) createOrUpdate(ctx context.Context, targetSecret *v1.Secret, f func() error) error {
+	target, err := c.userSecretClient.Get(ctx, targetSecret.Name, metav1.GetOptions{})
+	metrics.ObserveAPICall(constants.ProviderKubernetes, constants.CallKubernetesGetSecret, err)
+	if err != nil {
+		if !apierrors.IsNotFound(err) {
+			return err
+		}
+		if err := f(); err != nil {
+			return err
+		}
+		_, err := c.userSecretClient.Create(ctx, targetSecret, metav1.CreateOptions{})
+		metrics.ObserveAPICall(constants.ProviderKubernetes, constants.CallKubernetesCreateSecret, err)
+		if err != nil {
+			return err
 		}
+		return nil
+	}
 
-		return c.updateMap(ctx, extSecret, secret.Data)
+	*targetSecret = *target
+	existing := targetSecret.DeepCopyObject()
+	if err := f(); err != nil {
+		return err
 	}
 
-	// only a single property was pushed
-	if v, ok := extSecret.Data[data.GetProperty()]; ok && bytes.Equal(v, secret.Data[data.GetSecretKey()]) {
+	if equality.Semantic.DeepEqual(existing, targetSecret) {
 		return nil
 	}
 
-	return c.updateProperty(ctx, extSecret, data, secret.Data[data.GetSecretKey()])
+	_, err = c.userSecretClient.Update(ctx, targetSecret, metav1.UpdateOptions{})
+	metrics.ObserveAPICall(constants.ProviderKubernetes, constants.CallKubernetesUpdateSecret, err)
+	if err != nil {
+		return err
+	}
+	return nil
 }
 
 func (c *Client) marshalData(secret *v1.Secret) ([]byte, error) {
@@ -337,41 +393,6 @@ func convertMap(in map[string][]byte) map[string]string {
 	return out
 }
 
-func (c *Client) createSecret(ctx context.Context, secret *v1.Secret, typed v1.SecretType, remoteRef esv1beta1.PushSecretData) error {
-	data := make(map[string][]byte)
-
-	if remoteRef.GetProperty() != "" {
-		// set a specific remote key
-		if remoteRef.GetSecretKey() == "" {
-			value, err := c.marshalData(secret)
-			if err != nil {
-				return err
-			}
-
-			data[remoteRef.GetProperty()] = value
-		} else {
-			// push a specific secret key into a specific remote property
-			data[remoteRef.GetProperty()] = secret.Data[remoteRef.GetSecretKey()]
-		}
-	} else {
-		// push the whole secret as is using each key of the secret as a property in the created secret
-		data = secret.Data
-	}
-
-	s := v1.Secret{
-		ObjectMeta: metav1.ObjectMeta{
-			Name:      remoteRef.GetRemoteKey(),
-			Namespace: c.store.RemoteNamespace,
-		},
-		Data: data,
-		Type: typed,
-	}
-
-	_, err := c.userSecretClient.Create(ctx, &s, metav1.CreateOptions{})
-	metrics.ObserveAPICall(constants.ProviderKubernetes, constants.CallKubernetesCreateSecret, err)
-	return err
-}
-
 // fullDelete removes remote secret completely.
 func (c *Client) fullDelete(ctx context.Context, secretName string) error {
 	err := c.userSecretClient.Delete(ctx, secretName, metav1.DeleteOptions{})
@@ -392,33 +413,6 @@ func (c *Client) removeProperty(ctx context.Context, extSecret *v1.Secret, remot
 	return err
 }
 
-func (c *Client) updateMap(ctx context.Context, extSecret *v1.Secret, values map[string][]byte) error {
-	// update the existing map with values from the pushed secret but keep existing values in tack.
-	for k, v := range values {
-		extSecret.Data[k] = v
-	}
-
-	return c.updateSecret(ctx, extSecret)
-}
-
-func (c *Client) updateProperty(ctx context.Context, extSecret *v1.Secret, remoteRef esv1beta1.PushSecretRemoteRef, value []byte) error {
-	if extSecret.Data == nil {
-		extSecret.Data = make(map[string][]byte)
-	}
-
-	// otherwise update remote secret
-	extSecret.Data[remoteRef.GetProperty()] = value
-
-	return c.updateSecret(ctx, extSecret)
-}
-
-func (c *Client) updateSecret(ctx context.Context, extSecret *v1.Secret) error {
-	_, err := c.userSecretClient.Update(ctx, extSecret, metav1.UpdateOptions{})
-	metrics.ObserveAPICall(constants.ProviderKubernetes, constants.CallKubernetesUpdateSecret, err)
-
-	return err
-}
-
 func getSecret(secret *v1.Secret, ref esv1beta1.ExternalSecretDataRemoteRef) ([]byte, error) {
 	if ref.MetadataPolicy == esv1beta1.ExternalSecretMetadataPolicyFetch {
 		s, found, err := getFromSecretMetadata(secret, ref)

+ 330 - 4
pkg/provider/kubernetes/client_test.go

@@ -24,6 +24,7 @@ import (
 	"github.com/google/go-cmp/cmp"
 	"github.com/stretchr/testify/assert"
 	v1 "k8s.io/api/core/v1"
+	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime/schema"
@@ -86,8 +87,9 @@ func (fk *fakeClient) Delete(_ context.Context, name string, _ metav1.DeleteOpti
 
 func (fk *fakeClient) Create(_ context.Context, secret *v1.Secret, _ metav1.CreateOptions) (*v1.Secret, error) {
 	s := &v1.Secret{
-		Data: secret.Data,
-		Type: secret.Type,
+		Data:       secret.Data,
+		ObjectMeta: secret.ObjectMeta,
+		Type:       secret.Type,
 	}
 	fk.secretMap[secret.Name] = s
 	return s, nil
@@ -98,6 +100,7 @@ func (fk *fakeClient) Update(_ context.Context, secret *v1.Secret, _ metav1.Upda
 	if !ok {
 		return nil, errors.New("error while updating secret")
 	}
+	s.ObjectMeta = secret.ObjectMeta
 	s.Data = secret.Data
 	return s, nil
 }
@@ -705,6 +708,9 @@ func TestDeleteSecret(t *testing.T) {
 			wantErr: false,
 			wantSecretMap: map[string]*v1.Secret{
 				"mysec": {
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "mysec",
+					},
 					Data: map[string][]byte{
 						"secret": []byte(`bar`),
 					},
@@ -797,6 +803,11 @@ func TestPushSecret(t *testing.T) {
 			},
 			wantSecretMap: map[string]*v1.Secret{
 				"mysec": {
+					ObjectMeta: metav1.ObjectMeta{
+						Name:        "mysec",
+						Labels:      map[string]string{},
+						Annotations: map[string]string{},
+					},
 					Data: map[string][]byte{
 						"token":  []byte(`foo`),
 						"token2": []byte(`foo`),
@@ -827,6 +838,11 @@ func TestPushSecret(t *testing.T) {
 			},
 			wantSecretMap: map[string]*v1.Secret{
 				"mysec": {
+					ObjectMeta: metav1.ObjectMeta{
+						Name:        "mysec",
+						Labels:      map[string]string{},
+						Annotations: map[string]string{},
+					},
 					Data: map[string][]byte{
 						"token": []byte(`{"foo":"bar"}`),
 					},
@@ -856,6 +872,11 @@ func TestPushSecret(t *testing.T) {
 			},
 			wantSecretMap: map[string]*v1.Secret{
 				"mysec": {
+					ObjectMeta: metav1.ObjectMeta{
+						Name:        "mysec",
+						Labels:      map[string]string{},
+						Annotations: map[string]string{},
+					},
 					Data: map[string][]byte{
 						"token":  []byte(`foo`),
 						"token2": []byte(`{"foo":"bar"}`),
@@ -883,6 +904,11 @@ func TestPushSecret(t *testing.T) {
 			},
 			wantSecretMap: map[string]*v1.Secret{
 				"mysec": {
+					ObjectMeta: metav1.ObjectMeta{
+						Name:        "mysec",
+						Labels:      map[string]string{},
+						Annotations: map[string]string{},
+					},
 					Data: map[string][]byte{
 						"marshaled": []byte(`{"token":"foo","token2":"2"}`),
 					},
@@ -915,6 +941,11 @@ func TestPushSecret(t *testing.T) {
 			wantErr: false,
 			wantSecretMap: map[string]*v1.Secret{
 				"mysec": {
+					ObjectMeta: metav1.ObjectMeta{
+						Name:        "mysec",
+						Labels:      map[string]string{},
+						Annotations: map[string]string{},
+					},
 					Data: map[string][]byte{
 						"token":  []byte(`foo`),
 						"secret": []byte(`bar`),
@@ -947,6 +978,56 @@ func TestPushSecret(t *testing.T) {
 			wantErr: false,
 			wantSecretMap: map[string]*v1.Secret{
 				"mysec": {
+					ObjectMeta: metav1.ObjectMeta{
+						Name:        "mysec",
+						Labels:      map[string]string{},
+						Annotations: map[string]string{},
+					},
+					Data: map[string][]byte{
+						"token": []byte(`bar`),
+					},
+				},
+			},
+		},
+		{
+			name: "replace existing property in existing secret with targetMergePolicy set to Ignore",
+			fields: fields{
+				Client: &fakeClient{
+					t: t,
+					secretMap: map[string]*v1.Secret{
+						"mysec": {
+							Data: map[string][]byte{
+								"token": []byte(`foo`),
+							},
+						},
+					},
+				},
+			},
+			secret: &v1.Secret{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "mysec",
+					// these should be ignored as the targetMergePolicy is set to Ignore
+					Labels:      map[string]string{"dev": "seb"},
+					Annotations: map[string]string{"date": "today"},
+				},
+				Data: map[string][]byte{secretKey: []byte("bar")},
+			},
+			data: testingfake.PushSecretData{
+				SecretKey: secretKey,
+				RemoteKey: "mysec",
+				Property:  "token",
+				Metadata: &apiextensionsv1.JSON{
+					Raw: []byte(`{"apiVersion":"kubernetes.external-secrets.io/v1alpha1", "kind": "PushSecretMetadata", spec: {"targetMergePolicy": "Ignore"}}`),
+				},
+			},
+			wantErr: false,
+			wantSecretMap: map[string]*v1.Secret{
+				"mysec": {
+					ObjectMeta: metav1.ObjectMeta{
+						Name:        "mysec",
+						Labels:      map[string]string{},
+						Annotations: map[string]string{},
+					},
 					Data: map[string][]byte{
 						"token": []byte(`bar`),
 					},
@@ -954,7 +1035,65 @@ func TestPushSecret(t *testing.T) {
 			},
 		},
 		{
-			name: "create new secret",
+			name: "replace existing property in existing secret with targetMergePolicy set to Replace",
+			fields: fields{
+				Client: &fakeClient{
+					t: t,
+					secretMap: map[string]*v1.Secret{
+						"mysec": {
+							ObjectMeta: metav1.ObjectMeta{
+								Name: "mysec",
+								Labels: map[string]string{
+									"already": "existing",
+								},
+								Annotations: map[string]string{
+									"already": "existing",
+								},
+							},
+							Data: map[string][]byte{
+								"token": []byte(`foo`),
+							},
+						},
+					},
+				},
+			},
+			secret: &v1.Secret{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "mysec",
+					// these should replace existing metadata as the targetMergePolicy is set to Replace
+					Labels:      map[string]string{"dev": "seb"},
+					Annotations: map[string]string{"date": "today"},
+				},
+				Data: map[string][]byte{secretKey: []byte("bar")},
+			},
+			data: testingfake.PushSecretData{
+				SecretKey: secretKey,
+				RemoteKey: "mysec",
+				Property:  "token",
+				Metadata: &apiextensionsv1.JSON{
+					Raw: []byte(`{"apiVersion":"kubernetes.external-secrets.io/v1alpha1", "kind": "PushSecretMetadata", spec: {"targetMergePolicy": "Replace"}}`),
+				},
+			},
+			wantErr: false,
+			wantSecretMap: map[string]*v1.Secret{
+				"mysec": {
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "mysec",
+						Labels: map[string]string{
+							"dev": "seb",
+						},
+						Annotations: map[string]string{
+							"date": "today",
+						},
+					},
+					Data: map[string][]byte{
+						"token": []byte(`bar`),
+					},
+				},
+			},
+		},
+		{
+			name: "create new secret, merging existing metadata",
 			fields: fields{
 				Client: &fakeClient{
 					t: t,
@@ -968,12 +1107,20 @@ func TestPushSecret(t *testing.T) {
 				},
 			},
 			secret: &v1.Secret{
+				ObjectMeta: metav1.ObjectMeta{
+					Annotations: map[string]string{
+						"this-annotation": "should be present on the targey secret",
+					},
+				},
 				Data: map[string][]byte{secretKey: []byte("bar")},
 			},
 			data: testingfake.PushSecretData{
 				SecretKey: secretKey,
 				RemoteKey: "mysec",
 				Property:  "secret",
+				Metadata: &apiextensionsv1.JSON{
+					Raw: []byte(`{"apiVersion":"kubernetes.external-secrets.io/v1alpha1", "kind": "PushSecretMetadata", spec: {"annotations": {"date": "today"}, "labels": {"dev": "seb"}}}`),
+				},
 			},
 			wantErr: false,
 			wantSecretMap: map[string]*v1.Secret{
@@ -983,6 +1130,14 @@ func TestPushSecret(t *testing.T) {
 					},
 				},
 				"mysec": {
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "mysec",
+						Annotations: map[string]string{
+							"date":            "today",
+							"this-annotation": "should be present on the targey secret",
+						},
+						Labels: map[string]string{"dev": "seb"},
+					},
 					Data: map[string][]byte{
 						"secret": []byte(`bar`),
 					},
@@ -991,6 +1146,171 @@ func TestPushSecret(t *testing.T) {
 			},
 		},
 		{
+			name: "create new secret with metadata from secret metadata and remoteRef.metadata",
+			fields: fields{
+				Client: &fakeClient{
+					t: t,
+					secretMap: map[string]*v1.Secret{
+						"yoursec": {
+							Data: map[string][]byte{
+								"token": []byte(`foo`),
+							},
+						},
+					},
+				},
+			},
+			secret: &v1.Secret{
+				ObjectMeta: metav1.ObjectMeta{
+					Annotations: map[string]string{"date": "today"},
+					Labels:      map[string]string{"dev": "seb"},
+				},
+				Data: map[string][]byte{secretKey: []byte("bar")},
+			},
+			data: testingfake.PushSecretData{
+				SecretKey: secretKey,
+				RemoteKey: "mysec",
+				Property:  "secret",
+				Metadata: &apiextensionsv1.JSON{
+					Raw: []byte(`{"apiVersion":"kubernetes.external-secrets.io/v1alpha1", "kind": "PushSecretMetadata", spec: { "sourceMergePolicy": "Replace", "annotations": {"another-field": "from-remote-ref"}, "labels": {"other-label": "from-remote-ref"}}}`),
+				},
+			},
+			wantErr: false,
+			wantSecretMap: map[string]*v1.Secret{
+				"yoursec": {
+					Data: map[string][]byte{
+						"token": []byte(`foo`),
+					},
+				},
+				"mysec": {
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "mysec",
+						Annotations: map[string]string{
+							"another-field": "from-remote-ref",
+						},
+						Labels: map[string]string{
+							"other-label": "from-remote-ref",
+						},
+					},
+					Data: map[string][]byte{
+						"secret": []byte(`bar`),
+					},
+					Type: v1.SecretTypeOpaque,
+				},
+			},
+		},
+		{
+			name: "invalid secret metadata structure results in error",
+			fields: fields{
+				Client: &fakeClient{
+					t: t,
+					secretMap: map[string]*v1.Secret{
+						"yoursec": {
+							Data: map[string][]byte{
+								"token": []byte(`foo`),
+							},
+						},
+					},
+				},
+			},
+			secret: &v1.Secret{
+				Data: map[string][]byte{secretKey: []byte("bar")},
+			},
+			data: testingfake.PushSecretData{
+				SecretKey: secretKey,
+				RemoteKey: "mysec",
+				Property:  "secret",
+				Metadata: &apiextensionsv1.JSON{
+					Raw: []byte(`{}`),
+				},
+			},
+			wantErr: true,
+			wantSecretMap: map[string]*v1.Secret{
+				"yoursec": {
+					Data: map[string][]byte{
+						"token": []byte(`foo`),
+					},
+				},
+			},
+		},
+		{
+			name: "non-json secret metadata results in error",
+			fields: fields{
+				Client: &fakeClient{
+					t: t,
+					secretMap: map[string]*v1.Secret{
+						"yoursec": {
+							Data: map[string][]byte{
+								"token": []byte(`foo`),
+							},
+						},
+					},
+				},
+			},
+			secret: &v1.Secret{
+				Data: map[string][]byte{secretKey: []byte("bar")},
+			},
+			data: testingfake.PushSecretData{
+				SecretKey: secretKey,
+				RemoteKey: "mysec",
+				Property:  "secret",
+				Metadata: &apiextensionsv1.JSON{
+					Raw: []byte(`--- not json ---`),
+				},
+			},
+			wantErr: true,
+			wantSecretMap: map[string]*v1.Secret{
+				"yoursec": {
+					Data: map[string][]byte{
+						"token": []byte(`foo`),
+					},
+				},
+			},
+		},
+		{
+			name: "create new secret with whole secret",
+			fields: fields{
+				Client: &fakeClient{
+					t: t,
+					secretMap: map[string]*v1.Secret{
+						"yoursec": {
+							Data: map[string][]byte{
+								"token": []byte(`foo`),
+							},
+						},
+					},
+				},
+			},
+			secret: &v1.Secret{
+				Data: map[string][]byte{
+					"foo": []byte("bar"),
+					"baz": []byte("bang"),
+				},
+			},
+			data: testingfake.PushSecretData{
+				RemoteKey: "mysec",
+			},
+			wantErr: false,
+			wantSecretMap: map[string]*v1.Secret{
+				"yoursec": {
+					Data: map[string][]byte{
+						"token": []byte(`foo`),
+					},
+				},
+				"mysec": {
+					ObjectMeta: metav1.ObjectMeta{
+						Name:        "mysec",
+						Labels:      map[string]string{},
+						Annotations: map[string]string{},
+					},
+					Data: map[string][]byte{
+						"foo": []byte("bar"),
+						"baz": []byte("bang"),
+					},
+					Type: v1.SecretTypeOpaque,
+				},
+			},
+		},
+		{
 			name: "create new dockerconfigjson secret",
 			fields: fields{
 				Client: &fakeClient{
@@ -1021,13 +1341,19 @@ func TestPushSecret(t *testing.T) {
 					},
 				},
 				"mysec": {
+					ObjectMeta: metav1.ObjectMeta{
+						Name:        "mysec",
+						Labels:      map[string]string{},
+						Annotations: map[string]string{},
+					},
 					Data: map[string][]byte{
 						"config.json": []byte(`{"auths": {"myregistry.localhost": {"username": "{{ .username }}", "password": "{{ .password }}"}}}`),
 					},
 					Type: v1.SecretTypeDockerConfigJson,
 				},
 			},
-		}}
+		},
+	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			p := &Client{

+ 148 - 0
pkg/provider/kubernetes/metadata.go

@@ -0,0 +1,148 @@
+/*
+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 kubernetes
+
+import (
+	"fmt"
+
+	v1 "k8s.io/api/core/v1"
+	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"sigs.k8s.io/yaml"
+)
+
+const (
+	metadataAPIVersion = "kubernetes.external-secrets.io/v1alpha1"
+	metadataKind       = "PushSecretMetadata"
+)
+
+type PushSecretMetadata struct {
+	metav1.TypeMeta
+	Spec PushSecretMetadataSpec `json:"spec,omitempty"`
+}
+type PushSecretMetadataSpec struct {
+	TargetMergePolicy targetMergePolicy `json:"targetMergePolicy,omitempty"`
+	SourceMergePolicy sourceMergePolicy `json:"sourceMergePolicy,omitempty"`
+
+	Labels      map[string]string `json:"labels,omitempty"`
+	Annotations map[string]string `json:"annotations,omitempty"`
+}
+
+type targetMergePolicy string
+
+const (
+	targetMergePolicyMerge   targetMergePolicy = "Merge"
+	targetMergePolicyReplace targetMergePolicy = "Replace"
+	targetMergePolicyIgnore  targetMergePolicy = "Ignore"
+)
+
+type sourceMergePolicy string
+
+const (
+	sourceMergePolicyMerge   sourceMergePolicy = "Merge"
+	sourceMergePolicyReplace sourceMergePolicy = "Replace"
+)
+
+func parseMetadataParameters(data *apiextensionsv1.JSON) (*PushSecretMetadata, error) {
+	if data == nil {
+		return nil, nil
+	}
+	var metadata PushSecretMetadata
+	err := yaml.Unmarshal(data.Raw, &metadata, yaml.DisallowUnknownFields)
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse %s %s: %w", metadataAPIVersion, metadataKind, err)
+	}
+
+	if metadata.APIVersion != metadataAPIVersion {
+		return nil, fmt.Errorf("unexpected apiVersion %q, expected %q", metadata.APIVersion, metadataAPIVersion)
+	}
+
+	if metadata.Kind != metadataKind {
+		return nil, fmt.Errorf("unexpected kind %q, expected %q", metadata.Kind, metadataKind)
+	}
+
+	return &metadata, nil
+}
+
+// Takes the local secret metadata and merges it with the push metadata.
+// The push metadata takes precedence.
+// Depending on the policy, we either merge or overwrite the metadata from the local secret.
+func mergeSourceMetadata(localSecret *v1.Secret, pushMeta *PushSecretMetadata) (map[string]string, map[string]string, error) {
+	labels := localSecret.ObjectMeta.Labels
+	annotations := localSecret.ObjectMeta.Annotations
+	if pushMeta == nil {
+		return labels, annotations, nil
+	}
+	if labels == nil {
+		labels = make(map[string]string)
+	}
+	if annotations == nil {
+		annotations = make(map[string]string)
+	}
+
+	switch pushMeta.Spec.SourceMergePolicy {
+	case "", sourceMergePolicyMerge:
+		for k, v := range pushMeta.Spec.Labels {
+			labels[k] = v
+		}
+		for k, v := range pushMeta.Spec.Annotations {
+			annotations[k] = v
+		}
+	case sourceMergePolicyReplace:
+		labels = pushMeta.Spec.Labels
+		annotations = pushMeta.Spec.Annotations
+	default:
+		return nil, nil, fmt.Errorf("unexpected source merge policy %q", pushMeta.Spec.SourceMergePolicy)
+	}
+	return labels, annotations, nil
+}
+
+// Takes the remote secret metadata and merges it with the source metadata.
+// The source metadata may replace the existing labels/annotations
+// or merge into it depending on policy.
+func mergeTargetMetadata(remoteSecret *v1.Secret, pushMeta *PushSecretMetadata, sourceLabels, sourceAnnotations map[string]string) (map[string]string, map[string]string, error) {
+	labels := remoteSecret.ObjectMeta.Labels
+	annotations := remoteSecret.ObjectMeta.Annotations
+	if labels == nil {
+		labels = make(map[string]string)
+	}
+	if annotations == nil {
+		annotations = make(map[string]string)
+	}
+	var targetMergePolicy targetMergePolicy
+	if pushMeta != nil {
+		targetMergePolicy = pushMeta.Spec.TargetMergePolicy
+	}
+
+	switch targetMergePolicy {
+	case "", targetMergePolicyMerge:
+		for k, v := range sourceLabels {
+			labels[k] = v
+		}
+		for k, v := range sourceAnnotations {
+			annotations[k] = v
+		}
+	case targetMergePolicyReplace:
+		labels = sourceLabels
+		annotations = sourceAnnotations
+	case targetMergePolicyIgnore:
+		// leave the target metadata as is
+		// this is useful when we only want to push data
+		// and the user does not want to touch the metadata
+	default:
+		return nil, nil, fmt.Errorf("unexpected target merge policy %q", targetMergePolicy)
+	}
+	return labels, annotations, nil
+}