Skip to content

Direct-module recipes: securely flow developer secret values into module parameters #12244

Closed as not planned

Description

@willdavsmith

Overview of feature request

The direct-module recipe feature (radius-project/radius#11876) lets a platform engineer point a Recipe at an unmodified IaC module (Azure Verified Module, Terraform module) and map a developer resource onto its parameters with {{context.*}} expressions. This works for non-secret properties, but there is no way to flow developer-authored secret material into a module parameter.

Cloud provisioning modules require the literal secret value at deploy time:

  • AVM avm/res/db-for-postgre-sql/flexible-server → administratorLoginPassword (@secure() string)
  • terraform-aws-modules/rds/aws → password (sensitive variable)

But context.* exposes only non-secret data (context.resource.properties.*, context.resource.connections.<name>.properties.*). A recipe author has no expression that resolves to the value of a Radius.Security/secrets resource the developer referenced. So today the recipe cannot satisfy the module's secret input.

Concrete gap

Radius.Data/postgreSqlDatabases lets a developer wire credentials via a secret:

// app.bicep (developer)
resource dbsecret 'Radius.Security/secrets@...' = {
  name: 'db-credentials'
  properties: { data: { USERNAME: {...}, PASSWORD: {...} } }
}
resource pg 'Radius.Data/postgreSqlDatabases@...' = {
  properties: {
    environment: env
    size: 'S'
    database: 'appdb'
    secretName: dbsecret.name   // <-- developer's intended credential
  }
}

The recipe author wants to write, in platform.bicep:

parameters: {
  administratorLogin:         '{{context.resource.connections.credentials.secrets.username}}'
  administratorLoginPassword: '{{context.resource.connections.credentials.secrets.password}}'
}

There is no such path. In the resource-types-verification test repo we work around this by fabricating the credential: Azure generates a password in the workflow and passes the same literal to both the module and the decorative secret; AWS sets manage_master_user_password = true so nothing flows. The developer's secretName is effectively ignored — the secret↔module contract is faked, which means the direct-module feature's secret story is unverified.

Why this is non-trivial (and a security concern)

The output side is already secure: secure ARM outputs (securestring/secureobject, see isSecureARMOutputType/collectARMOutputs) and Terraform sensitive outputs are routed into a separate Secrets map rather than plain values (ApplyOutputsMapping, pkg/recipes/util/outputs.go). The input side has no equivalent.

Critically, the resolver/context must not simply surface secret values through the existing non-secret lane. GetAllPropertiesFromResource (pkg/resourceutil/utils.go) copies a connected resource's entire properties map into ConnectedResource.Properties, which is exposed verbatim as context.resource.connections.<name>.properties.*. If a Radius.Security/secrets resource is connected, naively reading its data from properties would (a) bypass any decryption/secret-store handling and (b) flow as a plain ARM parameter / non-sensitive Terraform variable that could be logged. Secret material needs a distinct, tainted, secure lane, end to end.

Background — current behavior

  • recipecontext.Resource (pkg/recipes/recipecontext/types.go) has Properties and Connections — no secrets field.
  • recipes.ConnectedResource (pkg/recipes/types.go:28) carries ID/Name/Type/Properties only.
  • paramresolver.ResolveParameterExpressions (pkg/recipes/paramresolver) resolves {{context.*}} against that context; it can reach properties.* but nothing secret.
  • Connections are built in pkg/portableresources/backend/controller/createorupdateresource.go:~284-304: GetConnectionNameandSourceIDs → DatabaseClient().Get → GetAllPropertiesFromResource → ConnectedResource{ID,Name,Type,Properties}.
  • Driver call sites that resolve params:
    • pkg/recipes/driver/bicep/bicep.go:126,140 (sets Connections, calls ResolveParameterExpressions, then wrapARMParameters).
    • pkg/recipes/terraform/execute.go:373,390,400.
  • The only secret plumbing that exists on the input side is for driver/provider config, not developer secrets: bicep getRegistryAuthClient (OCI auth to pull the module) and Terraform provider/git/env secrets sourced from environment recipeConfig secret references.

Goals / acceptance criteria

  • A recipe can resolve a developer-referenced secret's values into a module parameter via a dedicated context path, e.g. context.resource.connections.<name>.secrets.<key> (and/or a secretName-property bridge — see Open questions).
  • Resolved secret values are tainted as secure and routed accordingly:
    • Bicep/ARM: passed to the module's @secure() parameter; Radius never logs the Parameters map; value is never emitted in metrics/traces/errors.
    • Terraform: emitted as a variable with sensitive = true; protected in generated *.tf.json/tfvars (state is already treated as sensitive).
  • Secret values are fetched through the secret store (decrypted), not read from the non-secure connections.*.properties.* lane.
  • Secrets are only allowed as a whole-value single expression (no interpolation into a surrounding string), so partial cleartext cannot leak into a non-secure value. Interpolating a secret is a clear error.
  • No new infrastructure required for the basic case — no Key Vault, no Secrets Manager, no CSI driver (see "Cloud expectations").
  • Backward compatible: existing recipes and the non-secret properties/connections paths are unchanged.
  • Tests: resolution, taint propagation, secure routing per driver, "secret in interpolation → error", and a no-leak assertion (secret string never appears in logs).
  • Docs: the context.*.secrets.* grammar and the security model documented; direct-module design note updated.

Proposed implementation

1. Carry secret data on connections (engine)

Add a tainted secrets field to the connected-resource model:

// pkg/recipes/types.go
type ConnectedResource struct {
    ID         string         `json:"id"`
    Name       string         `json:"name"`
    Type       string         `json:"type"`
    Properties map[string]any `json:"properties,omitempty"`
    // Secrets holds resolved secret material for secret-typed connected resources.
    // Populated only via the secret store; never sourced from Properties.
    Secrets map[string]string `json:"-"` // never serialized into logs/state
}

Populate it in createorupdateresource.go (the connection loop). When a connected resource is a secret type (Radius.Security/secrets), fetch its data through the secret store and attach it to Secrets — do not let it ride along in Properties (filter secret data out of the non-secure lane):

connectedResourcesMetadata[connName] = recipes.ConnectedResource{
    ID: ..., Name: ..., Type: ..., Properties: ...,
    Secrets: secretData, // resolved+decrypted for secret-typed resources only
}

2. Expose a secure context path (recipecontext)

// pkg/recipes/recipecontext/types.go — Resource.Connections already maps to ConnectedResource.
// Document the new path:
//   context.resource.connections.[name].secrets.[key]

3. Taint-tracking in the resolver (paramresolver)

The resolver must report which parameters resolved from secret material so drivers can mark them secure. Return a result rather than a bare map (coordinate this signature change with the one proposed in #12238):

type ResolvedParameters struct {
    Values     map[string]any
    SecureKeys map[string]bool // parameter names whose value came from a secret
}

func ResolveParameterExpressions(params map[string]any, ctx *recipecontext.Context) (ResolvedParameters, error)

Rules:

  • A {{...secrets...}} expression is only valid as the entire parameter value; if found embedded in a larger string, return an error.
  • When a parameter resolves from a secret expression, add its key to SecureKeys.

4. Secure routing in the drivers

  • Bicep (bicep.go): the value is passed to the module's @secure() parameter via wrapARMParameters as usual — ARM scrubs @secure() params from deployment history automatically. The Radius-side requirements are: (a) never log the Parameters map (today's logger.Info calls log only deploymentID/scope — add a test/lint to keep it that way), and (b) optionally validate that SecureKeys map to parameters the template declares @secure().
  • Terraform (execute.go + pkg/recipes/terraform/config): emit secure parameters as variables with "sensitive": true (the config layer already knows how to mark sensitive, see output handling in config.go), and ensure the generated config/tfvars carrying the value are written with restrictive handling.

5. Security invariants (must-test)

  • Secret strings never appear in logs, metrics, traces, or error messages.
  • Secret values are fetched decrypted from the secret store, never from connections.*.properties.*.
  • Whole-value-only; interpolation is rejected.

Cloud / module expectations — and what is NOT required

Phase 1 — inline secure value (this issue) Phase 2 — reference passthrough (optional, later)
Azure / AVM Pass the literal to the @secure() administratorLoginPassword; ARM scrubs it from logs/history ARM-native Key Vault reference ({reference:{keyVault:{id},secretName}}) so cleartext never transits Radius
AWS / RDS Pass password as a sensitive Terraform variable (state already sensitive) Module reads from Secrets Manager (ARN/data source), or manage_master_user_password
  • Key Vault / Secrets Manager support is NOT required for Phase 1. It only becomes relevant for Phase 2 (cleartext never transits Radius, cloud-native rotation), which additionally requires the target module to accept a reference-style parameter and someone to own the vault lifecycle + secret seeding.
  • A Secrets Store CSI driver is NOT required and is orthogonal. CSI is a workload-consumption concern (mounting cloud secrets into pods at runtime). The gap here is provisioning input (getting the value into the module), which is upstream of any pod mount.

Phasing

  1. Phase 1 (this issue): inline secure injection — connection Secrets, context.*.secrets.* path, resolver taint tracking, secure routing (ARM @secure / TF sensitive), no-leak invariants. No Key Vault, Secrets Manager, or CSI.
  2. Phase 2 (follow-up): reference passthrough for modules that accept Key Vault / Secrets Manager references, enabling cleartext to never transit Radius and native rotation.

Affected files

  • pkg/recipes/types.go — ConnectedResource.Secrets.
  • pkg/portableresources/backend/controller/createorupdateresource.go — resolve + attach secret data through the secret store; keep it out of Properties.
  • pkg/recipes/recipecontext/types.go — document the secrets path.
  • pkg/recipes/paramresolver/resolver.go (+ _test.go) — taint tracking, whole-value-only rule, ResolvedParameters return.
  • pkg/recipes/driver/bicep/bicep.go — consume SecureKeys; enforce/no-log Parameters.
  • pkg/recipes/terraform/execute.go + pkg/recipes/terraform/config/config.go — emit sensitive = true for secure variables.

Open questions (needs-design)

  1. Reference shape. Radius.Data/postgreSqlDatabases references its secret via a secretName string property, not a connection. Options:
    • (a) Model secret references as connections (recommended; reuses existing connection plumbing and is type-agnostic) → context.resource.connections.<name>.secrets.<key>.
    • (b) Keep secretName and have the engine resolve the named Radius.Security/secrets specially → context.resource.secrets.<key>.
    • A bridge supporting both may be desirable during migration.
  2. Coordination with Direct-module recipe parameter resolver: support nested ternaries, more operators, and typed results #12238. Both this and Direct-module recipe parameter resolver: support nested ternaries, more operators, and typed results #12238 change ResolveParameterExpressions' signature; they should land together (the ResolvedParameters struct can also carry the typed-result/error work).

Additional context

Activity

  1. added
    recipesIssues for Radius Recipes functionality
    featureThis issue describes a feature request in Radius
    on Jun 24, 2026
  2. radius-triage commented on Jun 24, 2026

    @radius-triage

    👋 @willdavsmith Thanks for filing this feature request.

    A project maintainer will review this feature request and get back to you soon.

    We also welcome community contributions! If you would like to pick this item up sooner and submit a pull request, please visit our contribution guidelines and assign this to yourself by commenting "/assign" on this issue.

    For more information on our triage process please visit our triage overview

  3. willdavsmith commented on Jun 25, 2026

    @willdavsmith
    ContributorAuthor

    Validity assessment: confirmed valid — real gap, accurately scoped as needs-design

    I traced every structural claim against main; they all hold:

    • ConnectedResource carries only ID/Name/Type/Properties (pkg/recipes/types.go:28); the recipe context exposes .properties/.id/.name/.type and no .secrets (pkg/recipes/recipecontext/types.go).
    • The connection loop builds {ID,Name,Type,Properties} only (createorupdateresource.go:298), via GetAllPropertiesFromResource.
    • ResolveParameterExpressions(... ) map[string]any returns a bare, untainted map with no error (paramresolver/resolver.go:42).
    • Output/input asymmetry confirmed: outputs already split secure values into a Secrets lane (collectARMOutputs/isSecureARMOutputType in bicep.go, ApplyOutputsMapping in util/outputs.go, Terraform sensitive), but the input call sites (bicep.go:140, execute.go:400) have no equivalent.
    • The only input-side secret plumbing today is driver/provider config (git/OCI auth via GetPrivateGitRepoSecretStoreID/GetRegistrySecrets), not developer secrets.

    One correction to the threat-model framing

    The issue implies cleartext could ride along in connections.*.properties.* and get logged as a plain param. In fact the secret value is never present at rest: it is stripped before save (secret.Value = nil, secretstores/kubernetes.go:278), stored in Kubernetes, and the datamodel→versioned conversion copies Encoding/ValueFrom but not Value (secretstore_conversion.go:167-191). Cleartext is materialized only at consumption time via SecretsLoader.LoadSecrets → ListSecrets (configloader/secrets.go). So the accurate statement is "there is no way to obtain the value at all," not "the value could leak through properties." The prescribed design (populate only via the secret store, never from Properties) is exactly right; filtering secret data out of Properties is a no-op safety belt since it is already absent.

    Two design points worth nailing down (justify needs-design)

    1. Populate-step seam. createorupdateresource.go has no SecretsLoader today, so populating ConnectedResource.Secrets there means injecting a new dependency into a widely-constructed controller. The engine already owns a SecretsLoader (engine.go:43) and already pre-loads config secrets for drivers (getRecipeConfigSecrets → LoadSecrets → ExecuteOptions.Secrets). Enriching connection secrets in engine.executeCore reuses that seam with zero new DI — the smaller blast radius.
    2. Reference shape (Open Question 1 is real). GetConnectionNameandSourceIDs only surfaces properties.connections[].source; a secretName string property would not be picked up as a connection. So "model as connection" vs "resolve secretName specially" genuinely changes the implementation. I'll go with the issue's recommended option (a): connection-based context.resource.connections.<name>.secrets.<key>, which reuses existing connection plumbing and is type-agnostic.

    Implementation plan (Phase 1)

    Picking this up now as its own PR off main:

    • ConnectedResource.Secrets map[string]string (json:"-", never serialized into the context object/logs/state).
    • Engine enrichment: for secret-typed connections, load values via the existing SecretsLoader and attach to Secrets.
    • Resolver: signature → (ResolvedParameters{Values, SecureKeys}, error); resolve …secrets.<key> from a separate secret lookup; taint resolved keys; whole-value-only (interpolating a secret → error).
    • Drivers: Bicep passes the value to the module's @secure() param (ARM scrubs it; Radius already never logs the Parameters map); Terraform routes SecureKeys through sensitive = true variables.
    • Tests: resolution, taint propagation, secure routing per driver, interpolation→error, and a no-leak assertion.

    Coordinates with #12239 (open partial-ternary fix) — both touch ResolveParameterExpressions, so they'll conflict on resolver.go and the later one rebases. Phase 2 (Key Vault / Secrets Manager reference passthrough) remains out of scope.

    I'd suggest one small edit to the issue body: soften the "secret could ride along in properties and be logged" motivation to "the value is stripped at rest and unreachable today," since that's what the code actually does.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureThis issue describes a feature request in Radiusneeds-designrecipesIssues for Radius Recipes functionality

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions