diff --git a/pkg/recipes/driver/bicep/bicep.go b/pkg/recipes/driver/bicep/bicep.go index 2d47e933a3e..5b730c76f15 100644 --- a/pkg/recipes/driver/bicep/bicep.go +++ b/pkg/recipes/driver/bicep/bicep.go @@ -137,8 +137,14 @@ func (d *bicepDriver) Execute(ctx context.Context, opts driver.ExecuteOptions) ( // Direct module — resolve {{context.*}} expressions, merge parameters, and wrap as ARM parameters. // Resource (developer) parameters take precedence over environment (operator) parameters. mergedParams := recipes_util.ShallowMergeParameters(opts.Definition.Parameters, opts.Recipe.Parameters) - resolvedParams := paramresolver.ResolveParameterExpressions(mergedParams, recipeContext) - parameters = wrapARMParameters(resolvedParams) + resolved, err := paramresolver.ResolveParameterExpressions(mergedParams, recipeContext) + if err != nil { + return nil, recipes.NewRecipeError(recipes.RecipeDeploymentFailed, err.Error(), recipes_util.RecipeSetupError, recipes.GetErrorDetails(err)) + } + // Secret-sourced parameters (resolved.SecureKeys) are passed to the module's @secure() parameters. + // ARM scrubs @secure() parameter values from deployment history, and Radius never logs the + // parameter map, so no additional handling is required on the Bicep path. + parameters = wrapARMParameters(resolved.Values) } deploymentName := deploymentPrefix + strconv.FormatInt(time.Now().UnixNano(), 10) diff --git a/pkg/recipes/engine/engine.go b/pkg/recipes/engine/engine.go index e981ed54e82..dd435fd12e7 100644 --- a/pkg/recipes/engine/engine.go +++ b/pkg/recipes/engine/engine.go @@ -19,6 +19,7 @@ package engine import ( "context" "fmt" + "strings" "time" "github.com/radius-project/radius/pkg/components/metrics" @@ -96,6 +97,10 @@ func (e *engine) executeCore(ctx context.Context, recipe recipes.ResourceMetadat return nil, nil, err } + // Enrich secret-typed connections with their secret material so recipe parameter expressions + // (context.resource.connections..secrets.) can resolve developer-authored secrets. + e.enrichConnectionSecrets(ctx, &recipe) + res, err := driver.Execute(ctx, recipedriver.ExecuteOptions{ BaseOptions: recipedriver.BaseOptions{ Configuration: *configuration, @@ -247,3 +252,46 @@ func (e *engine) getRecipeConfigSecrets(ctx context.Context, driver recipedriver return secretData, nil } + +// secretConnectionTypes is the set of connected-resource types whose values are sourced from the secret +// store rather than from non-secret properties. +var secretConnectionTypes = map[string]bool{ + "applications.core/secretstores": true, + "radius.security/secrets": true, +} + +// isSecretConnectionType reports whether a connected resource's type is a secret-typed resource. +func isSecretConnectionType(resourceType string) bool { + return secretConnectionTypes[strings.ToLower(resourceType)] +} + +// enrichConnectionSecrets loads secret material for secret-typed connected resources through the secret +// store and stores it on each connection's tainted Secrets field, so the parameter resolver can inject +// developer-authored secrets into module parameters. Enrichment is best-effort: if the secrets loader is +// unavailable or a secret cannot be loaded, the connection is left without secret data and any recipe +// expression that references it is left unresolved (consistent with other unresolved expressions) rather +// than failing the deployment. +func (e *engine) enrichConnectionSecrets(ctx context.Context, recipe *recipes.ResourceMetadata) { + logger := ucplog.FromContextOrDiscard(ctx) + if e.options.SecretsLoader == nil || recipe == nil { + return + } + + for name, conn := range recipe.ConnectedResourcesProperties { + if conn.ID == "" || !isSecretConnectionType(conn.Type) { + continue + } + + // A nil keys filter loads all secret keys for the secret store. + loaded, err := e.options.SecretsLoader.LoadSecrets(ctx, map[string][]string{conn.ID: nil}) + if err != nil { + logger.Info(fmt.Sprintf("skipping secret enrichment for connection %q: %s", name, err.Error())) + continue + } + + if data, ok := loaded[conn.ID]; ok { + conn.Secrets = data.Data + recipe.ConnectedResourcesProperties[name] = conn + } + } +} diff --git a/pkg/recipes/engine/engine_test.go b/pkg/recipes/engine/engine_test.go index f443b9bb751..960fdaf4a92 100644 --- a/pkg/recipes/engine/engine_test.go +++ b/pkg/recipes/engine/engine_test.go @@ -1214,3 +1214,73 @@ func Test_Engine_Delete_With_Secrets_Success(t *testing.T) { }) require.NoError(t, err) } + +func Test_enrichConnectionSecrets(t *testing.T) { + const ( + secretStoreID = "/planes/radius/local/resourceGroups/test-rg/providers/Applications.Core/secretStores/db-credentials" + dbID = "/planes/radius/local/resourceGroups/test-rg/providers/Applications.Datastores/sqlDatabases/db" + ) + + newRecipe := func() *recipes.ResourceMetadata { + return &recipes.ResourceMetadata{ + ConnectedResourcesProperties: map[string]recipes.ConnectedResource{ + "credentials": { + ID: secretStoreID, + Name: "db-credentials", + Type: "Applications.Core/secretStores", + }, + "database": { + ID: dbID, + Name: "db", + Type: "Applications.Datastores/sqlDatabases", + }, + }, + } + } + + t.Run("populates secrets for secret-typed connections only", func(t *testing.T) { + ctrl := gomock.NewController(t) + secretsLoader := configloader.NewMockSecretsLoader(ctrl) + ctx := testcontext.New(t) + e := engine{options: Options{SecretsLoader: secretsLoader}} + recipe := newRecipe() + + secretsLoader.EXPECT(). + LoadSecrets(ctx, map[string][]string{secretStoreID: nil}). + Times(1). + Return(map[string]recipes.SecretData{ + secretStoreID: {Type: "generic", Data: map[string]string{"username": "admin", "password": "s3cr3t"}}, + }, nil) + + e.enrichConnectionSecrets(ctx, recipe) + + require.Equal(t, map[string]string{"username": "admin", "password": "s3cr3t"}, recipe.ConnectedResourcesProperties["credentials"].Secrets) + require.Nil(t, recipe.ConnectedResourcesProperties["database"].Secrets) + }) + + t.Run("load error is non-fatal and leaves connection without secrets", func(t *testing.T) { + ctrl := gomock.NewController(t) + secretsLoader := configloader.NewMockSecretsLoader(ctrl) + ctx := testcontext.New(t) + e := engine{options: Options{SecretsLoader: secretsLoader}} + recipe := newRecipe() + + secretsLoader.EXPECT(). + LoadSecrets(ctx, map[string][]string{secretStoreID: nil}). + Times(1). + Return(nil, errors.New("secret store not found")) + + e.enrichConnectionSecrets(ctx, recipe) + + require.Nil(t, recipe.ConnectedResourcesProperties["credentials"].Secrets) + }) + + t.Run("nil secrets loader is a no-op", func(t *testing.T) { + ctx := testcontext.New(t) + e := engine{options: Options{}} + recipe := newRecipe() + + require.NotPanics(t, func() { e.enrichConnectionSecrets(ctx, recipe) }) + require.Nil(t, recipe.ConnectedResourcesProperties["credentials"].Secrets) + }) +} diff --git a/pkg/recipes/paramresolver/resolver.go b/pkg/recipes/paramresolver/resolver.go index 0348cd34b13..8fb634a1c09 100644 --- a/pkg/recipes/paramresolver/resolver.go +++ b/pkg/recipes/paramresolver/resolver.go @@ -35,50 +35,100 @@ var singleExpressionPattern = regexp.MustCompile(`^\s*\{\{([^}]+)\}\}\s*$`) // conditionPattern matches a single ternary condition of the form: == "value". var conditionPattern = regexp.MustCompile(`^\s*(.+?)\s*==\s*"([^"]*)"\s*$`) +// ResolvedParameters holds resolved recipe parameters together with metadata identifying which +// parameters resolved from secret material, so drivers can route those values through secure channels +// (ARM @secure parameters / Terraform sensitive variables). +type ResolvedParameters struct { + // Values holds the resolved parameter values. + Values map[string]any + // SecureKeys is the set of top-level parameter names whose value resolved from a secret expression. + SecureKeys map[string]bool +} + // ResolveParameterExpressions resolves {{context.*}} template expressions in recipe parameters. // It traverses the parameter map recursively and replaces expressions with values from the // recipe context. Unrecognized expressions are left unchanged so that misconfigurations surface // as IaC engine errors rather than being silently masked. -func ResolveParameterExpressions(params map[string]any, ctx *recipecontext.Context) map[string]any { +// +// Secret expressions (context.resource.connections..secrets.) are resolved from a separate +// secret lookup and may only be used as the entire value of a parameter; interpolating a secret into a +// surrounding string returns an error so partial cleartext cannot leak into a non-secure value. Each +// parameter whose value resolves from a secret is reported in SecureKeys. +func ResolveParameterExpressions(params map[string]any, ctx *recipecontext.Context) (ResolvedParameters, error) { if params == nil { - return nil + return ResolvedParameters{}, nil } lookup := buildContextLookup(ctx) typedLookup := buildTypedContextLookup(ctx) + secretLookup := buildSecretLookup(ctx) + result := make(map[string]any, len(params)) + secureKeys := map[string]bool{} for k, v := range params { - result[k] = resolveValue(v, lookup, typedLookup) + resolved, secure, err := resolveValue(v, lookup, typedLookup, secretLookup) + if err != nil { + return ResolvedParameters{}, fmt.Errorf("failed to resolve parameter %q: %w", k, err) + } + result[k] = resolved + if secure { + secureKeys[k] = true + } } - return result + return ResolvedParameters{Values: result, SecureKeys: secureKeys}, nil } -// resolveValue resolves template expressions in a single value. It handles strings, maps, and slices recursively. -func resolveValue(v any, lookup map[string]string, typedLookup map[string]any) any { +// resolveValue resolves template expressions in a single value. It handles strings, maps, and slices +// recursively. The returned secure flag reports whether the value (or any nested value) resolved from a +// secret expression, so the caller can tag the top-level parameter for secure routing. +func resolveValue(v any, lookup map[string]string, typedLookup map[string]any, secretLookup map[string]string) (any, bool, error) { switch val := v.(type) { case string: + // A secret may only be referenced as the entire value of a parameter. When the whole string is a + // single {{...secrets...}} expression, inject the secret value and tag the parameter secure. + if m := singleExpressionPattern.FindStringSubmatch(val); m != nil { + if secret, ok := secretLookup[strings.TrimSpace(m[1])]; ok { + return secret, true, nil + } + } // When the entire value is a single {{...}} expression that maps to a typed context value, // preserve the original scalar type so typed module parameters (int, bool, object) are passed // through correctly instead of being coerced to a string. Interpolated strings and ternary // expressions fall back to string resolution below. if typed, ok := resolveTypedExpression(val, typedLookup); ok { - return typed + return typed, false, nil + } + resolved, err := resolveString(val, lookup, secretLookup) + if err != nil { + return nil, false, err } - return resolveString(val, lookup) + return resolved, false, nil case map[string]any: resolved := make(map[string]any, len(val)) + secure := false for k, inner := range val { - resolved[k] = resolveValue(inner, lookup, typedLookup) + r, s, err := resolveValue(inner, lookup, typedLookup, secretLookup) + if err != nil { + return nil, false, err + } + resolved[k] = r + secure = secure || s } - return resolved + return resolved, secure, nil case []any: resolved := make([]any, len(val)) + secure := false for i, inner := range val { - resolved[i] = resolveValue(inner, lookup, typedLookup) + r, s, err := resolveValue(inner, lookup, typedLookup, secretLookup) + if err != nil { + return nil, false, err + } + resolved[i] = r + secure = secure || s } - return resolved + return resolved, secure, nil default: - return v + return v, false, nil } } @@ -97,11 +147,23 @@ func resolveTypedExpression(s string, typedLookup map[string]any) (any, bool) { return val, ok } -// resolveString replaces all {{...}} expressions in a string with their resolved values. -func resolveString(s string, lookup map[string]string) string { - return expressionPattern.ReplaceAllStringFunc(s, func(match string) string { +// resolveString replaces all {{...}} expressions in a string with their resolved values. It returns an +// error if a secret expression is used in interpolation (i.e. embedded in surrounding text), because the +// whole-value secret case is handled by the caller and any secret reaching this path would leak partial +// cleartext into a non-secure value. +func resolveString(s string, lookup map[string]string, secretLookup map[string]string) (string, error) { + var resolveErr error + out := expressionPattern.ReplaceAllStringFunc(s, func(match string) string { // Strip the surrounding {{ and }}. inner := match[2 : len(match)-2] + key := strings.TrimSpace(inner) + + // A secret reaching the interpolation path is embedded in surrounding text (the whole-value + // case is handled in resolveValue). Reject it so partial cleartext cannot leak. + if _, isSecret := secretLookup[key]; isSecret { + resolveErr = fmt.Errorf("secret expression %q may only be used as the entire parameter value, not interpolated into a string", key) + return match + } // Try ternary evaluation first. if result, ok := evaluateTernary(inner, lookup); ok { @@ -109,7 +171,6 @@ func resolveString(s string, lookup map[string]string) string { } // Simple context path lookup. - key := strings.TrimSpace(inner) if val, ok := lookup[key]; ok { return val } @@ -117,6 +178,10 @@ func resolveString(s string, lookup map[string]string) string { // Unrecognized expression — leave unchanged. return match }) + if resolveErr != nil { + return "", resolveErr + } + return out, nil } // evaluateTernary evaluates a ternary expression of the form: @@ -306,3 +371,22 @@ func buildTypedContextLookup(ctx *recipecontext.Context) map[string]any { return typed } + +// buildSecretLookup builds a flat key-value map of secret material exposed by secret-typed connected +// resources. Keys use the path context.resource.connections..secrets.. This lookup is kept +// separate from the non-secret context lookup so secret values can only be resolved through the +// whole-value secret path and are tagged for secure routing, never resolved as ordinary string values. +func buildSecretLookup(ctx *recipecontext.Context) map[string]string { + secrets := map[string]string{} + if ctx == nil { + return secrets + } + + for connName, conn := range ctx.Resource.Connections { + for key, val := range conn.Secrets { + secrets[fmt.Sprintf("context.resource.connections.%s.secrets.%s", connName, key)] = val + } + } + + return secrets +} diff --git a/pkg/recipes/paramresolver/resolver_test.go b/pkg/recipes/paramresolver/resolver_test.go index f85ee2cc1ac..9475afa8572 100644 --- a/pkg/recipes/paramresolver/resolver_test.go +++ b/pkg/recipes/paramresolver/resolver_test.go @@ -46,6 +46,10 @@ func testContext() *recipecontext.Context { Properties: map[string]any{ "connectionString": "postgres://myhost:5432/mydb", }, + Secrets: map[string]string{ + "username": "admin", + "password": "s3cr3t-p@ss", + }, }, }, }, @@ -303,11 +307,12 @@ func Test_ResolveParameterExpressions(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := ResolveParameterExpressions(tt.params, tt.ctx) + result, err := ResolveParameterExpressions(tt.params, tt.ctx) + require.NoError(t, err) if tt.expected == nil { - assert.Nil(t, result) + assert.Nil(t, result.Values) } else { - require.Equal(t, tt.expected, result) + require.Equal(t, tt.expected, result.Values) } }) } @@ -437,8 +442,108 @@ func Test_TernaryExpressions(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := ResolveParameterExpressions(tt.params, tt.ctx) - require.Equal(t, tt.expected, result) + result, err := ResolveParameterExpressions(tt.params, tt.ctx) + require.NoError(t, err) + require.Equal(t, tt.expected, result.Values) + }) + } +} + +func Test_SecretExpressions(t *testing.T) { + tests := []struct { + name string + params map[string]any + expectedValues map[string]any + expectedSecureKeys map[string]bool + expectErr bool + errContains string + }{ + { + name: "whole-value secret resolves and is tagged secure", + params: map[string]any{ + "administratorLogin": "{{context.resource.connections.db.secrets.username}}", + "administratorLoginPassword": "{{context.resource.connections.db.secrets.password}}", + "sku": "Standard", + }, + expectedValues: map[string]any{ + "administratorLogin": "admin", + "administratorLoginPassword": "s3cr3t-p@ss", + "sku": "Standard", + }, + expectedSecureKeys: map[string]bool{ + "administratorLogin": true, + "administratorLoginPassword": true, + }, + }, + { + name: "whole-value secret with surrounding whitespace resolves", + params: map[string]any{ + "password": " {{ context.resource.connections.db.secrets.password }} ", + }, + expectedValues: map[string]any{ + "password": "s3cr3t-p@ss", + }, + expectedSecureKeys: map[string]bool{"password": true}, + }, + { + name: "secret nested in object value tags the top-level parameter secure", + params: map[string]any{ + "config": map[string]any{ + "user": "{{context.resource.connections.db.secrets.username}}", + "host": "{{context.resource.properties.host}}", + }, + }, + expectedValues: map[string]any{ + "config": map[string]any{ + "user": "admin", + "host": "myhost.example.com", + }, + }, + expectedSecureKeys: map[string]bool{"config": true}, + }, + { + name: "non-secret parameters are not tagged secure", + params: map[string]any{ + "host": "{{context.resource.properties.host}}", + "name": "{{context.resource.name}}", + }, + expectedValues: map[string]any{ + "host": "myhost.example.com", + "name": "my-resource", + }, + expectedSecureKeys: map[string]bool{}, + }, + { + name: "secret interpolated into a surrounding string is rejected", + params: map[string]any{ + "connectionString": "user=admin;password={{context.resource.connections.db.secrets.password}};", + }, + expectErr: true, + errContains: "may only be used as the entire parameter value", + }, + { + name: "reference to a missing secret key is left unchanged", + params: map[string]any{ + "token": "{{context.resource.connections.db.secrets.missing}}", + }, + expectedValues: map[string]any{ + "token": "{{context.resource.connections.db.secrets.missing}}", + }, + expectedSecureKeys: map[string]bool{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ResolveParameterExpressions(tt.params, testContext()) + if tt.expectErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + return + } + require.NoError(t, err) + require.Equal(t, tt.expectedValues, result.Values) + require.Equal(t, tt.expectedSecureKeys, result.SecureKeys) }) } } diff --git a/pkg/recipes/terraform/config/config.go b/pkg/recipes/terraform/config/config.go index 630f487f815..3eea751df9d 100644 --- a/pkg/recipes/terraform/config/config.go +++ b/pkg/recipes/terraform/config/config.go @@ -199,6 +199,60 @@ func (cfg *TerraformConfig) AddRecipeContext(ctx context.Context, moduleName str return nil } +// SetModuleParams sets the resolved recipe parameters on the named module, routing any parameter named +// in secureKeys through a Terraform input variable marked sensitive = true. The module argument then +// references that variable (${var.}) so Terraform redacts the value from plan/apply output. +// Non-secure parameters are set inline. Nil values are skipped so a module variable's default is not +// overridden with an explicit null. +func (cfg *TerraformConfig) SetModuleParams(moduleName string, params RecipeParams, secureKeys map[string]bool) { + mod, ok := cfg.Module[moduleName] + if !ok { + return + } + + for k, v := range params { + if v == nil { + continue + } + + if secureKeys[k] { + varName := secureVariableName(moduleName, k) + if cfg.Variable == nil { + cfg.Variable = map[string]any{} + } + cfg.Variable[varName] = map[string]any{ + "type": "string", + "sensitive": true, + "default": v, + } + mod[k] = fmt.Sprintf("${var.%s}", varName) + continue + } + + mod[k] = v + } +} + +// secureVariableName builds a deterministic, valid Terraform identifier for the sensitive variable that +// backs a secret-sourced module parameter, namespaced by module and parameter name to avoid collisions. +func secureVariableName(moduleName, paramKey string) string { + return "radius_secret_" + sanitizeTerraformIdentifier(moduleName) + "_" + sanitizeTerraformIdentifier(paramKey) +} + +// sanitizeTerraformIdentifier replaces any character that is not a letter, digit, or underscore with an +// underscore so the result is a valid Terraform identifier component. +func sanitizeTerraformIdentifier(s string) string { + out := make([]rune, 0, len(s)) + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' { + out = append(out, r) + } else { + out = append(out, '_') + } + } + return string(out) +} + // newModuleConfig creates a new TFModuleConfig object with the given module source and version // and also populates RecipeParams in TF module config. If same parameter key exists across params // then the last map specified gets precedence. diff --git a/pkg/recipes/terraform/config/config_test.go b/pkg/recipes/terraform/config/config_test.go index ce5db89b223..a0c389bfa4d 100644 --- a/pkg/recipes/terraform/config/config_test.go +++ b/pkg/recipes/terraform/config/config_test.go @@ -783,6 +783,55 @@ func Test_AddMappedOutputs(t *testing.T) { } } +func Test_SetModuleParams(t *testing.T) { + envRecipe, resourceRecipe := getTestInputs() + + t.Run("routes secure params through sensitive variables and sets others inline", func(t *testing.T) { + tfconfig, err := New(context.Background(), testRecipeName, &envRecipe, &resourceRecipe) + require.NoError(t, err) + + params := RecipeParams{ + "administratorLogin": "admin", + "administratorLoginPassword": "s3cr3t-p@ss", + "sku": "Standard", + "ignoredNil": nil, + } + secureKeys := map[string]bool{"administratorLoginPassword": true} + + tfconfig.SetModuleParams(testRecipeName, params, secureKeys) + + secureVar := "radius_secret_redis_azure_administratorLoginPassword" + mod := tfconfig.Module[testRecipeName] + + // Non-secure params are set inline. + require.Equal(t, "admin", mod["administratorLogin"]) + require.Equal(t, "Standard", mod["sku"]) + // Nil params are skipped so a module variable default is not overridden with null. + _, hasNil := mod["ignoredNil"] + require.False(t, hasNil) + // The secure param references the sensitive variable instead of carrying the value inline. + require.Equal(t, "${var."+secureVar+"}", mod["administratorLoginPassword"]) + require.Equal(t, map[string]any{ + secureVar: map[string]any{ + "type": "string", + "sensitive": true, + "default": "s3cr3t-p@ss", + }, + }, tfconfig.Variable) + }) + + t.Run("unknown module name is a no-op", func(t *testing.T) { + tfconfig, err := New(context.Background(), testRecipeName, &envRecipe, &resourceRecipe) + require.NoError(t, err) + + tfconfig.SetModuleParams("does-not-exist", RecipeParams{"foo": "bar"}, nil) + + require.Nil(t, tfconfig.Variable) + _, hasFoo := tfconfig.Module[testRecipeName]["foo"] + require.False(t, hasFoo) + }) +} + func Test_AddAllOutputs(t *testing.T) { envRecipe, resourceRecipe := getTestInputs() diff --git a/pkg/recipes/terraform/config/types.go b/pkg/recipes/terraform/config/types.go index aad1a3f7cc6..fa97a9f1d07 100644 --- a/pkg/recipes/terraform/config/types.go +++ b/pkg/recipes/terraform/config/types.go @@ -86,6 +86,12 @@ type TerraformConfig struct { // https://developer.hashicorp.com/terraform/language/modules/syntax Module map[string]TFModuleConfig `json:"module"` + // Variable is the Terraform input variable configuration. Radius declares sensitive variables to + // carry secret-sourced module parameters so their values are redacted from Terraform plan/apply + // output. The value is a map keyed by variable name. + // https://developer.hashicorp.com/terraform/language/values/variables + Variable map[string]any `json:"variable,omitempty"` + // Output is the Terraform output configuration. // https://developer.hashicorp.com/terraform/language/values/outputs Output map[string]any `json:"output,omitempty"` diff --git a/pkg/recipes/terraform/execute.go b/pkg/recipes/terraform/execute.go index e14584a903a..0735dfa04aa 100644 --- a/pkg/recipes/terraform/execute.go +++ b/pkg/recipes/terraform/execute.go @@ -397,9 +397,14 @@ func (e *executor) generateConfig(ctx context.Context, tf *tfexec.Terraform, opt resourceParams = options.ResourceRecipe.Parameters } mergedParams := recipes_util.ShallowMergeParameters(options.EnvRecipe.Parameters, resourceParams) - resolvedParams := paramresolver.ResolveParameterExpressions(mergedParams, recipectx) - if resolvedParams != nil { - tfConfig.Module[options.EnvRecipe.Name].SetParams(config.RecipeParams(resolvedParams)) + resolved, err := paramresolver.ResolveParameterExpressions(mergedParams, recipectx) + if err != nil { + return "", err + } + if resolved.Values != nil { + // Secret-sourced parameters (resolved.SecureKeys) are routed through sensitive Terraform + // variables so their values are redacted from plan/apply output. + tfConfig.SetModuleParams(options.EnvRecipe.Name, config.RecipeParams(resolved.Values), resolved.SecureKeys) } } if loadedModule.ResultOutputExists { diff --git a/pkg/recipes/types.go b/pkg/recipes/types.go index f21a751696f..bac2e259259 100644 --- a/pkg/recipes/types.go +++ b/pkg/recipes/types.go @@ -34,6 +34,11 @@ type ConnectedResource struct { Type string `json:"type"` // Properties represents the resource properties Properties map[string]any `json:"properties,omitempty"` + // Secrets holds resolved secret material for secret-typed connected resources, keyed by secret key. + // It is populated only from the secret store (never from Properties) and is tagged json:"-" so it is + // never serialized into the recipe context, logs, or IaC state. Recipe authors reference it via the + // context.resource.connections..secrets. expression path. + Secrets map[string]string `json:"-"` } // Configuration represents runtime and cloud provider configuration, which is used by the driver while deploying recipes.