Repository: dadrus/heimdall (go)

Issue:
Add map contextualizer
### 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

I'm currently in a situation where I have duplicated a lot of common logic across multiple RuleSets from different backend services. 

This leads to inconsistencies in writing style, copy-paste mistakes, etc, leading to potential security errors. 

### Describe your idea

In order to move commonly used configurations (e.g. extraction or unification of values from differend authenticators, substring-operations) to the configuration, I propose the introduction of a new contextualizer called `map`, which allows the user to define variables as its output from local expressions.

Expressions should be able to be written either in CEL or golang templates. 

A configuration could look like this:

```yaml
mechanisms:
  contextualizers:
  - id: common_props
    type: map
    config:
      tenant: # insert go template or cel here
```

Then lateron in other rules, the values could be used as `Outputs.common_props.tenant` - which is a lot more readable.

### Are there any workarounds or alternatives?

Right now the workaround is to generate the common configuration in the RuleSets by helm templates, which is also error prone.

### Version

0.15.0

### Additional Context

_No response_

Pull request:
feat: New `map` contextualizer
## Related issue(s)

Fixes #2531 

## Checklist

- [x] I agree to follow this project's [Code of Conduct](../blob/main/CODE_OF_CONDUCT.md).
- [x] I have read, and I am following this repository's [Contributing Guidelines](../blob/main/CONTRIBUTING.md).
- [x] I have read the [Security Policy](../blob/main/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

As discussed in #2531 and discord, I'd like to propose the addition of a local contextualizer. The implementation here uses go templates and allows the user to migrate commonly used items out of the rules into the static configuration, thus minimizing user mistakes and duplication.

Patch:
diff --git a/docs/content/docs/mechanisms/contextualizers.adoc b/docs/content/docs/mechanisms/contextualizers.adoc
index 2b9bf549..5422df06 100644
--- a/docs/content/docs/mechanisms/contextualizers.adoc
+++ b/docs/content/docs/mechanisms/contextualizers.adoc
@@ -103,3 +103,68 @@ Since the `values` property is not defined but used in the payload, it must be s
   - # other mechanisms
 ----
 ====
+
+== Map
+
+This mechanism allows you to define (templated) variables which can be used in contextualizers/authorizers/finalizers afterwards to extract common logic out of Rulesets.
+
+To enable the usage of this contextualizer, you have to set the `type` property to `map`.
+
+Configuration using the `config` property is mandatory. Following properties are available:
+
+* *`items`*: _map of strings_ (mandatory, not overridable)
++
+Every entry in the items map is a map of templates. Every key will be present in the link:{{< relref "/docs/mechanisms/evaluation_objects.adoc#_outputs" >}}[`Outputs`] object.
+
+* *`continue_pipeline_on_error`*: _boolean_ (optional, overridable)
++
+If set to `true`, allows the pipeline to continue with the execution of the next mechanisms. So the error, if thrown, is ignored. Defaults to `false`, which means the execution of the authentication & authorization pipeline is stopped and the execution of the error pipeline is started.
+
+* *`values`* _map of strings_ (optional, overridable)
++
+A key value map, which is made accessible to the template rendering engine as link:{{< relref "/docs/mechanisms/evaluation_objects.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 "/docs/mechanisms/evaluation_objects.adoc#_subject" >}}[`Subject`], the link:{{< relref "/docs/mechanisms/evaluation_objects.adoc#_outputs" >}}[`Outputs`] and link:{{< relref "/docs/mechanisms/evaluation_objects.adoc#_request" >}}[`Request`] objects.
+
+.Simple contextualizer configuration
+====
+
+In this example the contextualizer is configured to template a single variable named 'foo'.
+
+[source, yaml]
+----
+id: foo
+type: map
+config:
+  items:
+    foo: "https://some-other.service/users/{{.Subject.ID}}"
+----
+====
+
+.Contextualizer configuration with values
+====
+
+In this example the contextualizer is configured to template a single variable named foo, but contains a reference to local values.
+
+[source, yaml]
+----
+id: foo
+type: map
+config:
+  items:
+    foo: "https://some-other.service/users/{{.Values.user_id}}"
+----
+
+Since the `values` property is not defined but used in items, it must be specified in a rule making use of this contextualizer, 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 }}"
+  - # other mechanisms
+----
+====
diff --git a/internal/rules/mechanisms/contextualizers/constants.go b/internal/rules/mechanisms/contextualizers/constants.go
index 2adb3c41..22d02fa6 100644
--- a/internal/rules/mechanisms/contextualizers/constants.go
+++ b/internal/rules/mechanisms/contextualizers/constants.go
@@ -18,4 +18,5 @@ package contextualizers
 
 const (
 	ContextualizerGeneric = "generic"
+	ContextualizerMap     = "map"
 )
diff --git a/internal/rules/mechanisms/contextualizers/map_contextualizer.go b/internal/rules/mechanisms/contextualizers/map_contextualizer.go
new file mode 100644
index 00000000..92dbd498
--- /dev/null
+++ b/internal/rules/mechanisms/contextualizers/map_contextualizer.go
@@ -0,0 +1,159 @@
+package contextualizers
+
+import (
+	"fmt"
+
+	"github.com/dadrus/heimdall/internal/app"
+	"github.com/dadrus/heimdall/internal/heimdall"
+	"github.com/dadrus/heimdall/internal/rules/mechanisms/subject"
+	"github.com/dadrus/heimdall/internal/rules/mechanisms/template"
+	"github.com/dadrus/heimdall/internal/rules/mechanisms/values"
+	"github.com/dadrus/heimdall/internal/x"
+	"github.com/dadrus/heimdall/internal/x/errorchain"
+)
+
+// by intention. Used only during application bootstrap
+//
+//nolint:gochecknoinits
+func init() {
+	registerTypeFactory(
+		func(app app.Context, id string, typ string, conf map[string]any) (bool, Contextualizer, error) {
+			if typ != ContextualizerMap {
+				return false, nil, nil
+			}
+
+			eh, err := newMapContextualizer(app, id, conf)
+
+			return true, eh, err
+		})
+}
+
+func newMapContextualizer(app app.Context, id string, rawConfig map[string]any) (*mapContextualizer, error) {
+	logger := app.Logger()
+	logger.Info().Str("_id", id).Msg("Creating map contextualizer")
+
+	type Config struct {
+		Items           map[string]template.Template `mapstructure:"items"`
+		Values          values.Values                `mapstructure:"values"`
+		ContinueOnError *bool                        `mapstructure:"continue_pipeline_on_error"`
+	}
+
+	var conf Config
+	if err := decodeConfig(app, rawConfig, &conf); err != nil {
+		return nil, errorchain.NewWithMessagef(heimdall.ErrConfiguration,
+			"failed decoding config for map contextualizer '%s'", id).CausedBy(err)
+	}
+
+	return &mapContextualizer{
+		id:              id,
+		app:             app,
+		items:           conf.Items,
+		values:          conf.Values,
+		continueOnError: conf.ContinueOnError != nil && *conf.ContinueOnError,
+	}, nil
+}
+
+type mapContextualizer struct {
+	id              string
+	app             app.Context
+	items           map[string]template.Template
+	values          values.Values
+	continueOnError bool
+}
+
+func (m *mapContextualizer) ContinueOnError() bool {
+	return m.continueOnError
+}
+
+func (m *mapContextualizer) Execute(ctx heimdall.RequestContext, sub *subject.Subject) error {
+	if sub == nil {
+		return errorchain.NewWithMessage(heimdall.ErrInternal,
+			"failed to execute map contextualizer due to 'nil' subject").
+			WithErrorContext(m)
+	}
+
+	resp, err := m.renderTemplates(ctx, sub)
+	if err != nil {
+		return errorchain.NewWithMessage(heimdall.ErrInternal,
+			"failed to render templates for the map contextualizer").WithErrorContext(m).CausedBy(err)
+	}
+
+	if resp != nil {
+		ctx.Outputs()[m.id] = resp
+	}
+
+	return nil
+}
+
+func (m *mapContextualizer) ID() string {
+	return m.id
+}
+
+func (m *mapContextualizer) WithConfig(rawConfig map[string]any) (Contextualizer, error) {
+	if len(rawConfig) == 0 {
+		return m, nil
+	}
+
+	type Config struct {
+		Values          values.Values `mapstructure:"values"`
+		ContinueOnError *bool         `mapstructure:"continue_pipeline_on_error"`
+	}
+
+	var conf Config
+	if err := decodeConfig(m.app, rawConfig, &conf); err != nil {
+		return nil, errorchain.NewWithMessagef(heimdall.ErrConfiguration,
+			"failed decoding config for map contextualizer '%s'", m.id).CausedBy(err)
+	}
+
+	return &mapContextualizer{
+		id:     m.id,
+		app:    m.app,
+		items:  m.items,
+		values: m.values.Merge(conf.Values),
+		continueOnError: x.IfThenElseExec(conf.ContinueOnError != nil,
+			func() bool { return *conf.ContinueOnError },
+			func() bool { return m.continueOnError }),
+	}, nil
+}
+
+func (m *mapContextualizer) renderTemplates(
+	ctx heimdall.RequestContext,
+	sub *subject.Subject,
+) (map[string]string, error) {
+	var (
+		values   map[string]string
+		rendered string
+		err      error
+	)
+
+	if values, err = m.values.Render(map[string]any{
+		"Request": ctx.Request(),
+		"Subject": sub,
+		"Outputs": ctx.Outputs(),
+	}); err != nil {
+		return nil, errorchain.NewWithMessage(heimdall.ErrInternal,
+			"failed to render values for the map contextualizer").
+			WithErrorContext(m).
+			CausedBy(err)
+	}
+
+	resp := make(map[string]string, len(m.items))
+
+	for key, tmpl := range m.items {
+		if rendered, err = tmpl.Render(map[string]any{
+			"Request": ctx.Request(),
+			"Subject": sub,
+			"Values":  values,
+			"Outputs": ctx.Outputs(),
+		}); err != nil {
+			return nil, errorchain.NewWithMessage(heimdall.ErrInternal,
+				fmt.Sprintf("failed to render item %s for the map contextualizer", key)).
+				WithErrorContext(m).
+				CausedBy(err)
+		}
+
+		resp[key] = rendered
+	}
+
+	return resp, nil
+}


Test patch:
diff --git a/internal/rules/mechanisms/contextualizers/contextualizer_type_registry_test.go b/internal/rules/mechanisms/contextualizers/contextualizer_type_registry_test.go
index 406db5c5..e4a20640 100644
--- a/internal/rules/mechanisms/contextualizers/contextualizer_type_registry_test.go
+++ b/internal/rules/mechanisms/contextualizers/contextualizer_type_registry_test.go
@@ -30,8 +30,8 @@ import (
 func TestCreateContextualzerPrototype(t *testing.T) {
 	t.Parallel()
 
-	// there are 3 error handlers implemented, which should have been registered
-	require.Len(t, typeFactories, 1)
+	// there are 2 contextualizers implemented, which should have been registered
+	require.Len(t, typeFactories, 2)
 
 	for uc, tc := range map[string]struct {
 		typ    string
diff --git a/internal/rules/mechanisms/contextualizers/map_contextualizer_test.go b/internal/rules/mechanisms/contextualizers/map_contextualizer_test.go
new file mode 100644
index 00000000..63cd820f
--- /dev/null
+++ b/internal/rules/mechanisms/contextualizers/map_contextualizer_test.go
@@ -0,0 +1,423 @@
+// Copyright 2025 Martin Koppehel <martin@mko.dev>
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package contextualizers
+
+import (
+	"testing"
+
+	"github.com/rs/zerolog/log"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/dadrus/heimdall/internal/app"
+	"github.com/dadrus/heimdall/internal/config"
+	"github.com/dadrus/heimdall/internal/heimdall"
+	heimdallmocks "github.com/dadrus/heimdall/internal/heimdall/mocks"
+	"github.com/dadrus/heimdall/internal/rules/mechanisms/subject"
+	"github.com/dadrus/heimdall/internal/rules/mechanisms/template"
+	"github.com/dadrus/heimdall/internal/rules/mechanisms/values"
+	"github.com/dadrus/heimdall/internal/validation"
+	"github.com/dadrus/heimdall/internal/x"
+	"github.com/dadrus/heimdall/internal/x/testsupport"
+)
+
+func TestCreateMapContextualizer(t *testing.T) {
+	t.Parallel()
+
+	for uc, tc := range map[string]struct {
+		enforceTLS bool
+		config     []byte
+		assert     func(t *testing.T, err error, contextualizer *mapContextualizer)
+	}{
+		"with unsupported fields": {
+			config: []byte(`
+values:
+  url: http://foo.bar
+foo: bar
+`),
+			assert: func(t *testing.T, err error, _ *mapContextualizer) {
+				t.Helper()
+
+				require.Error(t, err)
+				require.ErrorIs(t, err, heimdall.ErrConfiguration)
+				assert.Contains(t, err.Error(), "failed decoding")
+			},
+		},
+		"with invalid configuration": {
+			config: []byte(`
+items:
+  method:
+    foo: bar
+values: 
+  foo: bar
+`),
+			assert: func(t *testing.T, err error, _ *mapContextualizer) {
+				t.Helper()
+
+				require.Error(t, err)
+				require.ErrorIs(t, err, heimdall.ErrConfiguration)
+				assert.Contains(t, err.Error(), "failed decoding")
+			},
+		},
+		"with minimal valid configuration": {
+			enforceTLS: true,
+			config: []byte(`
+items:
+  url: "{{ .Values.foo }}"
+values: 
+  foo: http://foo.bar
+`),
+			assert: func(t *testing.T, err error, contextualizer *mapContextualizer) {
+				t.Helper()
+
+				require.NoError(t, err)
+				require.NotNil(t, contextualizer)
+
+				assert.Len(t, contextualizer.items, 1)
+				assert.Len(t, contextualizer.values, 1)
+				assert.Equal(t, "http://foo.bar", contextualizer.values["foo"].String())
+				assert.Equal(t, "{{ .Values.foo }}", contextualizer.items["url"].String())
+
+				values, err := contextualizer.values.Render(map[string]any{})
+				require.NoError(t, err)
+				assert.Equal(t, map[string]string{"foo": "http://foo.bar"}, values)
+
+				val, err := contextualizer.items["url"].Render(map[string]any{
+					"Values":  values,
+					"Subject": &subject.Subject{ID: "baz"},
+				})
+				require.NoError(t, err)
+				assert.Equal(t, "http://foo.bar", val)
+			},
+		},
+	} {
+		t.Run(uc, func(t *testing.T) {
+			conf, err := testsupport.DecodeTestConfig(tc.config)
+			require.NoError(t, err)
+
+			es := config.EnforcementSettings{EnforceEgressTLS: tc.enforceTLS}
+			validator, err := validation.NewValidator(
+				validation.WithTagValidator(es),
+				validation.WithErrorTranslator(es),
+			)
+			require.NoError(t, err)
+
+			appCtx := app.NewContextMock(t)
+			appCtx.EXPECT().Validator().Maybe().Return(validator)
+			appCtx.EXPECT().Logger().Return(log.Logger)
+
+			// WHEN
+			contextualizer, err := newMapContextualizer(appCtx, "contextualizer", conf)
+
+			// THEN
+			tc.assert(t, err, contextualizer)
+		})
+	}
+}
+
+func TestCreateMapContextualizerFromPrototype(t *testing.T) {
+	t.Parallel()
+
+	for uc, tc := range map[string]struct {
+		prototypeConfig []byte
+		config          []byte
+		assert          func(t *testing.T, err error, prototype *mapContextualizer, configured *mapContextualizer)
+	}{
+		"with empty config": {
+			prototypeConfig: []byte(`
+items:
+  url: "{{ .Values.foo }}"
+values: 
+  foo: http://foo.bar
+`),
+			assert: func(t *testing.T, err error, prototype *mapContextualizer, configured *mapContextualizer) {
+				t.Helper()
+
+				require.NoError(t, err)
+
+				assert.Equal(t, prototype, configured)
+				assert.Equal(t, "contextualizer", configured.ID())
+			},
+		},
+		"with unsupported fields": {
+			prototypeConfig: []byte(`
+items:
+  url: "{{ .Values.foo }}"
+values: 
+  foo: http://foo.bar
+`),
+			config: []byte(`foo: bar`),
+			assert: func(t *testing.T, err error, _ *mapContextualizer, _ *mapContextualizer) {
+				t.Helper()
+
+				require.Error(t, err)
+				require.ErrorIs(t, err, heimdall.ErrConfiguration)
+				assert.Contains(t, err.Error(), "failed decoding")
+			},
+		},
+		"with only values reconfigured": {
+			prototypeConfig: []byte(`
+items:
+  url: "{{ .Values.foo }}"
+values: 
+  foo: http://foo.bar
+`),
+			config: []byte(`
+values:
+  foo: http://bar.foo
+`),
+			assert: func(t *testing.T, err error, prototype *mapContextualizer, configured *mapContextualizer) {
+				t.Helper()
+
+				require.NoError(t, err)
+
+				assert.NotEqual(t, prototype, configured)
+				assert.Equal(t, prototype.items, configured.items)
+				assert.Equal(t, prototype.id, configured.id)
+				assert.NotEqual(t, prototype.values, configured.values)
+				require.NotNil(t, configured.values)
+				val, err := configured.values.Render(map[string]any{
+					"Subject": &subject.Subject{ID: "baz"},
+				})
+				require.NoError(t, err)
+				resp, err := configured.items["url"].Render(map[string]any{
+					"Values":  val,
+					"Subject": &subject.Subject{ID: "baz"},
+				})
+				require.NoError(t, err)
+				assert.Equal(t, "http://bar.foo", resp)
+				assert.Equal(t, "contextualizer", configured.ID())
+				assert.False(t, prototype.ContinueOnError())
+				assert.False(t, configured.ContinueOnError())
+			},
+		},
+		"with continueOnError reconfigured": {
+			prototypeConfig: []byte(`
+items:
+  url: "{{ .Values.foo }}"
+values: 
+  foo: http://foo.bar
+`),
+			config: []byte(`
+continue_pipeline_on_error: true
+`),
+			assert: func(t *testing.T, err error, prototype *mapContextualizer, configured *mapContextualizer) {
+				t.Helper()
+
+				require.NoError(t, err)
+
+				assert.NotEqual(t, prototype, configured)
+				assert.Equal(t, prototype.items, configured.items)
+				assert.Equal(t, prototype.id, configured.id)
+				assert.NotEqual(t, prototype.continueOnError, configured.continueOnError)
+				assert.Equal(t, "contextualizer", configured.ID())
+				assert.False(t, prototype.ContinueOnError())
+				assert.True(t, configured.ContinueOnError())
+			},
+		},
+	} {
+		t.Run(uc, func(t *testing.T) {
+			pc, err := testsupport.DecodeTestConfig(tc.prototypeConfig)
+			require.NoError(t, err)
+
+			conf, err := testsupport.DecodeTestConfig(tc.config)
+			require.NoError(t, err)
+
+			validator, err := validation.NewValidator(
+				validation.WithTagValidator(config.EnforcementSettings{}),
+			)
+			require.NoError(t, err)
+
+			appCtx := app.NewContextMock(t)
+			appCtx.EXPECT().Validator().Return(validator)
+			appCtx.EXPECT().Logger().Return(log.Logger)
+
+			prototype, err := newMapContextualizer(appCtx, "contextualizer", pc)
+			require.NoError(t, err)
+
+			// WHEN
+			concrete, err := prototype.WithConfig(conf)
+
+			// THEN
+			var (
+				locContextualizer *mapContextualizer
+				ok                bool
+			)
+
+			if err == nil {
+				locContextualizer, ok = concrete.(*mapContextualizer)
+				require.True(t, ok)
+			}
+
+			tc.assert(t, err, prototype, locContextualizer)
+		})
+	}
+}
+
+func TestMapContextualizerExecute(t *testing.T) {
+	t.Parallel()
+
+	for uc, tc := range map[string]struct {
+		contextualizer   *mapContextualizer
+		subject          *subject.Subject
+		configureContext func(t *testing.T, ctx *heimdallmocks.RequestContextMock)
+		assert           func(t *testing.T, err error, sub *subject.Subject, outputs map[string]any)
+	}{
+		"fails due to nil subject": {
+			contextualizer: &mapContextualizer{id: "contextualizer"},
+			assert: func(t *testing.T, err error, _ *subject.Subject, _ map[string]any) {
+				t.Helper()
+
+				require.Error(t, err)
+				require.ErrorIs(t, err, heimdall.ErrInternal)
+				assert.Contains(t, err.Error(), "'nil' subject")
+
+				var identifier interface{ ID() string }
+				require.ErrorAs(t, err, &identifier)
+				assert.Equal(t, "contextualizer", identifier.ID())
+			},
+		},
+		"with error in values rendering": {
+			contextualizer: &mapContextualizer{
+				id: "contextualizer1",
+				values: 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.RequestContextMock) {
+				t.Helper()
+
+				ctx.EXPECT().Request().Return(nil)
+			},
+			assert: func(t *testing.T, err error, _ *subject.Subject, _ map[string]any) {
+				t.Helper()
+
+				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())
+			},
+		},
+		"with error in item rendering": {
+			contextualizer: &mapContextualizer{
+				id: "contextualizer1",
+				values: func() values.Values {
+					tpl, err := template.New("http://foo.bar")
+					require.NoError(t, err)
+
+					return values.Values{"foo": tpl}
+				}(),
+				items: map[string]template.Template{
+					"url": 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.RequestContextMock) {
+				t.Helper()
+
+				ctx.EXPECT().Request().Return(nil)
+			},
+			assert: func(t *testing.T, err error, _ *subject.Subject, _ map[string]any) {
+				t.Helper()
+
+				require.Error(t, err)
+				require.ErrorIs(t, err, heimdall.ErrInternal)
+				assert.Contains(t, err.Error(), "failed to render item")
+
+				var identifier interface{ ID() string }
+				require.ErrorAs(t, err, &identifier)
+				assert.Equal(t, "contextualizer1", identifier.ID())
+			},
+		},
+		"no error": {
+			contextualizer: &mapContextualizer{
+				id: "contextualizer1",
+				values: func() values.Values {
+					tpl, err := template.New("http://foo.bar")
+					require.NoError(t, err)
+
+					return values.Values{"url": tpl}
+				}(),
+				items: map[string]template.Template{
+					"urlValues": func() template.Template {
+						tpl, err := template.New("{{ .Values.url }}")
+						require.NoError(t, err)
+
+						return tpl
+					}(),
+					"subject": func() template.Template {
+						tpl, err := template.New("{{ .Subject.ID }}")
+						require.NoError(t, err)
+
+						return tpl
+					}(),
+					"outputs": func() template.Template {
+						tpl, err := template.New("{{ .Outputs.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.RequestContextMock) {
+				t.Helper()
+
+				ctx.EXPECT().Request().Return(nil)
+			},
+			assert: func(t *testing.T, err error, _ *subject.Subject, outputs map[string]any) {
+				t.Helper()
+
+				require.NoError(t, err)
+
+				assert.NotNil(t, outputs["contextualizer1"])
+				assert.Equal(t, "http://foo.bar", outputs["contextualizer1"].(map[string]string)["urlValues"])
+				assert.Equal(t, "Foo", outputs["contextualizer1"].(map[string]string)["subject"])
+				assert.Equal(t, "bar", outputs["contextualizer1"].(map[string]string)["outputs"])
+			},
+		},
+	} {
+		t.Run(uc, func(t *testing.T) {
+			configureContext := x.IfThenElse(tc.configureContext != nil,
+				tc.configureContext,
+				func(t *testing.T, _ *heimdallmocks.RequestContextMock) { t.Helper() })
+
+			ctx := heimdallmocks.NewRequestContextMock(t)
+			ctx.EXPECT().Outputs().Return(map[string]any{"foo": "bar"})
+
+			configureContext(t, ctx)
+
+			// WHEN
+			err := tc.contextualizer.Execute(ctx, tc.subject)
+
+			// THEN
+			tc.assert(t, err, tc.subject, ctx.Outputs())
+		})
+	}
+}