Просмотр исходного кода

feat(templating): decode templateFrom values (#6482)

* feat(templating): decode templateFrom values

Add decodingStrategy to templateFrom entries so templates can render
encoded text values and let ESO decode them after rendering, before
writing to the target Secret.

This is useful for dataFrom.find workflows where many provider secrets
contain structured JSON and only one nested property should be written to
the Kubernetes Secret. In particular, binary values are often stored in
JSON as Base64 text. Decoding those values inside the Go template with
b64dec injects raw bytes into the intermediate rendered YAML, which is
fragile for binary data. By decoding after rendering, the template output
remains text-safe and only the final Secret.data value becomes raw bytes.

I added decodingStrategy to TemplateFrom in v1 and backported to
v1beta1 API types to ease transitions into v1.

TemplateFrom ConfigMap, Secret, and Literal sources now pass their
configured decodingStrategy to the template executor. Template.Data keeps
decodingStrategy=None to preserve existing behavior.

For the impact on keys, Decode will only render values, not render keys.
For Values scope, each rendered template value is decoded before being
applied. For KeysAndValues scope, the rendered YAML map is parsed first,
then each map value is decoded. Keys remain unchanged so dynamic key names
produced by templates are not accidentally transformed.

The default strategy is None and the executor accepts the decoding
strategy as an optional argument so existing direct calls continue to work.

Finally, I wanted to reuse the existing ESO decoding strategies:
None, Base64, Base64URL, and Auto.

But to avoid a cyclic dependency esutils -> template -> esutils,
I extracted the decoding into its own runtime module.

In that case esutils and template can all use decoding independently
without importing each other.
This is a public API (go module) change that should be fine in the
long term.

Now, runtime/decoding/decoding.go contains Decode() and DecodeMap()
And the utils/template are slightly edited/rewired to make it
work without duplicating the content.

Signed-off-by: Jean-Philippe Evrard <jean-philippe.evrard+rochepub@external.roche.com>

* Update snapshots

Without this, make check-diff will fail.

Signed-off-by: Jean-Philippe Evrard <jean-philippe.evrard+rochepub@external.roche.com>

* Adopt recommendations from reviews

This clean us the code by allowing ourselves to introduce a breaking
change: removing methods from esutils.

This means the callers are now updated to directly point to the
decoding runtime module. At the same time, it cleans up the decoding_test,
which was testing the DecodingMap by naming the test Decoding.
It was a bit confusing, I adapted.

Finally, the review highlighted the fact that it might be awkward
to name the spec "decoding strategy" while it was only decoding values.
This is therefore renamed to "valuesDecodingStrategy", to highlight
the keys are not decoded.

Signed-off-by: Jean-Philippe Evrard <jean-philippe.evrard+rochepub@external.roche.com>

* Last review comments

- Remove valuesDecodingStrategy from v1
- Cleanup the Exec signature

Signed-off-by: Jean-Philippe Evrard <jean-philippe.evrard+rochepub@external.roche.com>

---------

Signed-off-by: Jean-Philippe Evrard <jean-philippe.evrard+rochepub@external.roche.com>
Jean-Philippe Evrard 7 часов назад
Родитель
Сommit
f11995789a

+ 5 - 0
apis/externalsecrets/v1/externalsecret_types.go

@@ -163,6 +163,11 @@ type TemplateFrom struct {
 
 	// +optional
 	Literal *string `json:"literal,omitempty"`
+
+	// Used to define a decoding Strategy for the rendered template values.
+	// +optional
+	// +kubebuilder:default="None"
+	ValuesDecodingStrategy ExternalSecretDecodingStrategy `json:"valuesDecodingStrategy,omitempty"`
 }
 
 // TemplateScope specifies how the template keys should be interpreted.

+ 10 - 0
config/crds/bases/external-secrets.io_clusterexternalsecrets.yaml

@@ -724,6 +724,16 @@ spec:
                                     For custom resources (when spec.target.manifest is set), this supports
                                     nested paths like "spec.database.config" or "data".
                                   type: string
+                                valuesDecodingStrategy:
+                                  default: None
+                                  description: Used to define a decoding Strategy
+                                    for the rendered template values.
+                                  enum:
+                                  - Auto
+                                  - Base64
+                                  - Base64URL
+                                  - None
+                                  type: string
                               type: object
                             type: array
                           type:

+ 10 - 0
config/crds/bases/external-secrets.io_clusterpushsecrets.yaml

@@ -652,6 +652,16 @@ spec:
                                 For custom resources (when spec.target.manifest is set), this supports
                                 nested paths like "spec.database.config" or "data".
                               type: string
+                            valuesDecodingStrategy:
+                              default: None
+                              description: Used to define a decoding Strategy for
+                                the rendered template values.
+                              enum:
+                              - Auto
+                              - Base64
+                              - Base64URL
+                              - None
+                              type: string
                           type: object
                         type: array
                       type:

+ 10 - 0
config/crds/bases/external-secrets.io_externalsecrets.yaml

@@ -703,6 +703,16 @@ spec:
                                 For custom resources (when spec.target.manifest is set), this supports
                                 nested paths like "spec.database.config" or "data".
                               type: string
+                            valuesDecodingStrategy:
+                              default: None
+                              description: Used to define a decoding Strategy for
+                                the rendered template values.
+                              enum:
+                              - Auto
+                              - Base64
+                              - Base64URL
+                              - None
+                              type: string
                           type: object
                         type: array
                       type:

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

@@ -574,6 +574,16 @@ spec:
                             For custom resources (when spec.target.manifest is set), this supports
                             nested paths like "spec.database.config" or "data".
                           type: string
+                        valuesDecodingStrategy:
+                          default: None
+                          description: Used to define a decoding Strategy for the
+                            rendered template values.
+                          enum:
+                          - Auto
+                          - Base64
+                          - Base64URL
+                          - None
+                          type: string
                       type: object
                     type: array
                   type:

+ 36 - 0
deploy/crds/bundle.yaml

@@ -675,6 +675,15 @@ spec:
                                       For custom resources (when spec.target.manifest is set), this supports
                                       nested paths like "spec.database.config" or "data".
                                     type: string
+                                  valuesDecodingStrategy:
+                                    default: None
+                                    description: Used to define a decoding Strategy for the rendered template values.
+                                    enum:
+                                      - Auto
+                                      - Base64
+                                      - Base64URL
+                                      - None
+                                    type: string
                                 type: object
                               type: array
                             type:
@@ -2184,6 +2193,15 @@ spec:
                                   For custom resources (when spec.target.manifest is set), this supports
                                   nested paths like "spec.database.config" or "data".
                                 type: string
+                              valuesDecodingStrategy:
+                                default: None
+                                description: Used to define a decoding Strategy for the rendered template values.
+                                enum:
+                                  - Auto
+                                  - Base64
+                                  - Base64URL
+                                  - None
+                                type: string
                             type: object
                           type: array
                         type:
@@ -13498,6 +13516,15 @@ spec:
                                   For custom resources (when spec.target.manifest is set), this supports
                                   nested paths like "spec.database.config" or "data".
                                 type: string
+                              valuesDecodingStrategy:
+                                default: None
+                                description: Used to define a decoding Strategy for the rendered template values.
+                                enum:
+                                  - Auto
+                                  - Base64
+                                  - Base64URL
+                                  - None
+                                type: string
                             type: object
                           type: array
                         type:
@@ -14720,6 +14747,15 @@ spec:
                               For custom resources (when spec.target.manifest is set), this supports
                               nested paths like "spec.database.config" or "data".
                             type: string
+                          valuesDecodingStrategy:
+                            default: None
+                            description: Used to define a decoding Strategy for the rendered template values.
+                            enum:
+                              - Auto
+                              - Base64
+                              - Base64URL
+                              - None
+                            type: string
                         type: object
                       type: array
                     type:

+ 16 - 1
docs/api/spec.md

@@ -4369,7 +4369,8 @@ ExternalSecretNullBytePolicy
 <p>
 (<em>Appears on:</em>
 <a href="#external-secrets.io/v1.ExternalSecretDataRemoteRef">ExternalSecretDataRemoteRef</a>, 
-<a href="#external-secrets.io/v1.ExternalSecretFind">ExternalSecretFind</a>)
+<a href="#external-secrets.io/v1.ExternalSecretFind">ExternalSecretFind</a>, 
+<a href="#external-secrets.io/v1.TemplateFrom">TemplateFrom</a>)
 </p>
 <p>
 <p>ExternalSecretDecodingStrategy defines strategies for decoding secret values.</p>
@@ -11443,6 +11444,20 @@ string
 <em>(Optional)</em>
 </td>
 </tr>
+<tr>
+<td>
+<code>valuesDecodingStrategy</code></br>
+<em>
+<a href="#external-secrets.io/v1.ExternalSecretDecodingStrategy">
+ExternalSecretDecodingStrategy
+</a>
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>Used to define a decoding Strategy for the rendered template values.</p>
+</td>
+</tr>
 </tbody>
 </table>
 <h3 id="external-secrets.io/v1.TemplateMergePolicy">TemplateMergePolicy

+ 96 - 1
docs/guides/templating.md

@@ -57,7 +57,102 @@ You do not have to define your templates inline in an ExternalSecret but you can
 
 Lastly, `TemplateFrom` also supports adding `Literal` blocks for quick templating. These `Literal` blocks differ from `Template.Data` as they are rendered as a a `key:value` pair (while the `Template.Data`, you can only template the value).
 
-See an example, how to produce a `htpasswd` file that can be used by an ingress-controller (for example: https://kubernetes.github.io/ingress-nginx/examples/auth/basic/) where the contents of the `htpasswd` file needs to be presented via the `auth` key. We use the `htpasswd` function to create a `bcrytped` hash of the password.
+#### ValuesDecodingStrategy example
+
+`TemplateFrom` entries can also decode rendered values with `ValuesDecodingStrategy`. This is useful when the template selects Base64-encoded values from structured provider data and the final Kubernetes Secret must contain the decoded bytes.
+
+For example, imagine several remote secrets matched by `dataFrom.find` contain JSON values like this:
+
+```json
+{
+  "cert": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCg==",
+  "description": "certificate encoded as base64"
+}
+```
+
+And let's imagine an ExternalSecret definition as this one:
+
+```yaml
+{% raw %}
+apiVersion: external-secrets.io/v1
+kind: ExternalSecret
+metadata:
+  name: nginx-certs
+spec:
+  refreshInterval: 1h
+  secretStoreRef:
+    kind: ClusterSecretStore
+    name: aws-secretsmanager
+  dataFrom:
+  - find:
+      name:
+        regexp: ^productA/nginx/.*
+    rewrite:
+    - regexp:
+        source: ^productA/nginx/(.*)
+        target: $1
+  target:
+    name: nginx-certs
+    template:
+      engineVersion: v2
+      templateFrom:
+      - literal: |-
+          {{- range $key, $val := . }}
+          {{- $json := $val | fromJson }}
+          {{ $key }}: {{ $json.cert }}
+          {{- end }}
+{% endraw %}
+```
+Without `templateFrom[0].ValuesDecodingStrategy`, the template will select the `cert` property, and get the base64 text. The resulting Kubernetes Secret value will be stored as Base64 text.
+
+Alternatively, you can use the `templateFrom[0].valuesDecodingStrategy: Base64` as following:
+
+```yaml
+{% raw %}
+apiVersion: external-secrets.io/v1
+kind: ExternalSecret
+metadata:
+  name: nginx-certs
+spec:
+  refreshInterval: 1h
+  secretStoreRef:
+    kind: ClusterSecretStore
+    name: aws-secretsmanager
+  dataFrom:
+  - find:
+      name:
+        regexp: ^productA/nginx/.*
+    rewrite:
+    - regexp:
+        source: ^productA/nginx/(.*)
+        target: $1
+  target:
+    name: nginx-certs
+    template:
+      engineVersion: v2
+      templateFrom:
+      - valuesDecodingStrategy: Base64
+        literal: |-
+          {{- range $key, $val := . }}
+          {{- $json := $val | fromJson }}
+          {{ $key }}: {{ $json.cert }}
+          {{- end }}
+{% endraw %}
+```
+
+This way, the template still renders safe Base64 text internally.
+ESO then decodes the value and writes the decoded bytes in the Kubernetes Secret's data.
+Only rendered values are decoded; rendered keys are left unchanged.
+
+In other words, use `valuesDecodingStrategy` to `None` when values are not encoded, and our usual strategies like `Base64`, `Base64URL` (or even `Auto`) when values may be either Base64/Base64URL encoded.
+
+!!! note
+
+    This is safer for binary data than decoding inside the template with `{% raw %}{{ $json.cert | b64dec }}{% endraw %}`, because `b64dec` injects raw bytes into the intermediate rendered YAML.
+
+#### htpasswd example
+
+See an example, how to produce a `htpasswd` file that can be used by an ingress-controller (for example: https://kubernetes.github.io/ingress-nginx/examples/auth/basic/) where the contents of the `htpasswd` file needs to be presented via the `auth` key. We use the `htpasswd` function to create a `bcrypted` hash of the password.
 
 Suppose you have multiple key-value pairs within your provider secret like
 

+ 3 - 3
pkg/controllers/externalsecret/externalsecret_controller_manifest.go

@@ -288,7 +288,7 @@ func (r *Reconciler) renderTemplatedManifest(ctx context.Context, es *esv1.Exter
 			// Execute template directly against the unstructured object
 			out := make(map[string][]byte)
 			out[*tplFrom.Literal] = []byte(*tplFrom.Literal)
-			if err := execute(out, dataMap, esv1.TemplateScopeKeysAndValues, targetPath, obj); err != nil {
+			if err := execute(out, dataMap, esv1.TemplateScopeKeysAndValues, targetPath, obj, tplFrom.ValuesDecodingStrategy); err != nil {
 				return nil, fmt.Errorf("failed to execute literal template: %w", err)
 			}
 		}
@@ -316,7 +316,7 @@ func (r *Reconciler) renderTemplatedManifest(ctx context.Context, es *esv1.Exter
 			}
 
 			// apply collected data to the target object
-			if err := execute(tempSecret.Data, dataMap, esv1.TemplateScopeValues, targetPath, obj); err != nil {
+			if err := execute(tempSecret.Data, dataMap, esv1.TemplateScopeValues, targetPath, obj, esv1.ExternalSecretDecodeNone); err != nil {
 				return nil, fmt.Errorf("failed to apply merged templates to path %s: %w", targetPath, err)
 			}
 		}
@@ -329,7 +329,7 @@ func (r *Reconciler) renderTemplatedManifest(ctx context.Context, es *esv1.Exter
 			tplMap[k] = []byte(v)
 		}
 
-		if err := execute(tplMap, dataMap, esv1.TemplateScopeValues, esv1.TemplateTargetData, obj); err != nil {
+		if err := execute(tplMap, dataMap, esv1.TemplateScopeValues, esv1.TemplateTargetData, obj, esv1.ExternalSecretDecodeNone); err != nil {
 			return nil, fmt.Errorf("failed to execute template.data: %w", err)
 		}
 	}

+ 4 - 3
pkg/controllers/externalsecret/externalsecret_controller_secret.go

@@ -31,6 +31,7 @@ import (
 	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
 	genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1"
 	"github.com/external-secrets/external-secrets/pkg/controllers/secretstore"
+	"github.com/external-secrets/external-secrets/runtime/decoding"
 	"github.com/external-secrets/external-secrets/runtime/esutils"
 	"github.com/external-secrets/external-secrets/runtime/esutils/resolvers"
 	"github.com/external-secrets/external-secrets/runtime/statemanager"
@@ -134,7 +135,7 @@ func (r *Reconciler) handleSecretData(ctx context.Context, externalSecret *esv1.
 	}
 
 	// decode the secret if needed
-	secretData, err = esutils.Decode(secretRef.RemoteRef.DecodingStrategy, secretData)
+	secretData, err = decoding.Decode(secretRef.RemoteRef.DecodingStrategy, secretData)
 	if err != nil {
 		return fmt.Errorf(errDecode, secretRef.RemoteRef.DecodingStrategy, err)
 	}
@@ -247,7 +248,7 @@ func (r *Reconciler) handleExtractSecrets(
 	}
 
 	// decode the secrets if needed
-	secretMap, err = esutils.DecodeMap(remoteRef.Extract.DecodingStrategy, secretMap)
+	secretMap, err = decoding.DecodeMap(remoteRef.Extract.DecodingStrategy, secretMap)
 	if err != nil {
 		return nil, fmt.Errorf(errDecode, remoteRef.Extract.DecodingStrategy, err)
 	}
@@ -298,7 +299,7 @@ func (r *Reconciler) handleFindAllSecrets(
 	}
 
 	// decode the secrets if needed
-	secretMap, err = esutils.DecodeMap(remoteRef.Find.DecodingStrategy, secretMap)
+	secretMap, err = decoding.DecodeMap(remoteRef.Find.DecodingStrategy, secretMap)
 	if err != nil {
 		return nil, fmt.Errorf(errDecode, remoteRef.Find.DecodingStrategy, err)
 	}

+ 4 - 4
pkg/controllers/templating/parser.go

@@ -83,7 +83,7 @@ func (p *Parser) MergeConfigMap(ctx context.Context, namespace string, tpl esv1.
 		case esv1.TemplateScopeKeysAndValues:
 			out[val] = []byte(val)
 		}
-		err := p.Exec(out, p.DataMap, k.TemplateAs, tpl.Target, p.TargetSecret)
+		err := p.Exec(out, p.DataMap, k.TemplateAs, tpl.Target, p.TargetSecret, tpl.ValuesDecodingStrategy)
 		if err != nil {
 			return err
 		}
@@ -122,7 +122,7 @@ func (p *Parser) MergeSecret(ctx context.Context, namespace string, tpl esv1.Tem
 		case esv1.TemplateScopeKeysAndValues:
 			out[string(val)] = val
 		}
-		err := p.Exec(out, p.DataMap, k.TemplateAs, tpl.Target, p.TargetSecret)
+		err := p.Exec(out, p.DataMap, k.TemplateAs, tpl.Target, p.TargetSecret, tpl.ValuesDecodingStrategy)
 		if err != nil {
 			return err
 		}
@@ -137,7 +137,7 @@ func (p *Parser) MergeLiteral(_ context.Context, tpl esv1.TemplateFrom) error {
 	}
 	out := make(map[string][]byte)
 	out[*tpl.Literal] = []byte(*tpl.Literal)
-	return p.Exec(out, p.DataMap, esv1.TemplateScopeKeysAndValues, tpl.Target, p.TargetSecret)
+	return p.Exec(out, p.DataMap, esv1.TemplateScopeKeysAndValues, tpl.Target, p.TargetSecret, tpl.ValuesDecodingStrategy)
 }
 
 // MergeTemplateFrom merges all templates specified in the ExternalSecretTemplate's TemplateFrom field.
@@ -169,7 +169,7 @@ func (p *Parser) MergeMap(tplMap map[string]string, target string) error {
 	for k, v := range tplMap {
 		byteMap[k] = []byte(v)
 	}
-	err := p.Exec(byteMap, p.DataMap, esv1.TemplateScopeValues, target, p.TargetSecret)
+	err := p.Exec(byteMap, p.DataMap, esv1.TemplateScopeValues, target, p.TargetSecret, esv1.ExternalSecretDecodeNone)
 	if err != nil {
 		return fmt.Errorf(errExecTpl, err)
 	}

+ 69 - 0
pkg/controllers/templating/parser_test.go

@@ -0,0 +1,69 @@
+/*
+Copyright © The ESO Authors
+
+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
+
+    https://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 templating
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	corev1 "k8s.io/api/core/v1"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+)
+
+func TestParserMergeLiteralPassesTemplateFromValuesDecodingStrategy(t *testing.T) {
+	literal := "decoded: SGVsbG8="
+	var got esv1.ExternalSecretDecodingStrategy
+
+	p := &Parser{
+		Exec: func(_ map[string][]byte, _ map[string][]byte, _ esv1.TemplateScope, _ string, _ client.Object, decodingStrategy esv1.ExternalSecretDecodingStrategy) error {
+			got = decodingStrategy
+			return nil
+		},
+		DataMap:      map[string][]byte{},
+		TargetSecret: &corev1.Secret{},
+	}
+
+	err := p.MergeLiteral(context.Background(), esv1.TemplateFrom{
+		Literal:                &literal,
+		ValuesDecodingStrategy: esv1.ExternalSecretDecodeBase64,
+	})
+
+	require.NoError(t, err)
+	assert.Equal(t, esv1.ExternalSecretDecodeBase64, got)
+}
+
+func TestParserMergeMapKeepsTemplateDataUndecoded(t *testing.T) {
+	var got esv1.ExternalSecretDecodingStrategy
+
+	p := &Parser{
+		Exec: func(_ map[string][]byte, _ map[string][]byte, _ esv1.TemplateScope, _ string, _ client.Object, decodingStrategy esv1.ExternalSecretDecodingStrategy) error {
+			got = decodingStrategy
+			return nil
+		},
+		DataMap:      map[string][]byte{},
+		TargetSecret: &corev1.Secret{},
+	}
+
+	err := p.MergeMap(map[string]string{"encoded": "SGVsbG8="}, esv1.TemplateTargetData)
+
+	require.NoError(t, err)
+	assert.Equal(t, esv1.ExternalSecretDecodeNone, got)
+}

+ 73 - 0
runtime/decoding/decoding.go

@@ -0,0 +1,73 @@
+/*
+Copyright © The ESO Authors
+
+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
+
+    https://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 decoding provides helpers for decoding ExternalSecret values.
+package decoding
+
+import (
+	"encoding/base64"
+	"fmt"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+)
+
+// DecodeMap decodes values from a secretMap.
+func DecodeMap(strategy esv1.ExternalSecretDecodingStrategy, in map[string][]byte) (map[string][]byte, error) {
+	out := make(map[string][]byte, len(in))
+	for k, v := range in {
+		val, err := Decode(strategy, v)
+		if err != nil {
+			return nil, fmt.Errorf("failure decoding key %v: %w", k, err)
+		}
+		out[k] = val
+	}
+	return out, nil
+}
+
+// Decode decodes the input byte slice according to the provided decoding strategy.
+func Decode(strategy esv1.ExternalSecretDecodingStrategy, in []byte) ([]byte, error) {
+	switch strategy {
+	case esv1.ExternalSecretDecodeBase64:
+		out, err := base64.StdEncoding.DecodeString(string(in))
+		if err != nil {
+			return nil, err
+		}
+		return out, nil
+	case esv1.ExternalSecretDecodeBase64URL:
+		out, err := base64.URLEncoding.DecodeString(string(in))
+		if err != nil {
+			return nil, err
+		}
+		return out, nil
+	case esv1.ExternalSecretDecodeNone:
+		return in, nil
+	// default when stored version is v1alpha1
+	case "":
+		return in, nil
+	case esv1.ExternalSecretDecodeAuto:
+		out, err := Decode(esv1.ExternalSecretDecodeBase64, in)
+		if err != nil {
+			out, err := Decode(esv1.ExternalSecretDecodeBase64URL, in)
+			if err != nil {
+				return Decode(esv1.ExternalSecretDecodeNone, in)
+			}
+			return out, nil
+		}
+		return out, nil
+	default:
+		return nil, fmt.Errorf("decoding strategy %v is not supported", strategy)
+	}
+}

+ 211 - 0
runtime/decoding/decoding_test.go

@@ -0,0 +1,211 @@
+/*
+Copyright © The ESO Authors
+
+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
+
+    https://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 decoding
+
+import (
+	"reflect"
+	"testing"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+)
+
+const (
+	base64DecodedValue    string = "foo%_?bar"
+	base64EncodedValue    string = "Zm9vJV8/YmFy"
+	base64URLEncodedValue string = "Zm9vJV8_YmFy"
+)
+
+func TestDecode(t *testing.T) {
+	tests := []struct {
+		name     string
+		strategy esv1.ExternalSecretDecodingStrategy
+		in       []byte
+		want     []byte
+		wantErr  bool
+	}{
+		{
+			name:     "base64 decoded",
+			strategy: esv1.ExternalSecretDecodeBase64,
+			in:       []byte("YmFy"),
+			want:     []byte("bar"),
+		},
+		{
+			name:     "invalid base64",
+			strategy: esv1.ExternalSecretDecodeBase64,
+			in:       []byte("foo"),
+			wantErr:  true,
+		},
+		{
+			name:     "base64url decoded",
+			strategy: esv1.ExternalSecretDecodeBase64URL,
+			in:       []byte(base64URLEncodedValue),
+			want:     []byte(base64DecodedValue),
+		},
+		{
+			name:     "invalid base64url",
+			strategy: esv1.ExternalSecretDecodeBase64URL,
+			in:       []byte("foo"),
+			wantErr:  true,
+		},
+		{
+			name:     "none",
+			strategy: esv1.ExternalSecretDecodeNone,
+			in:       []byte(base64URLEncodedValue),
+			want:     []byte(base64URLEncodedValue),
+		},
+		{
+			name:     "empty strategy defaults to none",
+			strategy: "",
+			in:       []byte(base64URLEncodedValue),
+			want:     []byte(base64URLEncodedValue),
+		},
+		{
+			name:     "auto base64",
+			strategy: esv1.ExternalSecretDecodeAuto,
+			in:       []byte(base64EncodedValue),
+			want:     []byte(base64DecodedValue),
+		},
+		{
+			name:     "auto base64url",
+			strategy: esv1.ExternalSecretDecodeAuto,
+			in:       []byte(base64URLEncodedValue),
+			want:     []byte(base64DecodedValue),
+		},
+		{
+			name:     "auto invalid base64 returns input",
+			strategy: esv1.ExternalSecretDecodeAuto,
+			in:       []byte("foo"),
+			want:     []byte("foo"),
+		},
+		{
+			name:     "unsupported strategy",
+			strategy: esv1.ExternalSecretDecodingStrategy("unsupported"),
+			in:       []byte("foo"),
+			wantErr:  true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := Decode(tt.strategy, tt.in)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("Decode() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("Decode() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestDecodeMap(t *testing.T) {
+	type args struct {
+		strategy esv1.ExternalSecretDecodingStrategy
+		in       map[string][]byte
+	}
+	tests := []struct {
+		name    string
+		args    args
+		want    map[string][]byte
+		wantErr bool
+	}{
+		{
+			name: "base64 decoded",
+			args: args{
+				strategy: esv1.ExternalSecretDecodeBase64,
+				in: map[string][]byte{
+					"foo": []byte("YmFy"),
+				},
+			},
+			want: map[string][]byte{
+				"foo": []byte("bar"),
+			},
+		},
+		{
+			name: "invalid base64",
+			args: args{
+				strategy: esv1.ExternalSecretDecodeBase64,
+				in: map[string][]byte{
+					"foo": []byte("foo"),
+				},
+			},
+			wantErr: true,
+		},
+		{
+			name: "base64url decoded",
+			args: args{
+				strategy: esv1.ExternalSecretDecodeBase64URL,
+				in: map[string][]byte{
+					"foo": []byte(base64URLEncodedValue),
+				},
+			},
+			want: map[string][]byte{
+				"foo": []byte(base64DecodedValue),
+			},
+		},
+		{
+			name: "invalid base64url",
+			args: args{
+				strategy: esv1.ExternalSecretDecodeBase64URL,
+				in: map[string][]byte{
+					"foo": []byte("foo"),
+				},
+			},
+			wantErr: true,
+		},
+		{
+			name: "none",
+			args: args{
+				strategy: esv1.ExternalSecretDecodeNone,
+				in: map[string][]byte{
+					"foo": []byte(base64URLEncodedValue),
+				},
+			},
+			want: map[string][]byte{
+				"foo": []byte(base64URLEncodedValue),
+			},
+		},
+		{
+			name: "auto",
+			args: args{
+				strategy: esv1.ExternalSecretDecodeAuto,
+				in: map[string][]byte{
+					"b64":        []byte(base64EncodedValue),
+					"invalidb64": []byte("foo"),
+					"b64url":     []byte(base64URLEncodedValue),
+				},
+			},
+			want: map[string][]byte{
+				"b64":        []byte(base64DecodedValue),
+				"invalidb64": []byte("foo"),
+				"b64url":     []byte(base64DecodedValue),
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := DecodeMap(tt.args.strategy, tt.args.in)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("DecodeMap() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("DecodeMap() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}

+ 2 - 49
runtime/esutils/utils.go

@@ -55,6 +55,7 @@ import (
 	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
 	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
 	esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
+	"github.com/external-secrets/external-secrets/runtime/decoding"
 	"github.com/external-secrets/external-secrets/runtime/esutils/resolvers"
 	estemplate "github.com/external-secrets/external-secrets/runtime/template/v2"
 )
@@ -228,54 +229,6 @@ func RewriteTransform(operation esv1.ExternalSecretRewriteTransform, in map[stri
 	return out, nil
 }
 
-// DecodeMap decodes values from a secretMap.
-func DecodeMap(strategy esv1.ExternalSecretDecodingStrategy, in map[string][]byte) (map[string][]byte, error) {
-	out := make(map[string][]byte, len(in))
-	for k, v := range in {
-		val, err := Decode(strategy, v)
-		if err != nil {
-			return nil, fmt.Errorf("failure decoding key %v: %w", k, err)
-		}
-		out[k] = val
-	}
-	return out, nil
-}
-
-// Decode decodes the input byte slice according to the provided decoding strategy.
-func Decode(strategy esv1.ExternalSecretDecodingStrategy, in []byte) ([]byte, error) {
-	switch strategy {
-	case esv1.ExternalSecretDecodeBase64:
-		out, err := base64.StdEncoding.DecodeString(string(in))
-		if err != nil {
-			return nil, err
-		}
-		return out, nil
-	case esv1.ExternalSecretDecodeBase64URL:
-		out, err := base64.URLEncoding.DecodeString(string(in))
-		if err != nil {
-			return nil, err
-		}
-		return out, nil
-	case esv1.ExternalSecretDecodeNone:
-		return in, nil
-	// default when stored version is v1alpha1
-	case "":
-		return in, nil
-	case esv1.ExternalSecretDecodeAuto:
-		out, err := Decode(esv1.ExternalSecretDecodeBase64, in)
-		if err != nil {
-			out, err := Decode(esv1.ExternalSecretDecodeBase64URL, in)
-			if err != nil {
-				return Decode(esv1.ExternalSecretDecodeNone, in)
-			}
-			return out, nil
-		}
-		return out, nil
-	default:
-		return nil, fmt.Errorf("decoding strategy %v is not supported", strategy)
-	}
-}
-
 // ValidateKeys checks if the keys in the secret map are valid keys for a Kubernetes secret.
 func ValidateKeys(log logr.Logger, in map[string][]byte) error {
 	for key := range in {
@@ -778,7 +731,7 @@ func base64decode(cert []byte) ([]byte, error) {
 	}
 
 	// try decoding and test for validity again...
-	certificate, err := Decode(esv1.ExternalSecretDecodeAuto, cert)
+	certificate, err := decoding.Decode(esv1.ExternalSecretDecodeAuto, cert)
 	if err != nil {
 		return nil, fmt.Errorf("failed to decode base64: %w", err)
 	}

+ 0 - 100
runtime/esutils/utils_test.go

@@ -38,9 +38,6 @@ import (
 )
 
 const (
-	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"
@@ -318,103 +315,6 @@ func TestReverseKeys(t *testing.T) {
 	}
 }
 
-func TestDecode(t *testing.T) {
-	type args struct {
-		strategy esv1.ExternalSecretDecodingStrategy
-		in       map[string][]byte
-	}
-	tests := []struct {
-		name    string
-		args    args
-		want    map[string][]byte
-		wantErr bool
-	}{
-		{
-			name: "base64 decoded",
-			args: args{
-				strategy: esv1.ExternalSecretDecodeBase64,
-				in: map[string][]byte{
-					"foo": []byte("YmFy"),
-				},
-			},
-			want: map[string][]byte{
-				"foo": []byte("bar"),
-			},
-		},
-		{
-			name: "invalid base64",
-			args: args{
-				strategy: esv1.ExternalSecretDecodeBase64,
-				in: map[string][]byte{
-					"foo": []byte("foo"),
-				},
-			},
-			wantErr: true,
-		},
-		{
-			name: "base64url decoded",
-			args: args{
-				strategy: esv1.ExternalSecretDecodeBase64URL,
-				in: map[string][]byte{
-					"foo": []byte(base64URLEncodedValue),
-				},
-			},
-			want: map[string][]byte{
-				"foo": []byte(base64DecodedValue),
-			},
-		},
-		{
-			name: "invalid base64url",
-			args: args{
-				strategy: esv1.ExternalSecretDecodeBase64URL,
-				in: map[string][]byte{
-					"foo": []byte("foo"),
-				},
-			},
-			wantErr: true,
-		},
-		{
-			name: "none",
-			args: args{
-				strategy: esv1.ExternalSecretDecodeNone,
-				in: map[string][]byte{
-					"foo": []byte(base64URLEncodedValue),
-				},
-			},
-			want: map[string][]byte{
-				"foo": []byte(base64URLEncodedValue),
-			},
-		},
-		{
-			name: "auto",
-			args: args{
-				strategy: esv1.ExternalSecretDecodeAuto,
-				in: map[string][]byte{
-					"b64":        []byte(base64EncodedValue),
-					"invalidb64": []byte("foo"),
-					"b64url":     []byte(base64URLEncodedValue),
-				},
-			},
-			want: map[string][]byte{
-				"b64":        []byte(base64DecodedValue),
-				"invalidb64": []byte("foo"),
-				"b64url":     []byte(base64DecodedValue),
-			},
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			got, err := DecodeMap(tt.args.strategy, tt.args.in)
-			if (err != nil) != tt.wantErr {
-				t.Errorf("DecodeMap() error = %v, wantErr %v", err, tt.wantErr)
-				return
-			}
-			if !reflect.DeepEqual(got, tt.want) {
-				t.Errorf("DecodeMap() = %v, want %v", got, tt.want)
-			}
-		})
-	}
-}
 func TestValidate(t *testing.T) {
 	err := NetworkValidate("http://google.com", 10*time.Second)
 	if err != nil {

+ 1 - 1
runtime/template/engine.go

@@ -27,7 +27,7 @@ import (
 )
 
 // ExecFunc is the function signature type for executing a template engine.
-type ExecFunc func(tpl, data map[string][]byte, scope esapi.TemplateScope, target string, secret client.Object) error
+type ExecFunc func(tpl, data map[string][]byte, scope esapi.TemplateScope, target string, secret client.Object, valueDecodingStrategy esapi.ExternalSecretDecodingStrategy) error
 
 // EngineForVersion returns the appropriate template engine for the given version.
 func EngineForVersion(version esapi.TemplateEngineVersion) (ExecFunc, error) {

+ 52 - 0
runtime/template/engine_test.go

@@ -0,0 +1,52 @@
+/*
+Copyright © The ESO Authors
+
+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
+
+    https://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 template
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	corev1 "k8s.io/api/core/v1"
+
+	esapi "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+)
+
+func TestEngineForVersionSupportsDecodingStrategy(t *testing.T) {
+	exec, err := EngineForVersion(esapi.TemplateEngineV2)
+	require.NoError(t, err)
+
+	secret := &corev1.Secret{}
+	err = exec(
+		map[string][]byte{"tpl": []byte("message: SGVsbG8=\n")},
+		map[string][]byte{},
+		esapi.TemplateScopeKeysAndValues,
+		esapi.TemplateTargetData,
+		secret,
+		esapi.ExternalSecretDecodeBase64,
+	)
+
+	require.NoError(t, err)
+	assert.Equal(t, []byte("Hello"), secret.Data["message"])
+}
+
+func TestEngineForVersionRejectsUnknownVersion(t *testing.T) {
+	exec, err := EngineForVersion(esapi.TemplateEngineVersion("v1"))
+
+	require.Error(t, err)
+	assert.Nil(t, exec)
+}

+ 15 - 6
runtime/template/v2/template.go

@@ -31,6 +31,7 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
 	esapi "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	"github.com/external-secrets/external-secrets/runtime/decoding"
 	"github.com/external-secrets/external-secrets/runtime/feature"
 	"github.com/external-secrets/external-secrets/runtime/template/v2/sprig"
 )
@@ -145,12 +146,16 @@ func applyToTarget(k string, val []byte, target string, obj client.Object) error
 	return nil
 }
 
-func valueScopeApply(tplMap, data map[string][]byte, target string, secret client.Object) error {
+func valueScopeApply(tplMap, data map[string][]byte, target string, secret client.Object, decodingStrategy esapi.ExternalSecretDecodingStrategy) error {
 	for k, v := range tplMap {
 		val, err := execute(k, string(v), data)
 		if err != nil {
 			return fmt.Errorf(errExecute, k, err)
 		}
+		val, err = decoding.Decode(decodingStrategy, val)
+		if err != nil {
+			return fmt.Errorf("failed to decode rendered template value for key %s: %w", k, err)
+		}
 		if err := applyToTarget(k, val, target, secret); err != nil {
 			return fmt.Errorf("failed to apply to target: %w", err)
 		}
@@ -158,7 +163,7 @@ func valueScopeApply(tplMap, data map[string][]byte, target string, secret clien
 	return nil
 }
 
-func mapScopeApply(tpl string, data map[string][]byte, target string, secret client.Object) error {
+func mapScopeApply(tpl string, data map[string][]byte, target string, secret client.Object, decodingStrategy esapi.ExternalSecretDecodingStrategy) error {
 	val, err := execute(tpl, tpl, data)
 	if err != nil {
 		return fmt.Errorf(errExecute, tpl, err)
@@ -175,7 +180,11 @@ func mapScopeApply(tpl string, data map[string][]byte, target string, secret cli
 			return fmt.Errorf("could not unmarshal template to 'map[string][]byte': %w", err)
 		}
 		for k, val := range src {
-			if err := applyToTarget(k, []byte(val), target, secret); err != nil {
+			decodedVal, err := decoding.Decode(decodingStrategy, []byte(val))
+			if err != nil {
+				return fmt.Errorf("failed to decode rendered template value for key %s: %w", k, err)
+			}
+			if err := applyToTarget(k, decodedVal, target, secret); err != nil {
 				return fmt.Errorf("failed to apply to target: %w", err)
 			}
 		}
@@ -196,20 +205,20 @@ func mapScopeApply(tpl string, data map[string][]byte, target string, secret cli
 }
 
 // Execute renders the secret data as template. If an error occurs processing is stopped immediately.
-func Execute(tpl, data map[string][]byte, scope esapi.TemplateScope, target string, secret client.Object) error {
+func Execute(tpl, data map[string][]byte, scope esapi.TemplateScope, target string, secret client.Object, valueDecodingStrategy esapi.ExternalSecretDecodingStrategy) error {
 	if tpl == nil {
 		return nil
 	}
 	switch scope {
 	case esapi.TemplateScopeKeysAndValues:
 		for _, v := range tpl {
-			err := mapScopeApply(string(v), data, target, secret)
+			err := mapScopeApply(string(v), data, target, secret, valueDecodingStrategy)
 			if err != nil {
 				return err
 			}
 		}
 	case esapi.TemplateScopeValues:
-		err := valueScopeApply(tpl, data, target, secret)
+		err := valueScopeApply(tpl, data, target, secret, valueDecodingStrategy)
 		if err != nil {
 			return err
 		}

+ 97 - 11
runtime/template/v2/template_test.go

@@ -738,15 +738,15 @@ func TestExecute(t *testing.T) {
 				leftDelim = oldLeftDelim
 				rightDelim = oldRightDelim
 			}()
-			err := Execute(tt.tpl, tt.data, esapi.TemplateScopeValues, esapi.TemplateTargetData, sec)
+			err := Execute(tt.tpl, tt.data, esapi.TemplateScopeValues, esapi.TemplateTargetData, sec, esapi.ExternalSecretDecodeNone)
 			if !ErrorContains(err, tt.expErr) {
 				t.Errorf("unexpected error: %s, expected: %s", err, tt.expErr)
 			}
-			err = Execute(tt.labelsTpl, tt.data, esapi.TemplateScopeValues, esapi.TemplateTargetLabels, sec)
+			err = Execute(tt.labelsTpl, tt.data, esapi.TemplateScopeValues, esapi.TemplateTargetLabels, sec, esapi.ExternalSecretDecodeNone)
 			if !ErrorContains(err, tt.expLblErr) {
 				t.Errorf("unexpected error: %s, expected: %s", err, tt.expErr)
 			}
-			err = Execute(tt.annotationsTpl, tt.data, esapi.TemplateScopeValues, esapi.TemplateTargetAnnotations, sec)
+			err = Execute(tt.annotationsTpl, tt.data, esapi.TemplateScopeValues, esapi.TemplateTargetAnnotations, sec, esapi.ExternalSecretDecodeNone)
 			if !ErrorContains(err, tt.expAnnoErr) {
 				t.Errorf("unexpected error: %s, expected: %s", err, tt.expErr)
 			}
@@ -820,7 +820,7 @@ func TestScopeValuesWithSecretFieldsNil(t *testing.T) {
 		row := tbl[i]
 		t.Run(row.name, func(t *testing.T) {
 			sec := &corev1.Secret{}
-			err := Execute(row.tpl, row.data, esapi.TemplateScopeValues, row.target, sec)
+			err := Execute(row.tpl, row.data, esapi.TemplateScopeValues, row.target, sec, esapi.ExternalSecretDecodeNone)
 			if !ErrorContains(err, row.expErr) {
 				t.Errorf("unexpected error: %s, expected: %s", err, row.expErr)
 			}
@@ -844,7 +844,7 @@ func TestScopeValuesWithSecretFieldsNil(t *testing.T) {
 
 func TestExecuteInvalidTemplateScope(t *testing.T) {
 	sec := &corev1.Secret{}
-	err := Execute(map[string][]byte{"foo": []byte("bar")}, nil, "invalid", esapi.TemplateTargetData, sec)
+	err := Execute(map[string][]byte{"foo": []byte("bar")}, nil, "invalid", esapi.TemplateTargetData, sec, esapi.ExternalSecretDecodeNone)
 	require.Error(t, err)
 	assert.ErrorContains(t, err, "expected 'Values' or 'KeysAndValues'")
 }
@@ -854,7 +854,7 @@ func TestExecuteTargetCaseInsensitive(t *testing.T) {
 	for _, target := range []string{"Annotations", "annotations", "ANNOTATIONS", "AnNoTaTiOnS"} {
 		t.Run(target, func(t *testing.T) {
 			sec := &corev1.Secret{}
-			require.NoError(t, Execute(map[string][]byte{"foo": []byte("bar")}, nil, esapi.TemplateScopeValues, target, sec))
+			require.NoError(t, Execute(map[string][]byte{"foo": []byte("bar")}, nil, esapi.TemplateScopeValues, target, sec, esapi.ExternalSecretDecodeNone))
 			assert.Equal(t, "bar", sec.Annotations["foo"])
 			assert.Empty(t, sec.Labels)
 			assert.Empty(t, sec.Data)
@@ -923,7 +923,7 @@ func TestScopeKeysAndValues(t *testing.T) {
 				StringData: make(map[string]string),
 				ObjectMeta: v1.ObjectMeta{Labels: make(map[string]string), Annotations: make(map[string]string)},
 			}
-			err := Execute(row.tpl, row.data, esapi.TemplateScopeKeysAndValues, row.target, sec)
+			err := Execute(row.tpl, row.data, esapi.TemplateScopeKeysAndValues, row.target, sec, esapi.ExternalSecretDecodeNone)
 			if !ErrorContains(err, row.expErr) {
 				t.Errorf("unexpected error: %s, expected: %s", err, row.expErr)
 			}
@@ -1039,7 +1039,7 @@ func TestComplexYAMLFieldsWithSpec(t *testing.T) {
 				Data: make(map[string][]byte),
 			}
 
-			err := Execute(tt.tpl, tt.data, tt.scope, tt.target, obj)
+			err := Execute(tt.tpl, tt.data, tt.scope, tt.target, obj, esapi.ExternalSecretDecodeNone)
 
 			if tt.expErr != "" {
 				require.Error(t, err)
@@ -1285,7 +1285,7 @@ func TestConfigMapDataNotBase64Encoded(t *testing.T) {
 		"database": []byte("{{ .database }}"),
 	}
 
-	err := Execute(tplMap, data, esapi.TemplateScopeValues, "Data", configMap)
+	err := Execute(tplMap, data, esapi.TemplateScopeValues, "Data", configMap, esapi.ExternalSecretDecodeNone)
 	require.NoError(t, err)
 
 	assert.Equal(t, "localhost", configMap.Data["host"], "host should be plain text, not base64")
@@ -1629,7 +1629,7 @@ channel: {{ .new_channel }}
 				}
 			}
 
-			err := Execute(tt.tpl, tt.data, tt.scope, tt.target, obj)
+			err := Execute(tt.tpl, tt.data, tt.scope, tt.target, obj, esapi.ExternalSecretDecodeNone)
 
 			if tt.wantErr {
 				require.Error(t, err)
@@ -1735,7 +1735,7 @@ func TestNestedPathTargetingIsIdempotent(t *testing.T) {
 
 			var snapshot map[string]any
 			for i := range 3 {
-				require.NoError(t, Execute(tt.tpl, tt.data, tt.scope, tt.target, obj))
+				require.NoError(t, Execute(tt.tpl, tt.data, tt.scope, tt.target, obj, esapi.ExternalSecretDecodeNone))
 				if i == 0 {
 					snapshot = obj.DeepCopy().Object
 					continue
@@ -1749,3 +1749,89 @@ func TestNestedPathTargetingIsIdempotent(t *testing.T) {
 		})
 	}
 }
+
+func TestExecuteDecodesRenderedTemplateValues(t *testing.T) {
+	tests := []struct {
+		name             string
+		scope            esapi.TemplateScope
+		tpl              map[string][]byte
+		data             map[string][]byte
+		decodingStrategy esapi.ExternalSecretDecodingStrategy
+		wantData         map[string][]byte
+		wantErr          string
+	}{
+		{
+			name:  "keys and values scope decodes each rendered value",
+			scope: esapi.TemplateScopeKeysAndValues,
+			tpl: map[string][]byte{
+				"tpl": []byte("service-a: SGVsbG8=\nservice-b: V29ybGQ=\n"),
+			},
+			decodingStrategy: esapi.ExternalSecretDecodeBase64,
+			wantData: map[string][]byte{
+				"service-a": []byte("Hello"),
+				"service-b": []byte("World"),
+			},
+		},
+		{
+			name:  "keys are not decoded",
+			scope: esapi.TemplateScopeKeysAndValues,
+			tpl: map[string][]byte{
+				"tpl": []byte("SGVsbG8=: V29ybGQ=\n"),
+			},
+			decodingStrategy: esapi.ExternalSecretDecodeBase64,
+			wantData: map[string][]byte{
+				"SGVsbG8=": []byte("World"),
+			},
+		},
+		{
+			name:  "values scope decodes rendered value",
+			scope: esapi.TemplateScopeValues,
+			tpl: map[string][]byte{
+				"service-a": []byte("{{ .encoded }}"),
+			},
+			data: map[string][]byte{
+				"encoded": []byte("SGVsbG8="),
+			},
+			decodingStrategy: esapi.ExternalSecretDecodeBase64,
+			wantData: map[string][]byte{
+				"service-a": []byte("Hello"),
+			},
+		},
+		{
+			name:  "none keeps rendered value unchanged",
+			scope: esapi.TemplateScopeKeysAndValues,
+			tpl: map[string][]byte{
+				"tpl": []byte("service-a: SGVsbG8=\n"),
+			},
+			decodingStrategy: esapi.ExternalSecretDecodeNone,
+			wantData: map[string][]byte{
+				"service-a": []byte("SGVsbG8="),
+			},
+		},
+		{
+			name:  "base64 fails for non base64 rendered value",
+			scope: esapi.TemplateScopeKeysAndValues,
+			tpl: map[string][]byte{
+				"tpl": []byte("service-a: not-base64!\n"),
+			},
+			decodingStrategy: esapi.ExternalSecretDecodeBase64,
+			wantErr:          "failed to decode rendered template value for key service-a",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			secret := &corev1.Secret{}
+
+			err := Execute(tt.tpl, tt.data, tt.scope, esapi.TemplateTargetData, secret, tt.decodingStrategy)
+
+			if tt.wantErr != "" {
+				require.Error(t, err)
+				assert.Contains(t, err.Error(), tt.wantErr)
+				return
+			}
+			require.NoError(t, err)
+			assert.Equal(t, tt.wantData, secret.Data)
+		})
+	}
+}

+ 1 - 0
tests/__snapshot__/clusterexternalsecret-v1.yaml

@@ -96,6 +96,7 @@ spec:
               templateAs: "Values"
             name: string
           target: "Data"
+          valuesDecodingStrategy: "None"
         type: string
   namespaceSelector:
     matchExpressions:

+ 1 - 0
tests/__snapshot__/externalsecret-v1.yaml

@@ -91,6 +91,7 @@ spec:
             templateAs: "Values"
           name: string
         target: "Data"
+        valuesDecodingStrategy: "None"
       type: string
 status:
   binding:

+ 1 - 0
tests/__snapshot__/pushsecret-v1alpha1.yaml

@@ -76,6 +76,7 @@ spec:
           templateAs: "Values"
         name: string
       target: "Data"
+      valuesDecodingStrategy: "None"
     type: string
   updatePolicy: "Replace"
 status: