Repository navigation
Description
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) hasPropertiesandConnections— no secrets field.recipes.ConnectedResource(pkg/recipes/types.go:28) carriesID/Name/Type/Propertiesonly.paramresolver.ResolveParameterExpressions(pkg/recipes/paramresolver) resolves{{context.*}}against that context; it can reachproperties.*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(setsConnections, callsResolveParameterExpressions, thenwrapARMParameters).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 environmentrecipeConfigsecret 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 asecretName-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 theParametersmap; 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).
- Bicep/ARM: passed to the module's
- 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/connectionspaths 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 viawrapARMParametersas usual — ARM scrubs@secure()params from deployment history automatically. The Radius-side requirements are: (a) never log theParametersmap (today'slogger.Infocalls log only deploymentID/scope — add a test/lint to keep it that way), and (b) optionally validate thatSecureKeysmap 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 marksensitive, see output handling inconfig.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
- Phase 1 (this issue): inline secure injection — connection
Secrets,context.*.secrets.*path, resolver taint tracking, secure routing (ARM@secure/ TFsensitive), no-leak invariants. No Key Vault, Secrets Manager, or CSI. - 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 ofProperties.pkg/recipes/recipecontext/types.go— document thesecretspath.pkg/recipes/paramresolver/resolver.go(+_test.go) — taint tracking, whole-value-only rule,ResolvedParametersreturn.pkg/recipes/driver/bicep/bicep.go— consumeSecureKeys; enforce/no-logParameters.pkg/recipes/terraform/execute.go+pkg/recipes/terraform/config/config.go— emitsensitive = truefor secure variables.
Open questions (needs-design)
- Reference shape.
Radius.Data/postgreSqlDatabasesreferences its secret via asecretNamestring property, not aconnection. Options:- (a) Model secret references as connections (recommended; reuses existing connection plumbing and is type-agnostic) →
context.resource.connections.<name>.secrets.<key>. - (b) Keep
secretNameand have the engine resolve the namedRadius.Security/secretsspecially →context.resource.secrets.<key>. - A bridge supporting both may be desirable during migration.
- (a) Model secret references as connections (recommended; reuses existing connection plumbing and is type-agnostic) →
- 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 (theResolvedParametersstruct can also carry the typed-result/error work).
Additional context
- Direct module support for Recipes — Direct module support for Recipes #11876.
- Related resolver work — Direct-module recipe parameter resolver: support nested ternaries, more operators, and typed results #12238 (expression grammar; shares the signature change) and Support nested and chained ternaries in recipe parameter resolver #12239 (partial ternary fix).
- Real-world driver: the
resource-types-verificationrepo verifies resource types against real clouds via direct modules (PostgreSQL on Azure AVM and AWS Terraform). It currently cannot exercise the secret↔module contract and fabricates the credential instead, so this half of the direct-module feature is unverified end to end.
Activity
radius-triage commentedon Jun 24, 2026
👋 @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
willdavsmith commentedon Jun 25, 2026
Validity assessment: confirmed valid — real gap, accurately scoped as
needs-designI traced every structural claim against
main; they all hold:ConnectedResourcecarries onlyID/Name/Type/Properties(pkg/recipes/types.go:28); the recipe context exposes.properties/.id/.name/.typeand no.secrets(pkg/recipes/recipecontext/types.go).{ID,Name,Type,Properties}only (createorupdateresource.go:298), viaGetAllPropertiesFromResource.ResolveParameterExpressions(... ) map[string]anyreturns a bare, untainted map with no error (paramresolver/resolver.go:42).Secretslane (collectARMOutputs/isSecureARMOutputTypeinbicep.go,ApplyOutputsMappinginutil/outputs.go, Terraformsensitive), but the input call sites (bicep.go:140,execute.go:400) have no equivalent.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 copiesEncoding/ValueFrombut notValue(secretstore_conversion.go:167-191). Cleartext is materialized only at consumption time viaSecretsLoader.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 fromProperties) is exactly right; filtering secret data out ofPropertiesis a no-op safety belt since it is already absent.Two design points worth nailing down (justify
needs-design)createorupdateresource.gohas noSecretsLoadertoday, so populatingConnectedResource.Secretsthere means injecting a new dependency into a widely-constructed controller. The engine already owns aSecretsLoader(engine.go:43) and already pre-loads config secrets for drivers (getRecipeConfigSecrets→LoadSecrets→ExecuteOptions.Secrets). Enriching connection secrets inengine.executeCorereuses that seam with zero new DI — the smaller blast radius.GetConnectionNameandSourceIDsonly surfacesproperties.connections[].source; asecretNamestring property would not be picked up as a connection. So "model as connection" vs "resolvesecretNamespecially" genuinely changes the implementation. I'll go with the issue's recommended option (a): connection-basedcontext.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).SecretsLoaderand attach toSecrets.(ResolvedParameters{Values, SecureKeys}, error); resolve…secrets.<key>from a separate secret lookup; taint resolved keys; whole-value-only (interpolating a secret → error).@secure()param (ARM scrubs it; Radius already never logs theParametersmap); Terraform routesSecureKeysthroughsensitive = truevariables.Coordinates with #12239 (open partial-ternary fix) — both touch
ResolveParameterExpressions, so they'll conflict onresolver.goand 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
propertiesand be logged" motivation to "the value is stripped at rest and unreachable today," since that's what the code actually does.Resolve Radius.Security/secrets references into recipe parameters
Revert "Resolve Radius.Security/secrets references into recipe parame…