Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions pkg/recipes/driver/bicep/bicep.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
48 changes: 48 additions & 0 deletions pkg/recipes/engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package engine
import (
"context"
"fmt"
"strings"
"time"

"github.com/radius-project/radius/pkg/components/metrics"
Expand Down Expand Up @@ -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.<name>.secrets.<key>) can resolve developer-authored secrets.
e.enrichConnectionSecrets(ctx, &recipe)

res, err := driver.Execute(ctx, recipedriver.ExecuteOptions{
BaseOptions: recipedriver.BaseOptions{
Configuration: *configuration,
Expand Down Expand Up @@ -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
}
}
}
70 changes: 70 additions & 0 deletions pkg/recipes/engine/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
118 changes: 101 additions & 17 deletions pkg/recipes/paramresolver/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,50 +35,100 @@ var singleExpressionPattern = regexp.MustCompile(`^\s*\{\{([^}]+)\}\}\s*$`)
// conditionPattern matches a single ternary condition of the form: <context path> == "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.<name>.secrets.<key>) 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
}
}

Expand All @@ -97,26 +147,41 @@ 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 {
return result
}

// Simple context path lookup.
key := strings.TrimSpace(inner)
if val, ok := lookup[key]; ok {
return val
}

// Unrecognized expression — leave unchanged.
return match
})
if resolveErr != nil {
return "", resolveErr
}
return out, nil
}

// evaluateTernary evaluates a ternary expression of the form:
Expand Down Expand Up @@ -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.<name>.secrets.<key>. 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
}
Loading
Loading