Repository: dadrus/heimdall (go)

Issue:
Allow templating in authorizer/contextualizer values
### Preflight checklist

- [X] I agree to follow this project's [Code of Conduct](https://github.com/dadrus/heimdall/blob/main/CODE_OF_CONDUCT.md).
- [X] I have read and am following this repository's [Contribution Guidelines](https://github.com/dadrus/heimdall/blob/main/CONTRIBUTING.md)."
- [X] I have discussed this feature request with the community.

### Describe the background of your feature request

Given an authorizer connected to a service such as OpenFGA, I'm not able to make checks based on dynamic data without overriding the payload, e.g.
```yaml
      - authorizer: openfga_check
        config:
          payload: |
            {
              "user": "user:{{ .Subject.ID }}",
              "relation": "can_delete",
              "object": "file:{{ .Request.URL.Captures.uuid }}"
            }
```

### Describe your idea

Instead, I want to be able to define the authorizer as
```yaml
      - id: openfga_check
        type: remote
        config:
          endpoint:
            url: https://openfga.local
          payload: |
            {
              "user": {{ .Values.user | quote }},
              "relation": {{ .Values.relation | quote }},
              "object": {{ .Values.object | quote }}
            }
```
and then call it like
```yaml
  - authorizer: openfga_check
    config:
      values:
        user: "{{ .Subject.ID }}"
        relation: can_delete
        object: "{{ .Request.Path.Captures.uuid }}"
```

Same should apply for all other mechanisms offering a `values` configuration.
### Are there any workarounds or alternatives?

See payload-overriding variant above

### Version

dev (cda0a84)

### Additional Context

This feature request was discussed on Discord: https://discord.com/channels/1100447190796742698/1174345483683123211

Pull request description:
feat: Templating support in `remote` authorizer and `generic` contextualizer `values` property
## Related issue(s)

closes #1039

## Checklist

<!--
Remove the boxes, which are not applicable and put an `x` in the boxes that apply.
You can also fill these out after creating the PR.
-->

- [x] I agree to follow this project's [Code of Conduct](../CODE_OF_CONDUCT.md).
- [x] I have read, and I am following this repository's [Contributing Guidelines](../CONTRIBUTING.md).
- [x] I have read the [Security Policy](../SECURITY.md).
- [x] I have referenced an issue describing the bug/feature request.
- [x] I have added tests that prove the correctness of my implementation.
- [x] I have updated the documentation.

## Description

This PR adds support for `values` templating in `generic` contextualizer and `remote` authorizer to allow functionality requested in #1039. With that in place, the configuration option sketched below is now possible.

In heimdall config file:
```yaml
mechanisms:
  authorizers:
  - id: openfga_check
    type: remote
    config:
      endpoint:
        url: https://openfga.local
      payload: |
        {
          "user": {{ .Subject.ID }},
          "relation": {{ .Values.relation | quote }},
          "object": {{ .Values.object | quote }}
        }
```

In your rule set:
```yaml
rules:
- authorizer: openfga_check
  config:
    values:
      relation: can_delete
      object: "{{ index (splitList "/" .Request.URL.Path) 2 }}"
```

Patch:
diff --git a/docs/content/docs/configuration/rules/pipeline_mechanisms/authorizers.adoc b/docs/content/docs/configuration/rules/pipeline_mechanisms/authorizers.adoc
index 04bdd0bd..80feda4b 100644
--- a/docs/content/docs/configuration/rules/pipeline_mechanisms/authorizers.adoc
+++ b/docs/content/docs/configuration/rules/pipeline_mechanisms/authorizers.adoc
@@ -116,7 +116,7 @@ The API endpoint of your authorization system. At least the `url` must be config
 
 * *`payload`*: _string_ (optional, overridable)
 +
-Your link:{{< relref "overview.adoc#_templating" >}}[template] with definitions required to communicate to the authorization endpoint. The template can make use of link:{{< relref "overview.adoc#_subject" >}}[`Subject`] and link:{{< relref "overview.adoc#_request" >}}[`Request`] objects.
+Your link:{{< relref "overview.adoc#_templating" >}}[template] with definitions required to communicate to the authorization endpoint. The template can make use of link:{{< relref "overview.adoc#_values" >}}[`Values`], link:{{< relref "overview.adoc#_subject" >}}[`Subject`] and link:{{< relref "overview.adoc#_request" >}}[`Request`] objects.
 
 * *`expressions`*: _link:{{< relref "/docs/configuration/reference/types.adoc#_authorization_expression">}}[Authorization Expression] array_ (optional, overridable)
 +
@@ -134,7 +134,7 @@ Allows caching of the authorization endpoint responses. Defaults to 0s, which me
 
 * *`values`* _map of strings_ (optional, overridable)
 +
-A key value map, which is made accessible to the template rendering engine as link:{{< relref "overview.adoc#_values" >}}[`Values`] object, to render parts of the URL or the payload.
+A key value map, which is made accessible to the template rendering engine as link:{{< relref "overview.adoc#_values" >}}[`Values`] object, to render parts of the URL and/or the payload. The actual values in that map can be templated as well with access to the link:{{< relref "overview.adoc#_subject" >}}[`Subject`] and link:{{< relref "overview.adoc#_request" >}}[`Request`] objects.
 
 .Configuration of Remote authorizer to communicate with https://www.openpolicyagent.org/[Open Policy Agent] (OPA)
 ====
@@ -160,6 +160,8 @@ config:
   values:
     namespace: myapi/policy
     policy: allow_write
+    whatever: |
+     {{ .Request.Header("X-Whatever") }}
   expressions:
     - expression: |
         Payload.result == true
@@ -187,7 +189,8 @@ A specific rule could then use this authorizer in the following ways:
     config: # overriding with rule specifics
       values:
         policy: allow_read
-        whatever: foo
+        whatever: |
+          {{ .Request.Header("X-SomethingElse") }}
   - # other mechanisms
 ----
 
diff --git a/docs/content/docs/configuration/rules/pipeline_mechanisms/contextualizers.adoc b/docs/content/docs/configuration/rules/pipeline_mechanisms/contextualizers.adoc
index 7de159e9..b9976474 100644
--- a/docs/content/docs/configuration/rules/pipeline_mechanisms/contextualizers.adoc
+++ b/docs/content/docs/configuration/rules/pipeline_mechanisms/contextualizers.adoc
@@ -37,7 +37,7 @@ If the API requires any cookies from the request to heimdall, you can forward th
 
 * *`payload`*: _string_ (optional, overridable)
 +
-Your link:{{< relref "overview.adoc#_templating" >}}[template] with definitions required to communicate to the endpoint. The template can make use of link:{{< relref "overview.adoc#_subject" >}}[`Subject`] and link:{{< relref "overview.adoc#_request" >}}[`Request`] objects.
+Your link:{{< relref "overview.adoc#_templating" >}}[template] with definitions required to communicate to the endpoint. The template can make use of link:{{< relref "overview.adoc#_values" >}}[`Values`], link:{{< relref "overview.adoc#_subject" >}}[`Subject`] and link:{{< relref "overview.adoc#_request" >}}[`Request`] objects.
 
 * *`cache_ttl`*: _link:{{< relref "/docs/configuration/reference/types.adoc#_duration" >}}[Duration]_ (optional, overridable)
 +
@@ -49,7 +49,7 @@ If set to `true`, allows the pipeline to continue with the execution of the next
 
 * *`values`* _map of strings_ (optional, overridable)
 +
-A key value map, which is made accessible to the template rendering engine as link:{{< relref "overview.adoc#_values" >}}[`Values`] object to render parts of the URL or the payload.
+A key value map, which is made accessible to the template rendering engine as link:{{< relref "overview.adoc#_values" >}}[`Values`] object to render parts of the URL and/or the payload. The actual values in that map can be templated as well with access to the link:{{< relref "overview.adoc#_subject" >}}[`Subject`] and link:{{< relref "overview.adoc#_request" >}}[`Request`] objects.
 
 .Contextualizer configuration without payload
 ====
@@ -72,7 +72,7 @@ config:
 .Contextualizer configuration with payload
 ====
 
-In this example the contextualizer is configured to call an endpoint using the HTTP `POST` and send some data. Since the `values` property is not defined, but used in the payload, it must be specified in a rule making use of this contextualzer.
+In this example the contextualizer is configured to call an endpoint using the HTTP `POST` and send some data.
 
 [source, yaml]
 ----
@@ -85,6 +85,23 @@ config:
   payload: |
     {
       "user_id": {{ quote .Values.user_id }}
+      "whatever": {{ quote .Values.whatever }}
     }
 ----
+
+Since the `values` property is not defined but used in the payload, it must be specified in a rule making use of this contextualzer, e.g. in the following way:
+
+[source, yaml]
+----
+- id: rule1
+  # other rule properties
+  execute:
+  - # other mechanisms
+  - contextualizer: foo
+    config: # overriding with rule specifics
+      values:
+        user_id: "{{ .Subject.ID }}"
+        whatever: "some value"
+  - # other mechanisms
+----
 ====
diff --git a/docs/content/docs/configuration/rules/pipeline_mechanisms/overview.adoc b/docs/content/docs/configuration/rules/pipeline_mechanisms/overview.adoc
index 83461acb..e141debe 100644
--- a/docs/content/docs/configuration/rules/pipeline_mechanisms/overview.adoc
+++ b/docs/content/docs/configuration/rules/pipeline_mechanisms/overview.adoc
@@ -276,7 +276,7 @@ Payload = "SomeStringValue"
 
 === Values
 
-This object represents a key value map, with both, the key and the value being of string type. The contents depend on the configuration of the particular mechanism, respectively the corresponding override in a rule.
+This object represents a key value map, with both, the key and the value being of string type. Though, the actual values can be templated (see (link:{{< relref "#_templating" >}}[Templating]). The contents and the variables available in templates depend on the configuration of the particular mechanism, respectively the corresponding override in a rule.
 
 Here is an example:
 
diff --git a/internal/rules/mechanisms/authorizers/remote_authorizer.go b/internal/rules/mechanisms/authorizers/remote_authorizer.go
index 572d20bc..7c4ad429 100644
--- a/internal/rules/mechanisms/authorizers/remote_authorizer.go
+++ b/internal/rules/mechanisms/authorizers/remote_authorizer.go
@@ -109,8 +109,8 @@ func newRemoteAuthorizer(id string, rawConfig map[string]any) (*remoteAuthorizer
 
 	env, err := cel.NewEnv(cellib.Library())
 	if err != nil {
-		return nil, errorchain.NewWithMessage(heimdall.ErrInternal,
-			"failed creating CEL environment").CausedBy(err)
+		return nil, errorchain.NewWithMessage(heimdall.ErrInternal, "failed creating CEL environment").
+			CausedBy(err)
 	}
 
 	expressions, err := compileExpressions(conf.Expressions, env)
@@ -135,8 +135,8 @@ func (a *remoteAuthorizer) Execute(ctx heimdall.Context, sub *subject.Subject) e
 	logger.Debug().Str("_id", a.id).Msg("Authorizing using remote authorizer")
 
 	if sub == nil {
-		return errorchain.
-			NewWithMessage(heimdall.ErrInternal, "failed to execute remote authorizer due to 'nil' subject").
+		return errorchain.NewWithMessage(heimdall.ErrInternal,
+			"failed to execute remote authorizer due to 'nil' subject").
 			WithErrorContext(a)
 	}
 
@@ -150,8 +150,13 @@ func (a *remoteAuthorizer) Execute(ctx heimdall.Context, sub *subject.Subject) e
 		ok         bool
 	)
 
+	vals, payload, err := a.renderTemplates(ctx, sub)
+	if err != nil {
+		return err
+	}
+
 	if a.ttl > 0 {
-		cacheKey = a.calculateCacheKey(sub)
+		cacheKey = a.calculateCacheKey(sub, vals, payload)
 		cacheEntry = cch.Get(ctx.AppContext(), cacheKey)
 	}
 
@@ -165,7 +170,7 @@ func (a *remoteAuthorizer) Execute(ctx heimdall.Context, sub *subject.Subject) e
 	}
 
 	if authInfo == nil {
-		authInfo, err = a.doAuthorize(ctx, sub)
+		authInfo, err = a.doAuthorize(ctx, sub, vals, payload)
 		if err != nil {
 			return err
 		}
@@ -221,28 +226,48 @@ func (a *remoteAuthorizer) ID() string { return a.id }
 
 func (a *remoteAuthorizer) ContinueOnError() bool { return false }
 
-func (a *remoteAuthorizer) doAuthorize(ctx heimdall.Context, sub *subject.Subject) (*authorizationInformation, error) {
+func (a *remoteAuthorizer) doAuthorize(
+	ctx heimdall.Context,
+	sub *subject.Subject,
+	values map[string]string,
+	payload string,
+) (*authorizationInformation, error) {
 	logger := zerolog.Ctx(ctx.AppContext())
 	logger.Debug().Msg("Calling remote authorization endpoint")
 
-	req, err := a.createRequest(ctx, sub)
+	endpointRenderer := endpoint.RenderFunc(func(tplString string) (string, error) {
+		tpl, err := template.New(tplString)
+		if err != nil {
+			return "", errorchain.NewWithMessage(heimdall.ErrInternal, "failed to create template").
+				WithErrorContext(a).
+				CausedBy(err)
+		}
+
+		return tpl.Render(map[string]any{
+			"Subject": sub,
+			"Values":  values,
+		})
+	})
+
+	req, err := a.e.CreateRequest(ctx.AppContext(), strings.NewReader(payload), endpointRenderer)
 	if err != nil {
-		return nil, err
+		return nil, errorchain.NewWithMessage(heimdall.ErrInternal, "failed creating request").
+			WithErrorContext(a).
+			CausedBy(err)
 	}
 
 	resp, err := a.e.CreateClient(req.URL.Hostname()).Do(req)
 	if err != nil {
 		var clientErr *url.Error
 		if errors.As(err, &clientErr) && clientErr.Timeout() {
-			return nil, errorchain.
-				NewWithMessage(heimdall.ErrCommunicationTimeout,
-					"request to the authorization endpoint timed out").
+			return nil, errorchain.NewWithMessage(heimdall.ErrCommunicationTimeout,
+				"request to the authorization endpoint timed out").
 				WithErrorContext(a).
 				CausedBy(err)
 		}
 
-		return nil, errorchain.
-			NewWithMessage(heimdall.ErrCommunication, "request to the authorization endpoint failed").
+		return nil, errorchain.NewWithMessage(heimdall.ErrCommunication,
+			"request to the authorization endpoint failed").
 			WithErrorContext(a).
 			CausedBy(err)
 	}
@@ -262,57 +287,12 @@ func (a *remoteAuthorizer) doAuthorize(ctx heimdall.Context, sub *subject.Subjec
 	return &authorizationInformation{headers: resp.Header, payload: data}, nil
 }
 
-func (a *remoteAuthorizer) createRequest(ctx heimdall.Context, sub *subject.Subject) (*http.Request, error) {
-	var body io.Reader
-
-	if a.payload != nil {
-		bodyContents, err := a.payload.Render(map[string]any{
-			"Request": ctx.Request(),
-			"Subject": sub,
-			"Values":  a.v,
-		})
-		if err != nil {
-			return nil, errorchain.
-				NewWithMessage(heimdall.ErrInternal, "failed to render payload for the authorization endpoint").
-				WithErrorContext(a).
-				CausedBy(err)
-		}
-
-		body = strings.NewReader(bodyContents)
-	}
-
-	req, err := a.e.CreateRequest(ctx.AppContext(), body,
-		endpoint.RenderFunc(func(tplString string) (string, error) {
-			tpl, err := template.New(tplString)
-			if err != nil {
-				return "", errorchain.
-					NewWithMessage(heimdall.ErrInternal, "failed to create template").
-					WithErrorContext(a).
-					CausedBy(err)
-			}
-
-			return tpl.Render(map[string]any{
-				"Subject": sub,
-				"Values":  a.v,
-			})
-		}))
-	if err != nil {
-		return nil, errorchain.
-			NewWithMessage(heimdall.ErrInternal, "failed creating request").
-			WithErrorContext(a).
-			CausedBy(err)
-	}
-
-	return req, nil
-}
-
 func (a *remoteAuthorizer) readResponse(ctx heimdall.Context, resp *http.Response) (any, error) {
 	logger := zerolog.Ctx(ctx.AppContext())
 
 	if !(resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices) {
-		return nil, errorchain.
-			NewWithMessagef(heimdall.ErrAuthorization,
-				"authorization failed based on received response code: %v", resp.StatusCode).
+		return nil, errorchain.NewWithMessagef(heimdall.ErrAuthorization,
+			"authorization failed based on received response code: %v", resp.StatusCode).
 			WithErrorContext(a)
 	}
 
@@ -324,8 +304,7 @@ func (a *remoteAuthorizer) readResponse(ctx heimdall.Context, resp *http.Respons
 
 	rawData, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return nil, errorchain.
-			NewWithMessage(heimdall.ErrInternal, "failed to read response").
+		return nil, errorchain.NewWithMessage(heimdall.ErrInternal, "failed to read response").
 			WithErrorContext(a).
 			CausedBy(err)
 	}
@@ -342,8 +321,7 @@ func (a *remoteAuthorizer) readResponse(ctx heimdall.Context, resp *http.Respons
 
 	result, err := decoder.Decode(rawData)
 	if err != nil {
-		return nil, errorchain.
-			NewWithMessage(heimdall.ErrInternal, "failed to unmarshal response").
+		return nil, errorchain.NewWithMessage(heimdall.ErrInternal, "failed to unmarshal response").
 			WithErrorContext(a).
 			CausedBy(err)
 	}
@@ -351,7 +329,7 @@ func (a *remoteAuthorizer) readResponse(ctx heimdall.Context, resp *http.Respons
 	return result, nil
 }
 
-func (a *remoteAuthorizer) calculateCacheKey(sub *subject.Subject) string {
+func (a *remoteAuthorizer) calculateCacheKey(sub *subject.Subject, values map[string]string, payload string) string {
 	const int64BytesCount = 8
 
 	ttlBytes := make([]byte, int64BytesCount)
@@ -361,12 +339,15 @@ func (a *remoteAuthorizer) calculateCacheKey(sub *subject.Subject) string {
 	hash.Write(a.e.Hash())
 	hash.Write(stringx.ToBytes(a.id))
 	hash.Write(stringx.ToBytes(strings.Join(a.headersForUpstream, ",")))
-	hash.Write(x.IfThenElseExec(a.payload != nil,
-		func() []byte { return a.payload.Hash() },
-		func() []byte { return []byte{} }))
+	hash.Write(stringx.ToBytes(payload))
 	hash.Write(ttlBytes)
 	hash.Write(sub.Hash())
 
+	for k, v := range values {
+		hash.Write(stringx.ToBytes(k))
+		hash.Write(stringx.ToBytes(v))
+	}
+
 	return hex.EncodeToString(hash.Sum(nil))
 }
 
@@ -376,3 +357,39 @@ func (a *remoteAuthorizer) verify(ctx heimdall.Context, result any) error {
 
 	return a.expressions.eval(map[string]any{"Payload": result}, a)
 }
+
+func (a *remoteAuthorizer) renderTemplates(
+	ctx heimdall.Context,
+	sub *subject.Subject,
+) (map[string]string, string, error) {
+	var (
+		values  map[string]string
+		payload string
+		err     error
+	)
+
+	if values, err = a.v.Render(map[string]any{
+		"Request": ctx.Request(),
+		"Subject": sub,
+	}); err != nil {
+		return nil, "", errorchain.NewWithMessage(heimdall.ErrInternal,
+			"failed to render values for the authorization endpoint").
+			WithErrorContext(a).
+			CausedBy(err)
+	}
+
+	if a.payload != nil {
+		if payload, err = a.payload.Render(map[string]any{
+			"Request": ctx.Request(),
+			"Subject": sub,
+			"Values":  values,
+		}); err != nil {
+			return nil, "", errorchain.NewWithMessage(heimdall.ErrInternal,
+				"failed to render payload for the authorization endpoint").
+				WithErrorContext(a).
+				CausedBy(err)
+		}
+	}
+
+	return values, payload, nil
+}
diff --git a/internal/rules/mechanisms/contextualizers/generic_contextualizer.go b/internal/rules/mechanisms/contextualizers/generic_contextualizer.go
index 73575b1e..1d3ee717 100644
--- a/internal/rules/mechanisms/contextualizers/generic_contextualizer.go
+++ b/internal/rules/mechanisms/contextualizers/generic_contextualizer.go
@@ -131,8 +131,13 @@ func (h *genericContextualizer) Execute(ctx heimdall.Context, sub *subject.Subje
 		response   *contextualizerData
 	)
 
+	vals, payload, err := h.renderTemplates(ctx, sub)
+	if err != nil {
+		return err
+	}
+
 	if h.ttl > 0 {
-		cacheKey = h.calculateCacheKey(sub)
+		cacheKey = h.calculateCacheKey(sub, vals, payload)
 		cacheEntry = cch.Get(ctx.AppContext(), cacheKey)
 	}
 
@@ -146,7 +151,7 @@ func (h *genericContextualizer) Execute(ctx heimdall.Context, sub *subject.Subje
 	}
 
 	if response == nil {
-		response, err = h.callEndpoint(ctx, sub)
+		response, err = h.callEndpoint(ctx, sub, vals, payload)
 		if err != nil {
 			return err
 		}
@@ -202,11 +207,16 @@ func (h *genericContextualizer) ID() string { return h.id }
 
 func (h *genericContextualizer) ContinueOnError() bool { return h.continueOnError }
 
-func (h *genericContextualizer) callEndpoint(ctx heimdall.Context, sub *subject.Subject) (*contextualizerData, error) {
+func (h *genericContextualizer) callEndpoint(
+	ctx heimdall.Context,
+	sub *subject.Subject,
+	values map[string]string,
+	payload string,
+) (*contextualizerData, error) {
 	logger := zerolog.Ctx(ctx.AppContext())
 	logger.Debug().Msg("Calling contextualizer endpoint")
 
-	req, err := h.createRequest(ctx, sub)
+	req, err := h.createRequest(ctx, sub, values, payload)
 	if err != nil {
 		return nil, err
 	}
@@ -217,12 +227,14 @@ func (h *genericContextualizer) callEndpoint(ctx heimdall.Context, sub *subject.
 		if errors.As(err, &clientErr) && clientErr.Timeout() {
 			return nil, errorchain.NewWithMessage(heimdall.ErrCommunicationTimeout,
 				"request to the contextualizer endpoint timed out").
-				WithErrorContext(h).CausedBy(err)
+				WithErrorContext(h).
+				CausedBy(err)
 		}
 
 		return nil, errorchain.NewWithMessage(heimdall.ErrCommunication,
 			"request to the contextualizer endpoint failed").
-			WithErrorContext(h).CausedBy(err)
+			WithErrorContext(h).
+			CausedBy(err)
 	}
 
 	defer resp.Body.Close()
@@ -235,40 +247,29 @@ func (h *genericContextualizer) callEndpoint(ctx heimdall.Context, sub *subject.
 	return &contextualizerData{payload: data}, nil
 }
 
-func (h *genericContextualizer) createRequest(ctx heimdall.Context, sub *subject.Subject) (*http.Request, error) {
+func (h *genericContextualizer) createRequest(
+	ctx heimdall.Context,
+	sub *subject.Subject,
+	values map[string]string,
+	payload string,
+) (*http.Request, error) {
 	logger := zerolog.Ctx(ctx.AppContext())
 
-	var body io.Reader
-
-	if h.payload != nil {
-		value, err := h.payload.Render(map[string]any{
-			"Request": ctx.Request(),
-			"Subject": sub,
-			"Values":  h.v,
-		})
+	endpointRenderer := endpoint.RenderFunc(func(value string) (string, error) {
+		tpl, err := template.New(value)
 		if err != nil {
-			return nil, errorchain.NewWithMessage(heimdall.ErrInternal,
-				"failed to render payload for the contextualizer endpoint").
-				WithErrorContext(h).CausedBy(err)
+			return "", errorchain.NewWithMessage(heimdall.ErrInternal, "failed to create template").
+				WithErrorContext(h).
+				CausedBy(err)
 		}
 
-		body = strings.NewReader(value)
-	}
-
-	req, err := h.e.CreateRequest(ctx.AppContext(), body,
-		endpoint.RenderFunc(func(value string) (string, error) {
-			tpl, err := template.New(value)
-			if err != nil {
-				return "", errorchain.NewWithMessage(heimdall.ErrInternal, "failed to create template").
-					WithErrorContext(h).
-					CausedBy(err)
-			}
+		return tpl.Render(map[string]any{
+			"Subject": sub,
+			"Values":  values,
+		})
+	})
 
-			return tpl.Render(map[string]any{
-				"Subject": sub,
-				"Values":  h.v,
-			})
-		}))
+	req, err := h.e.CreateRequest(ctx.AppContext(), strings.NewReader(payload), endpointRenderer)
 	if err != nil {
 		return nil, errorchain.NewWithMessage(heimdall.ErrInternal, "failed creating request").
 			WithErrorContext(h).
@@ -302,8 +303,8 @@ func (h *genericContextualizer) readResponse(ctx heimdall.Context, resp *http.Re
 	logger := zerolog.Ctx(ctx.AppContext())
 
 	if !(resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices) {
-		return nil, errorchain.
-			NewWithMessagef(heimdall.ErrCommunication, "unexpected response code: %v", resp.StatusCode).
+		return nil, errorchain.NewWithMessagef(heimdall.ErrCommunication,
+			"unexpected response code: %v", resp.StatusCode).
 			WithErrorContext(h)
 	}
 
@@ -342,22 +343,65 @@ func (h *genericContextualizer) readResponse(ctx heimdall.Context, resp *http.Re
 	return result, nil
 }
 
-func (h *genericContextualizer) calculateCacheKey(sub *subject.Subject) string {
+func (h *genericContextualizer) calculateCacheKey(
+	sub *subject.Subject,
+	values map[string]string,
+	payload string,
+) string {
 	const int64BytesCount = 8
 
 	ttlBytes := make([]byte, int64BytesCount)
 	binary.LittleEndian.PutUint64(ttlBytes, uint64(h.ttl))
 
 	hash := sha256.New()
+	hash.Write(h.e.Hash())
 	hash.Write(stringx.ToBytes(h.id))
 	hash.Write(stringx.ToBytes(strings.Join(h.fwdHeaders, ",")))
 	hash.Write(stringx.ToBytes(strings.Join(h.fwdCookies, ",")))
-	hash.Write(x.IfThenElseExec(h.payload != nil,
-		func() []byte { return h.payload.Hash() },
-		func() []byte { return []byte{} }))
-	hash.Write(h.e.Hash())
+	hash.Write(stringx.ToBytes(payload))
 	hash.Write(ttlBytes)
 	hash.Write(sub.Hash())
 
+	for k, v := range values {
+		hash.Write(stringx.ToBytes(k))
+		hash.Write(stringx.ToBytes(v))
+	}
+
 	return hex.EncodeToString(hash.Sum(nil))
 }
+
+func (h *genericContextualizer) renderTemplates(
+	ctx heimdall.Context,
+	sub *subject.Subject,
+) (map[string]string, string, error) {
+	var (
+		values  map[string]string
+		payload string
+		err     error
+	)
+
+	if values, err = h.v.Render(map[string]any{
+		"Request": ctx.Request(),
+		"Subject": sub,
+	}); err != nil {
+		return nil, "", errorchain.NewWithMessage(heimdall.ErrInternal,
+			"failed to render values for the contextualization endpoint").
+			WithErrorContext(h).
+			CausedBy(err)
+	}
+
+	if h.payload != nil {
+		if payload, err = h.payload.Render(map[string]any{
+			"Request": ctx.Request(),
+			"Subject": sub,
+			"Values":  values,
+		}); err != nil {
+			return nil, "", errorchain.NewWithMessage(heimdall.ErrInternal,
+				"failed to render payload for the contextualization endpoint").
+				WithErrorContext(h).
+				CausedBy(err)
+		}
+	}
+
+	return values, payload, nil
+}
diff --git a/internal/rules/mechanisms/values/values.go b/internal/rules/mechanisms/values/values.go
index 27607397..6f987e3e 100644
--- a/internal/rules/mechanisms/values/values.go
+++ b/internal/rules/mechanisms/values/values.go
@@ -16,9 +16,13 @@
 
 package values
 
-import "maps"
+import (
+	"maps"
 
-type Values map[string]string
+	"github.com/dadrus/heimdall/internal/rules/mechanisms/template"
+)
+
+type Values map[string]template.Template
 
 func (v Values) Merge(other Values) Values {
 	if len(other) == 0 {
@@ -39,3 +43,18 @@ func (v Values) Merge(other Values) Values {
 
 	return res
 }
+
+func (v Values) Render(values map[string]any) (map[string]string, error) {
+	res := make(map[string]string, len(v))
+
+	for key, val := range v {
+		rendered, err := val.Render(values)
+		if err != nil {
+			return nil, err
+		}
+
+		res[key] = rendered
+	}
+
+	return res, nil
+}


Test patch:
diff --git a/internal/rules/mechanisms/authorizers/remote_authorizer_test.go b/internal/rules/mechanisms/authorizers/remote_authorizer_test.go
index de181031..e2c5bf88 100644
--- a/internal/rules/mechanisms/authorizers/remote_authorizer_test.go
+++ b/internal/rules/mechanisms/authorizers/remote_authorizer_test.go
@@ -183,7 +183,7 @@ forward_response_headers_to_upstream:
   - Bar
 cache_ttl: 5s
 values:
-  foo: bar
+  foo: "{{ .Subject.ID }}"
 `),
 			assert: func(t *testing.T, err error, auth *remoteAuthorizer) {
 				t.Helper()
@@ -216,7 +216,12 @@ values:
 				assert.Contains(t, auth.headersForUpstream, "Bar")
 				assert.NotNil(t, auth.ttl)
 				assert.Equal(t, 5*time.Second, auth.ttl)
-				assert.Equal(t, values.Values{"foo": "bar"}, auth.v)
+
+				res, err := auth.v.Render(map[string]any{
+					"Subject": &subject.Subject{ID: "bar"},
+				})
+				require.NoError(t, err)
+				assert.Equal(t, map[string]string{"foo": "bar"}, res)
 
 				assert.Equal(t, "authz", auth.ID())
 				assert.False(t, auth.ContinueOnError())
@@ -435,7 +440,10 @@ cache_ttl: 15s
 				assert.Equal(t, prototype.e.URL, configured.e.URL)
 				assert.Equal(t, prototype.e.Headers, configured.e.Headers)
 				assert.NotEqual(t, prototype.v, configured.v)
-				assert.Equal(t, values.Values{"bar": "foo", "foo": "bar"}, configured.v)
+
+				res, err := configured.v.Render(map[string]any{})
+				require.NoError(t, err)
+				assert.Equal(t, map[string]string{"bar": "foo", "foo": "bar"}, res)
 				assert.Equal(t, prototype.id, configured.id)
 				require.NotNil(t, configured.payload)
 				val, err := configured.payload.Render(nil)
@@ -542,7 +550,11 @@ func TestRemoteAuthorizerExecute(t *testing.T) {
 					URL:     srv.URL,
 					Headers: map[string]string{"Foo-Bar": "{{ .Subject.Attributes.bar }}"},
 				},
-				v: values.Values{"foo": "bar"},
+				v: func() values.Values {
+					tpl, _ := template.New("bar")
+
+					return values.Values{"foo": tpl}
+				}(),
 				payload: func() template.Template {
 					tpl, _ := template.New("{{ .Subject.ID }}-{{ .Values.foo }}")
 
@@ -684,8 +696,13 @@ func TestRemoteAuthorizerExecute(t *testing.T) {
 						"Content-Type": "application/x-www-form-urlencoded",
 					},
 				},
+				v: func() values.Values {
+					tpl, _ := template.New("foo")
+
+					return values.Values{"foo": tpl}
+				}(),
 				payload: func() template.Template {
-					tpl, _ := template.New(`user_id={{ urlenc .Subject.ID }}&{{ .Subject.Attributes.bar }}=foo`)
+					tpl, _ := template.New(`user_id={{ urlenc .Subject.ID }}&{{ .Subject.Attributes.bar }}={{ .Values.foo }}`)
 
 					return tpl
 				}(),
@@ -728,10 +745,8 @@ func TestRemoteAuthorizerExecute(t *testing.T) {
 			configureCache: func(t *testing.T, cch *mocks.CacheMock, auth *remoteAuthorizer, sub *subject.Subject) {
 				t.Helper()
 
-				cacheKey := auth.calculateCacheKey(sub)
-
-				cch.EXPECT().Get(mock.Anything, cacheKey).Return(nil)
-				cch.EXPECT().Set(mock.Anything, cacheKey,
+				cch.EXPECT().Get(mock.Anything, mock.Anything).Return(nil)
+				cch.EXPECT().Set(mock.Anything, mock.Anything,
 					mock.MatchedBy(func(val *authorizationInformation) bool {
 						return val != nil && val.payload == nil && len(val.headers.Get("X-Foo-Bar")) != 0
 					}), auth.ttl)
@@ -775,10 +790,15 @@ func TestRemoteAuthorizerExecute(t *testing.T) {
 
 				responseCode = http.StatusOK
 			},
+			configureContext: func(t *testing.T, ctx *heimdallmocks.ContextMock) {
+				t.Helper()
+
+				ctx.EXPECT().Request().Return(nil)
+			},
 			configureCache: func(t *testing.T, cch *mocks.CacheMock, auth *remoteAuthorizer, sub *subject.Subject) {
 				t.Helper()
 
-				cacheKey := auth.calculateCacheKey(sub)
+				cacheKey := auth.calculateCacheKey(sub, nil, "")
 
 				cch.EXPECT().Get(mock.Anything, cacheKey).Return(nil)
 				cch.EXPECT().Set(mock.Anything, cacheKey, mock.Anything, auth.ttl)
@@ -823,6 +843,7 @@ func TestRemoteAuthorizerExecute(t *testing.T) {
 
 				ctx.EXPECT().AddHeaderForUpstream("X-Foo-Bar", "HeyFoo")
 				ctx.EXPECT().AddHeaderForUpstream("X-Bar-Foo", "HeyBar")
+				ctx.EXPECT().Request().Return(nil)
 			},
 			configureCache: func(t *testing.T, cch *mocks.CacheMock, auth *remoteAuthorizer, sub *subject.Subject) {
 				t.Helper()
@@ -932,6 +953,11 @@ func TestRemoteAuthorizerExecute(t *testing.T) {
 
 				responseCode = http.StatusUnauthorized
 			},
+			configureContext: func(t *testing.T, ctx *heimdallmocks.ContextMock) {
+				t.Helper()
+
+				ctx.EXPECT().Request().Return(nil)
+			},
 			assert: func(t *testing.T, err error, sub *subject.Subject) {
 				t.Helper()
 
@@ -962,6 +988,11 @@ func TestRemoteAuthorizerExecute(t *testing.T) {
 				responseContentType = "text/text"
 				responseCode = http.StatusOK
 			},
+			configureContext: func(t *testing.T, ctx *heimdallmocks.ContextMock) {
+				t.Helper()
+
+				ctx.EXPECT().Request().Return(nil)
+			},
 			assert: func(t *testing.T, err error, sub *subject.Subject) {
 				t.Helper()
 
@@ -1182,6 +1213,70 @@ func TestRemoteAuthorizerExecute(t *testing.T) {
 				assert.Contains(t, authorizerAttrs["groups"], "Foo-Users")
 			},
 		},
+		{
+			uc: "with payload rendering error",
+			authorizer: &remoteAuthorizer{
+				id: "authorizer",
+				e:  endpoint.Endpoint{URL: srv.URL},
+				payload: func() template.Template {
+					tpl, err := template.New("{{ len .foo }}")
+					require.NoError(t, err)
+
+					return tpl
+				}(),
+			},
+			subject: &subject.Subject{ID: "Foo", Attributes: map[string]any{"bar": "baz"}},
+			configureContext: func(t *testing.T, ctx *heimdallmocks.ContextMock) {
+				t.Helper()
+
+				ctx.EXPECT().Request().Return(nil)
+			},
+			assert: func(t *testing.T, err error, sub *subject.Subject) {
+				t.Helper()
+
+				assert.False(t, authorizationEndpointCalled)
+
+				require.Error(t, err)
+				require.ErrorIs(t, err, heimdall.ErrInternal)
+				assert.Contains(t, err.Error(), "failed to render payload")
+
+				var identifier interface{ ID() string }
+				require.ErrorAs(t, err, &identifier)
+				assert.Equal(t, "authorizer", identifier.ID())
+			},
+		},
+		{
+			uc: "with error in values rendering",
+			authorizer: &remoteAuthorizer{
+				id: "authorizer",
+				e:  endpoint.Endpoint{URL: srv.URL},
+				v: func() values.Values {
+					tpl, err := template.New("{{ len .foo }}")
+					require.NoError(t, err)
+
+					return values.Values{"foo": tpl}
+				}(),
+			},
+			subject: &subject.Subject{ID: "Foo", Attributes: map[string]any{"bar": "baz"}},
+			configureContext: func(t *testing.T, ctx *heimdallmocks.ContextMock) {
+				t.Helper()
+
+				ctx.EXPECT().Request().Return(nil)
+			},
+			assert: func(t *testing.T, err error, sub *subject.Subject) {
+				t.Helper()
+
+				assert.False(t, authorizationEndpointCalled)
+
+				require.Error(t, err)
+				require.ErrorIs(t, err, heimdall.ErrInternal)
+				assert.Contains(t, err.Error(), "failed to render values")
+
+				var identifier interface{ ID() string }
+				require.ErrorAs(t, err, &identifier)
+				assert.Equal(t, "authorizer", identifier.ID())
+			},
+		},
 	} {
 		t.Run("case="+tc.uc, func(t *testing.T) {
 			// GIVEN
diff --git a/internal/rules/mechanisms/contextualizers/generic_contextualizer_test.go b/internal/rules/mechanisms/contextualizers/generic_contextualizer_test.go
index 433f187a..da444f83 100644
--- a/internal/rules/mechanisms/contextualizers/generic_contextualizer_test.go
+++ b/internal/rules/mechanisms/contextualizers/generic_contextualizer_test.go
@@ -125,7 +125,7 @@ forward_cookies:
 payload: "{{ .Subject.ID }}"
 cache_ttl: 5s
 values:
-  foo: bar
+  foo: "{{ .Subject.ID }}"
 continue_pipeline_on_error: true
 `),
 			assert: func(t *testing.T, err error, contextualizer *genericContextualizer) {
@@ -147,7 +147,12 @@ continue_pipeline_on_error: true
 				assert.Contains(t, contextualizer.fwdHeaders, "X-User-ID")
 				assert.Contains(t, contextualizer.fwdHeaders, "X-Foo-Bar")
 				assert.Equal(t, 5*time.Second, contextualizer.ttl)
-				assert.Equal(t, values.Values{"foo": "bar"}, contextualizer.v)
+
+				res, err := contextualizer.v.Render(map[string]any{
+					"Subject": &subject.Subject{ID: "bar"},
+				})
+				require.NoError(t, err)
+				assert.Equal(t, map[string]string{"foo": "bar"}, res)
 
 				assert.Equal(t, "contextualizer", contextualizer.ID())
 				assert.True(t, contextualizer.ContinueOnError())
@@ -434,7 +439,9 @@ continue_pipeline_on_error: false
 				assert.NotEqual(t, prototype, configured)
 				assert.Equal(t, prototype.e, configured.e)
 				assert.NotEqual(t, prototype.v, configured.v)
-				assert.Equal(t, values.Values{"bar": "foo", "foo": "bar"}, configured.v)
+				res, err := configured.v.Render(map[string]any{})
+				require.NoError(t, err)
+				assert.Equal(t, map[string]string{"bar": "foo", "foo": "bar"}, res)
 				assert.Equal(t, prototype.id, configured.id)
 				assert.NotEqual(t, prototype.payload, configured.payload)
 				require.NotNil(t, configured.payload)
@@ -554,13 +561,17 @@ func TestGenericContextualizerExecute(t *testing.T) {
 				}(),
 			},
 			subject: &subject.Subject{ID: "Foo", Attributes: map[string]any{"bar": "baz"}},
+			configureContext: func(t *testing.T, ctx *heimdallmocks.ContextMock) {
+				t.Helper()
+
+				ctx.EXPECT().Request().Return(nil)
+			},
 			configureCache: func(t *testing.T, cch *mocks.CacheMock, contextualizer *genericContextualizer,
 				sub *subject.Subject,
 			) {
 				t.Helper()
 
-				key := contextualizer.calculateCacheKey(sub)
-				cch.EXPECT().Get(mock.Anything, key).Return(&contextualizerData{payload: "Hi Foo"})
+				cch.EXPECT().Get(mock.Anything, mock.Anything).Return(&contextualizerData{payload: "Hi Foo"})
 			},
 			assert: func(t *testing.T, err error, sub *subject.Subject) {
 				t.Helper()
@@ -590,10 +601,9 @@ func TestGenericContextualizerExecute(t *testing.T) {
 			) {
 				t.Helper()
 
-				key := contextualizer.calculateCacheKey(sub)
-				cch.EXPECT().Get(mock.Anything, key).Return("Hi Foo")
-				cch.EXPECT().Delete(mock.Anything, key)
-				cch.EXPECT().Set(mock.Anything, key, mock.MatchedBy(func(val *contextualizerData) bool {
+				cch.EXPECT().Get(mock.Anything, mock.Anything).Return("Hi Foo")
+				cch.EXPECT().Delete(mock.Anything, mock.Anything)
+				cch.EXPECT().Set(mock.Anything, mock.Anything, mock.MatchedBy(func(val *contextualizerData) bool {
 					return val != nil && val.payload == "Hi from endpoint"
 				}), 5*time.Second)
 			},
@@ -619,6 +629,38 @@ func TestGenericContextualizerExecute(t *testing.T) {
 				assert.Equal(t, "Hi from endpoint", sub.Attributes["contextualizer"])
 			},
 		},
+		{
+			uc: "with error in values rendering",
+			contextualizer: &genericContextualizer{
+				id: "contextualizer1",
+				e:  endpoint.Endpoint{URL: srv.URL},
+				v: func() values.Values {
+					tpl, err := template.New("{{ len .foo }}")
+					require.NoError(t, err)
+
+					return values.Values{"foo": tpl}
+				}(),
+			},
+			subject: &subject.Subject{ID: "Foo", Attributes: map[string]any{"bar": "baz"}},
+			configureContext: func(t *testing.T, ctx *heimdallmocks.ContextMock) {
+				t.Helper()
+
+				ctx.EXPECT().Request().Return(nil)
+			},
+			assert: func(t *testing.T, err error, sub *subject.Subject) {
+				t.Helper()
+
+				assert.False(t, remoteEndpointCalled)
+
+				require.Error(t, err)
+				require.ErrorIs(t, err, heimdall.ErrInternal)
+				assert.Contains(t, err.Error(), "failed to render values")
+
+				var identifier interface{ ID() string }
+				require.ErrorAs(t, err, &identifier)
+				assert.Equal(t, "contextualizer1", identifier.ID())
+			},
+		},
 		{
 			uc: "with error in payload rendering",
 			contextualizer: &genericContextualizer{
@@ -658,6 +700,11 @@ func TestGenericContextualizerExecute(t *testing.T) {
 				e:  endpoint.Endpoint{URL: "http://heimdall.test.local"},
 			},
 			subject: &subject.Subject{ID: "Foo", Attributes: map[string]any{"bar": "baz"}},
+			configureContext: func(t *testing.T, ctx *heimdallmocks.ContextMock) {
+				t.Helper()
+
+				ctx.EXPECT().Request().Return(nil)
+			},
 			assert: func(t *testing.T, err error, sub *subject.Subject) {
 				t.Helper()
 
@@ -684,6 +731,11 @@ func TestGenericContextualizerExecute(t *testing.T) {
 
 				responseCode = http.StatusInternalServerError
 			},
+			configureContext: func(t *testing.T, ctx *heimdallmocks.ContextMock) {
+				t.Helper()
+
+				ctx.EXPECT().Request().Return(nil)
+			},
 			assert: func(t *testing.T, err error, sub *subject.Subject) {
 				t.Helper()
 
@@ -699,10 +751,17 @@ func TestGenericContextualizerExecute(t *testing.T) {
 			},
 		},
 		{
-			uc: "without payload",
+			uc: "without payload, but values, without cache hit",
 			contextualizer: &genericContextualizer{
-				id: "test-contextualizer",
-				e:  endpoint.Endpoint{URL: srv.URL + "/{{ .Subject.ID }}"},
+				id:  "test-contextualizer",
+				ttl: 5 * time.Second,
+				e:   endpoint.Endpoint{URL: srv.URL + "/{{ .Values.user_id }}"},
+				v: func() values.Values {
+					tpl, err := template.New("{{ .Subject.ID }}")
+					require.NoError(t, err)
+
+					return values.Values{"user_id": tpl}
+				}(),
 			},
 			subject: &subject.Subject{ID: "Foo", Attributes: map[string]any{"bar": "baz"}},
 			instructServer: func(t *testing.T) {
@@ -716,6 +775,19 @@ func TestGenericContextualizerExecute(t *testing.T) {
 
 				responseCode = http.StatusAccepted
 			},
+			configureContext: func(t *testing.T, ctx *heimdallmocks.ContextMock) {
+				t.Helper()
+
+				ctx.EXPECT().Request().Return(nil)
+			},
+			configureCache: func(t *testing.T, cch *mocks.CacheMock, contextualizer *genericContextualizer,
+				sub *subject.Subject,
+			) {
+				t.Helper()
+
+				cch.EXPECT().Get(mock.Anything, mock.Anything).Return(nil)
+				cch.EXPECT().Set(mock.Anything, mock.Anything, mock.Anything, mock.Anything)
+			},
 			assert: func(t *testing.T, err error, sub *subject.Subject) {
 				t.Helper()
 
@@ -739,12 +811,16 @@ func TestGenericContextualizerExecute(t *testing.T) {
 			) {
 				t.Helper()
 
-				key := contextualizer.calculateCacheKey(sub)
-				cch.EXPECT().Get(mock.Anything, key).Return(nil)
-				cch.EXPECT().Set(mock.Anything, key, mock.MatchedBy(func(val *contextualizerData) bool {
+				cch.EXPECT().Get(mock.Anything, mock.Anything).Return(nil)
+				cch.EXPECT().Set(mock.Anything, mock.Anything, mock.MatchedBy(func(val *contextualizerData) bool {
 					return val != nil && val.payload == "Hi from endpoint"
 				}), contextualizer.ttl)
 			},
+			configureContext: func(t *testing.T, ctx *heimdallmocks.ContextMock) {
+				t.Helper()
+
+				ctx.EXPECT().Request().Return(nil)
+			},
 			instructServer: func(t *testing.T) {
 				t.Helper()
 
@@ -780,7 +856,11 @@ func TestGenericContextualizerExecute(t *testing.T) {
 						"X-Bar":        "{{ .Subject.Attributes.bar }}",
 					},
 				},
-				v: values.Values{"foo": "bar"},
+				v: func() values.Values {
+					tpl, _ := template.New("bar")
+
+					return values.Values{"foo": tpl}
+				}(),
 				payload: func() template.Template {
 					tpl, _ := template.New(`
 {
diff --git a/internal/rules/mechanisms/values/values_test.go b/internal/rules/mechanisms/values/values_test.go
index a277f503..b2772335 100644
--- a/internal/rules/mechanisms/values/values_test.go
+++ b/internal/rules/mechanisms/values/values_test.go
@@ -21,6 +21,8 @@ import (
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+
+	"github.com/dadrus/heimdall/internal/rules/mechanisms/template"
 )
 
 func TestValuesMerge(t *testing.T) {
@@ -41,20 +43,38 @@ func TestValuesMerge(t *testing.T) {
 			},
 		},
 		{
-			uc:  "original is nil, new is not",
-			tbm: Values{"foo": "bar", "baz": "zab"},
+			uc: "original is nil, new is not",
+			tbm: func() Values {
+				bar, _ := template.New("bar")
+				zab, _ := template.New("zab")
+
+				return Values{"foo": bar, "baz": zab}
+			}(),
 			assert: func(t *testing.T, merged Values, orig Values) {
 				t.Helper()
 
 				require.Nil(t, orig)
 				require.NotEmpty(t, merged)
-				assert.Equal(t, "bar", merged["foo"])
-				assert.Equal(t, "zab", merged["baz"])
+
+				tpl := merged["foo"]
+				require.NotNil(t, tpl)
+				val, _ := tpl.Render(map[string]any{})
+				assert.Equal(t, "bar", val)
+
+				tpl = merged["baz"]
+				require.NotNil(t, tpl)
+				val, _ = tpl.Render(map[string]any{})
+				assert.Equal(t, "zab", val)
 			},
 		},
 		{
-			uc:   "original is not nil, new is nil",
-			orig: Values{"foo": "bar", "baz": "zab"},
+			uc: "original is not nil, new is nil",
+			orig: func() Values {
+				bar, _ := template.New("bar")
+				zab, _ := template.New("zab")
+
+				return Values{"foo": bar, "baz": zab}
+			}(),
 			assert: func(t *testing.T, merged Values, orig Values) {
 				t.Helper()
 
@@ -64,9 +84,17 @@ func TestValuesMerge(t *testing.T) {
 			},
 		},
 		{
-			uc:   "original is not nil, new is not nil",
-			orig: Values{"foo": "bar"},
-			tbm:  Values{"baz": "zab"},
+			uc: "original is not nil, new is not nil",
+			orig: func() Values {
+				bar, _ := template.New("bar")
+
+				return Values{"foo": bar}
+			}(),
+			tbm: func() Values {
+				zab, _ := template.New("zab")
+
+				return Values{"baz": zab}
+			}(),
 			assert: func(t *testing.T, merged Values, orig Values) {
 				t.Helper()
 
@@ -74,8 +102,16 @@ func TestValuesMerge(t *testing.T) {
 				assert.NotEqual(t, orig, merged)
 				assert.NotSame(t, orig, merged)
 				assert.Len(t, merged, 2)
-				assert.Equal(t, "bar", merged["foo"])
-				assert.Equal(t, "zab", merged["baz"])
+
+				tpl := merged["foo"]
+				require.NotNil(t, tpl)
+				val, _ := tpl.Render(map[string]any{})
+				assert.Equal(t, "bar", val)
+
+				tpl = merged["baz"]
+				require.NotNil(t, tpl)
+				val, _ = tpl.Render(map[string]any{})
+				assert.Equal(t, "zab", val)
 			},
 		},
 	} {
@@ -88,3 +124,40 @@ func TestValuesMerge(t *testing.T) {
 		})
 	}
 }
+
+func TestValuesRender(t *testing.T) {
+	t.Parallel()
+
+	badTpl, _ := template.New("{{ .Foo.Bar }}")
+	fooTpl, _ := template.New("{{ .Foo }}")
+	barTpl, _ := template.New("{{ .Bar }}")
+
+	for _, tc := range []struct {
+		uc     string
+		values Values
+		expErr bool
+		expRes map[string]string
+	}{
+		{
+			uc:     "render fails",
+			values: Values{"foo": badTpl},
+			expErr: true,
+		},
+		{
+			uc:     "render succeeds",
+			values: Values{"foo": fooTpl, "bar": barTpl},
+			expErr: false,
+			expRes: map[string]string{"foo": "foo", "bar": "bar"},
+		},
+	} {
+		t.Run(tc.uc, func(t *testing.T) {
+			res, err := tc.values.Render(map[string]any{"Foo": "foo", "Bar": "bar"})
+
+			if tc.expErr {
+				require.Error(t, err)
+			} else {
+				assert.Equal(t, tc.expRes, res)
+			}
+		})
+	}
+}