Browse Source

fix: introducing support for conversion strategy for PushSecret. (#3292)

* fix: introducing support for conversion strategy for PushSecret.

Signed-off-by: Rodrigo Fior Kuntzer <rodrigo@miro.com>

* fix: unit tests code quality.

Signed-off-by: Rodrigo Fior Kuntzer <rodrigo@miro.com>

---------

Signed-off-by: Rodrigo Fior Kuntzer <rodrigo@miro.com>
Rodrigo Fior Kuntzer 2 years ago
parent
commit
9ff2354213

+ 12 - 0
apis/externalsecrets/v1alpha1/pushsecret_types.go

@@ -57,6 +57,14 @@ const (
 	PushSecretDeletionPolicyNone   PushSecretDeletionPolicy = "None"
 	PushSecretDeletionPolicyNone   PushSecretDeletionPolicy = "None"
 )
 )
 
 
+// +kubebuilder:validation:Enum=None;ReverseUnicode
+type PushSecretConversionStrategy string
+
+const (
+	PushSecretConversionNone           PushSecretConversionStrategy = "None"
+	PushSecretConversionReverseUnicode PushSecretConversionStrategy = "ReverseUnicode"
+)
+
 // PushSecretSpec configures the behavior of the PushSecret.
 // PushSecretSpec configures the behavior of the PushSecret.
 type PushSecretSpec struct {
 type PushSecretSpec struct {
 	// The Interval to which External Secrets will try to push a secret definition
 	// The Interval to which External Secrets will try to push a secret definition
@@ -121,6 +129,10 @@ type PushSecretData struct {
 	// The structure of metadata is provider specific, please look it up in the provider documentation.
 	// The structure of metadata is provider specific, please look it up in the provider documentation.
 	// +optional
 	// +optional
 	Metadata *apiextensionsv1.JSON `json:"metadata,omitempty"`
 	Metadata *apiextensionsv1.JSON `json:"metadata,omitempty"`
+	// +optional
+	// Used to define a conversion Strategy for the secret keys
+	// +kubebuilder:default="None"
+	ConversionStrategy PushSecretConversionStrategy `json:"conversionStrategy,omitempty"`
 }
 }
 
 
 func (d PushSecretData) GetMetadata() *apiextensionsv1.JSON {
 func (d PushSecretData) GetMetadata() *apiextensionsv1.JSON {

+ 16 - 0
config/crds/bases/external-secrets.io_pushsecrets.yaml

@@ -50,6 +50,14 @@ spec:
                 description: Secret Data that should be pushed to providers
                 description: Secret Data that should be pushed to providers
                 items:
                 items:
                   properties:
                   properties:
+                    conversionStrategy:
+                      default: None
+                      description: Used to define a conversion Strategy for the secret
+                        keys
+                      enum:
+                      - None
+                      - ReverseUnicode
+                      type: string
                     match:
                     match:
                       description: Match a given Secret Key to be pushed to the provider.
                       description: Match a given Secret Key to be pushed to the provider.
                       properties:
                       properties:
@@ -315,6 +323,14 @@ spec:
                 additionalProperties:
                 additionalProperties:
                   additionalProperties:
                   additionalProperties:
                     properties:
                     properties:
+                      conversionStrategy:
+                        default: None
+                        description: Used to define a conversion Strategy for the
+                          secret keys
+                        enum:
+                        - None
+                        - ReverseUnicode
+                        type: string
                       match:
                       match:
                         description: Match a given Secret Key to be pushed to the
                         description: Match a given Secret Key to be pushed to the
                           provider.
                           provider.

+ 14 - 0
deploy/crds/bundle.yaml

@@ -5547,6 +5547,13 @@ spec:
                   description: Secret Data that should be pushed to providers
                   description: Secret Data that should be pushed to providers
                   items:
                   items:
                     properties:
                     properties:
+                      conversionStrategy:
+                        default: None
+                        description: Used to define a conversion Strategy for the secret keys
+                        enum:
+                          - None
+                          - ReverseUnicode
+                        type: string
                       match:
                       match:
                         description: Match a given Secret Key to be pushed to the provider.
                         description: Match a given Secret Key to be pushed to the provider.
                         properties:
                         properties:
@@ -5802,6 +5809,13 @@ spec:
                   additionalProperties:
                   additionalProperties:
                     additionalProperties:
                     additionalProperties:
                       properties:
                       properties:
+                        conversionStrategy:
+                          default: None
+                          description: Used to define a conversion Strategy for the secret keys
+                          enum:
+                            - None
+                            - ReverseUnicode
+                          type: string
                         match:
                         match:
                           description: Match a given Secret Key to be pushed to the provider.
                           description: Match a given Secret Key to be pushed to the provider.
                           properties:
                           properties:

+ 3 - 0
docs/guides/pushsecrets.md

@@ -40,3 +40,6 @@ This will _marshal_ the entire secret data and push it into this single property
 
 
 !!! warning inline
 !!! warning inline
     This should _ONLY_ be done if the secret data is marshal-able. Values like, binary data cannot be marshaled and will result in error or invalid secret data.
     This should _ONLY_ be done if the secret data is marshal-able. Values like, binary data cannot be marshaled and will result in error or invalid secret data.
+
+### Key conversion strategy
+You can also set `data[*].conversionStrategy: ReverseUnicode` to reverse the invalid character replaced by the `conversionStrategy: Unicode` configuration in the `ExternalSecret` object as [documented here](../guides/getallsecrets/#avoiding-name-conflicts).

+ 2 - 1
docs/snippets/full-pushsecret.yaml

@@ -29,7 +29,8 @@ spec:
           items:
           items:
             - key: config.yml
             - key: config.yml
   data:
   data:
-    - match:
+    - conversionStrategy: None # Also supports the ReverseUnicode strategy
+      match:
         secretKey: best-pokemon # Source Kubernetes secret key to be pushed
         secretKey: best-pokemon # Source Kubernetes secret key to be pushed
         remoteRef:
         remoteRef:
           remoteKey: my-first-parameter # Remote reference (where the secret is going to be pushed)
           remoteKey: my-first-parameter # Remote reference (where the secret is going to be pushed)

+ 8 - 0
pkg/controllers/pushsecret/pushsecret_controller.go

@@ -38,6 +38,7 @@ import (
 	"github.com/external-secrets/external-secrets/pkg/controllers/pushsecret/psmetrics"
 	"github.com/external-secrets/external-secrets/pkg/controllers/pushsecret/psmetrics"
 	"github.com/external-secrets/external-secrets/pkg/controllers/secretstore"
 	"github.com/external-secrets/external-secrets/pkg/controllers/secretstore"
 	"github.com/external-secrets/external-secrets/pkg/provider/util/locks"
 	"github.com/external-secrets/external-secrets/pkg/provider/util/locks"
+	"github.com/external-secrets/external-secrets/pkg/utils"
 )
 )
 
 
 const (
 const (
@@ -47,6 +48,7 @@ const (
 	errGetClusterSecretStore = "could not get ClusterSecretStore %q, %w"
 	errGetClusterSecretStore = "could not get ClusterSecretStore %q, %w"
 	errSetSecretFailed       = "could not write remote ref %v to target secretstore %v: %v"
 	errSetSecretFailed       = "could not write remote ref %v to target secretstore %v: %v"
 	errFailedSetSecret       = "set secret failed: %v"
 	errFailedSetSecret       = "set secret failed: %v"
+	errConvert               = "could not apply conversion strategy to keys: %v"
 	pushSecretFinalizer      = "pushsecret.externalsecrets.io/finalizer"
 	pushSecretFinalizer      = "pushsecret.externalsecrets.io/finalizer"
 )
 )
 
 
@@ -289,11 +291,17 @@ func (r *Reconciler) handlePushSecretDataForStore(ctx context.Context, ps esapi.
 		Name: storeName,
 		Name: storeName,
 		Kind: refKind,
 		Kind: refKind,
 	}
 	}
+	originalSecretData := secret.Data
 	secretClient, err := mgr.Get(ctx, storeRef, ps.GetNamespace(), nil)
 	secretClient, err := mgr.Get(ctx, storeRef, ps.GetNamespace(), nil)
 	if err != nil {
 	if err != nil {
 		return out, fmt.Errorf("could not get secrets client for store %v: %w", storeName, err)
 		return out, fmt.Errorf("could not get secrets client for store %v: %w", storeName, err)
 	}
 	}
 	for _, data := range ps.Spec.Data {
 	for _, data := range ps.Spec.Data {
+		secretData, err := utils.ReverseKeys(data.ConversionStrategy, originalSecretData)
+		if err != nil {
+			return nil, fmt.Errorf(errConvert, err)
+		}
+		secret.Data = secretData
 		key := data.GetSecretKey()
 		key := data.GetSecretKey()
 		if !secretKeyExists(key, secret) {
 		if !secretKeyExists(key, secret) {
 			return out, fmt.Errorf("secret key %v does not exist", key)
 			return out, fmt.Errorf("secret key %v does not exist", key)

+ 59 - 0
pkg/controllers/pushsecret/pushsecret_controller_test.go

@@ -637,6 +637,64 @@ var _ = Describe("ExternalSecret controller", func() {
 			return true
 			return true
 		}
 		}
 	}
 	}
+	// if conversion strategy is defined, revert the keys based on the strategy.
+	syncSuccessfullyWithConversionStrategy := func(tc *testCase) {
+		fakeProvider.SetSecretFn = func() error {
+			return nil
+		}
+		tc.pushsecret = &v1alpha1.PushSecret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      PushSecretName,
+				Namespace: PushSecretNamespace,
+			},
+			Spec: v1alpha1.PushSecretSpec{
+				SecretStoreRefs: []v1alpha1.PushSecretStoreRef{
+					{
+						Name: PushSecretStore,
+						Kind: "SecretStore",
+					},
+				},
+				Selector: v1alpha1.PushSecretSelector{
+					Secret: v1alpha1.PushSecretSecret{
+						Name: SecretName,
+					},
+				},
+				Data: []v1alpha1.PushSecretData{
+					{
+						ConversionStrategy: v1alpha1.PushSecretConversionReverseUnicode,
+						Match: v1alpha1.PushSecretMatch{
+							SecretKey: "some-array[0].entity",
+							RemoteRef: v1alpha1.PushSecretRemoteRef{
+								RemoteKey: "path/to/key",
+							},
+						},
+					},
+				},
+			},
+		}
+		tc.secret = &v1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      SecretName,
+				Namespace: PushSecretNamespace,
+			},
+			Data: map[string][]byte{
+				"some-array_U005b_0_U005d_.entity": []byte("value"),
+			},
+		}
+		tc.assert = func(ps *v1alpha1.PushSecret, secret *v1.Secret) bool {
+			Eventually(func() bool {
+				By("checking if Provider value got updated")
+				secretValue := secret.Data["some-array_U005b_0_U005d_.entity"]
+				providerValue, ok := fakeProvider.SetSecretArgs[ps.Spec.Data[0].Match.RemoteRef.RemoteKey]
+				if !ok {
+					return false
+				}
+				got := providerValue.Value
+				return bytes.Equal(got, secretValue)
+			}, time.Second*10, time.Second).Should(BeTrue())
+			return true
+		}
+	}
 	// if target Secret name is not specified it should use the ExternalSecret name.
 	// if target Secret name is not specified it should use the ExternalSecret name.
 	syncMatchingLabels := func(tc *testCase) {
 	syncMatchingLabels := func(tc *testCase) {
 		fakeProvider.SetSecretFn = func() error {
 		fakeProvider.SetSecretFn = func() error {
@@ -937,6 +995,7 @@ var _ = Describe("ExternalSecret controller", func() {
 		Entry("should update the PushSecret status correctly if UpdatePolicy=IfNotExists", updateIfNotExistsSyncStatus),
 		Entry("should update the PushSecret status correctly if UpdatePolicy=IfNotExists", updateIfNotExistsSyncStatus),
 		Entry("should fail if secret existence cannot be verified if UpdatePolicy=IfNotExists", updateIfNotExistsSyncFailed),
 		Entry("should fail if secret existence cannot be verified if UpdatePolicy=IfNotExists", updateIfNotExistsSyncFailed),
 		Entry("should sync with template", syncSuccessfullyWithTemplate),
 		Entry("should sync with template", syncSuccessfullyWithTemplate),
+		Entry("should sync with conversion strategy", syncSuccessfullyWithConversionStrategy),
 		Entry("should delete if DeletionPolicy=Delete", syncAndDeleteSuccessfully),
 		Entry("should delete if DeletionPolicy=Delete", syncAndDeleteSuccessfully),
 		Entry("should track deletion tasks if Delete fails", failDelete),
 		Entry("should track deletion tasks if Delete fails", failDelete),
 		Entry("should track deleted stores if Delete fails", failDeleteStore),
 		Entry("should track deleted stores if Delete fails", failDeleteStore),

+ 44 - 0
pkg/utils/utils.go

@@ -33,6 +33,7 @@ import (
 
 
 	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 
 
+	esv1alpha1 "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"
 	esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
 	esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
 	"github.com/external-secrets/external-secrets/pkg/template/v2"
 	"github.com/external-secrets/external-secrets/pkg/template/v2"
@@ -45,6 +46,7 @@ const (
 
 
 var (
 var (
 	errKeyNotFound = errors.New("key not found")
 	errKeyNotFound = errors.New("key not found")
+	unicodeRegex   = regexp.MustCompile(`_U([0-9a-fA-F]{4,5})_`)
 )
 )
 
 
 // JSONMarshal takes an interface and returns a new escaped and encoded byte slice.
 // JSONMarshal takes an interface and returns a new escaped and encoded byte slice.
@@ -237,6 +239,48 @@ func convert(strategy esv1beta1.ExternalSecretConversionStrategy, str string) st
 	return strings.Join(newName, "")
 	return strings.Join(newName, "")
 }
 }
 
 
+// ReverseKeys reverses a secret map into a valid key map as expected by push secrets.
+// Replaces the unicode encoded representation characters back to the actual unicode character depending on convert strategy.
+func ReverseKeys(strategy esv1alpha1.PushSecretConversionStrategy, in map[string][]byte) (map[string][]byte, error) {
+	out := make(map[string][]byte, len(in))
+	for k, v := range in {
+		key := reverse(strategy, k)
+		if _, exists := out[key]; exists {
+			return nil, fmt.Errorf("secret name collision during conversion: %s", key)
+		}
+		out[key] = v
+	}
+	return out, nil
+}
+
+func reverse(strategy esv1alpha1.PushSecretConversionStrategy, str string) string {
+	switch strategy {
+	case esv1alpha1.PushSecretConversionReverseUnicode:
+		matches := unicodeRegex.FindAllStringSubmatchIndex(str, -1)
+
+		for i := len(matches) - 1; i >= 0; i-- {
+			match := matches[i]
+			start := match[0]
+			end := match[1]
+			unicodeHex := str[match[2]:match[3]]
+
+			unicodeInt, err := strconv.ParseInt(unicodeHex, 16, 32)
+			if err != nil {
+				continue // Skip invalid unicode representations
+			}
+
+			unicodeChar := fmt.Sprintf("%c", unicodeInt)
+			str = str[:start] + unicodeChar + str[end:]
+		}
+
+		return str
+	case esv1alpha1.PushSecretConversionNone:
+		return str
+	default:
+		return str
+	}
+}
+
 // MergeStringMap performs a deep clone from src to dest.
 // MergeStringMap performs a deep clone from src to dest.
 func MergeStringMap(dest, src map[string]string) {
 func MergeStringMap(dest, src map[string]string) {
 	for k, v := range src {
 	for k, v := range src {

+ 123 - 4
pkg/utils/utils_test.go

@@ -24,13 +24,17 @@ import (
 	v1 "k8s.io/api/core/v1"
 	v1 "k8s.io/api/core/v1"
 	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 
 
+	esv1alpha1 "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"
 )
 )
 
 
 const (
 const (
-	base64DecodedValue    string = "foo%_?bar"
-	base64EncodedValue    string = "Zm9vJV8/YmFy"
-	base64URLEncodedValue string = "Zm9vJV8_YmFy"
+	base64DecodedValue         string = "foo%_?bar"
+	base64EncodedValue         string = "Zm9vJV8/YmFy"
+	base64URLEncodedValue      string = "Zm9vJV8_YmFy"
+	keyWithEmojis              string = "😀foo😁bar😂baz😈bing"
+	keyWithInvalidChars        string = "some-array[0].entity"
+	keyWithEncodedInvalidChars string = "some-array_U005b_0_U005d_.entity"
 )
 )
 
 
 func TestObjectHash(t *testing.T) {
 func TestObjectHash(t *testing.T) {
@@ -212,7 +216,7 @@ func TestConvertKeys(t *testing.T) {
 			args: args{
 			args: args{
 				strategy: esv1beta1.ExternalSecretConversionUnicode,
 				strategy: esv1beta1.ExternalSecretConversionUnicode,
 				in: map[string][]byte{
 				in: map[string][]byte{
-					"😀foo😁bar😂baz😈bing": []byte(`noop`),
+					keyWithEmojis: []byte(`noop`),
 				},
 				},
 			},
 			},
 			want: map[string][]byte{
 			want: map[string][]byte{
@@ -234,6 +238,77 @@ func TestConvertKeys(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestReverseKeys(t *testing.T) {
+	type args struct {
+		encodingStrategy esv1beta1.ExternalSecretConversionStrategy
+		decodingStrategy esv1alpha1.PushSecretConversionStrategy
+		in               map[string][]byte
+	}
+	tests := []struct {
+		name    string
+		args    args
+		want    map[string][]byte
+		wantErr bool
+	}{
+		{
+			name: "encoding and decoding strategy are selecting Unicode conversion and reverse unicode, so the in and want should match, this test covers Unicode characters beyond the Basic Multilingual Plane (BMP)",
+			args: args{
+				encodingStrategy: esv1beta1.ExternalSecretConversionUnicode,
+				decodingStrategy: esv1alpha1.PushSecretConversionReverseUnicode,
+				in: map[string][]byte{
+					keyWithEmojis: []byte(`noop`),
+				},
+			},
+			want: map[string][]byte{
+				keyWithEmojis: []byte(`noop`),
+			},
+		},
+		{
+			name: "encoding and decoding strategy are selecting Unicode conversion and reverse unicode, so the in and want should match, this test covers Unicode characters in the Basic Multilingual Plane (BMP)",
+			args: args{
+				encodingStrategy: esv1beta1.ExternalSecretConversionUnicode,
+				decodingStrategy: esv1alpha1.PushSecretConversionReverseUnicode,
+				in: map[string][]byte{
+					keyWithInvalidChars: []byte(`noop`),
+				},
+			},
+			want: map[string][]byte{
+				keyWithInvalidChars: []byte(`noop`),
+			},
+		},
+		{
+			name: "the encoding strategy is selecting Unicode conversion, but the decoding strategy is none, so we want an encoded representation of the content",
+			args: args{
+				encodingStrategy: esv1beta1.ExternalSecretConversionUnicode,
+				decodingStrategy: esv1alpha1.PushSecretConversionNone,
+				in: map[string][]byte{
+					keyWithInvalidChars: []byte(`noop`),
+				},
+			},
+			want: map[string][]byte{
+				keyWithEncodedInvalidChars: []byte(`noop`),
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := ConvertKeys(tt.args.encodingStrategy, tt.args.in)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("ConvertKeys() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			got, err = ReverseKeys(tt.args.decodingStrategy, got)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("ReverseKeys() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("ReverseKeys() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
 func TestDecode(t *testing.T) {
 func TestDecode(t *testing.T) {
 	type args struct {
 	type args struct {
 		strategy esv1beta1.ExternalSecretDecodingStrategy
 		strategy esv1beta1.ExternalSecretDecodingStrategy
@@ -542,6 +617,50 @@ func TestRewrite(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestReverse(t *testing.T) {
+	type args struct {
+		strategy esv1alpha1.PushSecretConversionStrategy
+		in       string
+	}
+	tests := []struct {
+		name string
+		args args
+		want string
+	}{
+		{
+			name: "do not change the key when using the None strategy",
+			args: args{
+				strategy: esv1alpha1.PushSecretConversionNone,
+				in:       keyWithEncodedInvalidChars,
+			},
+			want: keyWithEncodedInvalidChars,
+		},
+		{
+			name: "reverse an unicode encoded key",
+			args: args{
+				strategy: esv1alpha1.PushSecretConversionReverseUnicode,
+				in:       keyWithEncodedInvalidChars,
+			},
+			want: keyWithInvalidChars,
+		},
+		{
+			name: "do not attempt to decode an invalid unicode representation",
+			args: args{
+				strategy: esv1alpha1.PushSecretConversionReverseUnicode,
+				in:       "_U0xxx_x_U005b_",
+			},
+			want: "_U0xxx_x[",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := reverse(tt.args.strategy, tt.args.in); got != tt.want {
+				t.Errorf("reverse() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
 func TestFetchValueFromMetadata(t *testing.T) {
 func TestFetchValueFromMetadata(t *testing.T) {
 	type args struct {
 	type args struct {
 		key  string
 		key  string