Skip to content

Securely flow developer secrets into direct-module recipe parameters#12286

Draft
willdavsmith wants to merge 9 commits into
mainfrom
willdavsmith-retain-encrypted-secrets
Draft

Securely flow developer secrets into direct-module recipe parameters#12286
willdavsmith wants to merge 9 commits into
mainfrom
willdavsmith-retain-encrypted-secrets

Conversation

@willdavsmith

Copy link
Copy Markdown
Contributor

Description

Securely flows developer-authored secrets into the parameters of direct-module recipes, and makes Radius.Security/secrets durable and multi-cluster-safe by retaining the encrypted value at rest and decrypting it from Radius's own store at recipe time. Addresses #12244 (Phase 2 external secret stores remain out of scope, so this does not close the issue).

This consolidates the previously stacked drafts #12249 (connection-based secrets) and #12254 (secret-binding references) into a single PR, and supersedes #12254's "read the deployed Kubernetes Secret" approach with retain-at-rest (see Secret handling at rest below).

Two developer-facing ways to hand a secret to a recipe

  1. Connection-based — a secret-typed connection (Applications.Core/secretStores, Radius.Security/secrets) resolves as {{context.resource.connections.<name>.secrets.<key>}}.
  2. Binding-based — a resource property annotated x-radius-secret-binding (a secrets array of Radius.Security/secrets resource IDs) resolves as {{context.resource.secrets.<secretName>.<key>}}.

Both paths resolve secrets as whole values only (a secret interpolated into a surrounding string is rejected), tag the resulting parameters secure, and route them through each driver's native secret mechanism (ARM @secure() / Terraform sensitive = true). Radius never logs the parameter map.

Secret handling at rest (multi-cluster-safe)

Radius.Security/secrets data values are marked x-radius-sensitive and x-radius-retain, so the encrypted value is persisted at rest instead of being nulled, while remaining redacted on GET/LIST. At recipe time the secrets loader decrypts the retained value from Radius's own store — it does not read a Kubernetes Secret. This lets a recipe deploy to a different target cluster (the app-owned K8s Secret is not part of the infrastructure deploy). The earlier Kubernetes-Secret migration fallback has been removed: if a secret value is not retained at rest, the loader fails closed (loudly) rather than silently reading from the cluster.

Changes by layer

  • Schema (pkg/schema): adds x-radius-secret-binding (+ extractor) and x-radius-retain annotations and helpers; the validator enforces x-radius-retain only on x-radius-sensitive fields.
  • Types (pkg/recipes/types.go): a tainted Secrets map[string]string (json:"-") lane on ConnectedResource/recipe metadata, so secret material is never serialized alongside regular data.
  • Controller (pkg/portableresources/backend/controller): persists the encrypted retained value at rest; redacts retain fields on GET/LIST even at Succeeded; resolves x-radius-secret-binding properties into sibling Radius.Security/secrets resource IDs (fail-closed on malformed references).
  • Loader (pkg/dynamicrp/secretsloader, new): a type-aware dispatcher. Radius.Security/secrets is decrypted from its retained at-rest value (schema-driven, via the UCP schema client); other secret store types delegate to the existing Applications.Core/secretStores loader. Cleartext is reachable only by the in-process engine — never re-persisted, never exposed via GET.
  • Engine (pkg/recipes/engine): enriches connection secrets and secret bindings into the recipe's tainted Secrets (fail-closed: a bound secret that cannot be loaded fails the deployment rather than passing a literal {{…}} placeholder).
  • Param resolver / drivers (pkg/recipes/paramresolver, driver/bicep, terraform): resolves context.resource.connections.<name>.secrets.<key> and context.resource.secrets.<secretName>.<key>, whole-value-only, secure-tagged, routed to @secure() / sensitive.
  • Built-in manifest (deploy/manifest/built-in-providers/{dev,self-hosted}/secrets.yaml): marks the secret value field x-radius-retain: true so the built-in Radius.Security/secrets type retains its encrypted value at rest.

Testing

  • Unit tests across pkg/schema, pkg/dynamicrp/secretsloader, pkg/portableresources/backend/controller, pkg/recipes/{engine,paramresolver,terraform}, and pkg/dynamicrp/frontend (redaction). gofmt/go vet clean; packages green, including the UCP initializer test that loads the edited built-in manifests.
  • End-to-end (manual): the Azure (Bicep AVM) direct-module workflow in resource-types-verification provisions a real Azure PostgreSQL flexible server via a recipe that consumes a developer Radius.Security/secrets, and proves the app container connects to the DB using the password decrypted from Radius's store with the Kubernetes fallback removed — the resource value is retained encrypted at rest, never read from a cluster Secret.

Out of scope

  • External (Key Vault / Secrets Manager / Vault) secret references — Phase 2.
  • Docs — planned follow-up.

Implements Phase 1 of #12244: securely flow developer-authored secrets into direct-module recipe parameters. The engine enriches secret-typed connections with secret material, the param resolver resolves whole-value secret references and tags them, and the Bicep and Terraform drivers route secret values through @secure() / sensitive variables.

Docs are a planned follow-up.

Signed-off-by: willdavsmith <[email protected]>
Extends Phase 1 of #12244 so a developer-authored Radius.Security/secrets
resource referenced by a resource property (x-radius-secret-reference, e.g.
secretName) flows its real cleartext into direct-module recipe parameters via
the context.resource.secrets.<key> expression, tagged for secure routing
(ARM @secure() / Terraform sensitive = true).

Because a secret's sensitive data is redacted from the database before recipe
execution, the new dynamic-RP secrets loader reads cleartext on demand from the
secret's deployed Kubernetes Secret via status.outputResources (never the
redacted Properties.Data), scoped to Kubernetes-backed secrets. Enrichment is
fail-closed: a referenced secret that cannot be loaded fails the deployment
rather than passing a literal placeholder to the module.

Docs are a planned follow-up.

Signed-off-by: willdavsmith <[email protected]>
…ters"

This reverts commit 3876f0a.

The #12244 owner has parked the secretName / Radius.Security/secrets path
pending reconciliation of its scope (Phase 1 vs deferred Phase 2 reference
passthrough, the latter potentially needing security-owner sign-off). This
restores PR #12249 to the merge-ready connection-based Phase 1 deliverable.
The reverted work remains in history at 3876f0a and can be re-applied when
the owner picks it back up.

Signed-off-by: willdavsmith <[email protected]>
Extends Phase 1 of #12244 so a developer-authored Radius.Security/secrets
resource referenced by a resource property (x-radius-secret-reference, e.g.
secretName) flows its real cleartext into direct-module recipe parameters via
the context.resource.secrets.<key> expression, tagged for secure routing
(ARM @secure() / Terraform sensitive = true).

Because a secret's sensitive data is redacted from the database before recipe
execution, the new dynamic-RP secrets loader reads cleartext on demand from the
secret's deployed Kubernetes Secret via status.outputResources (never the
redacted Properties.Data), scoped to Kubernetes-backed secrets. Enrichment is
fail-closed: a referenced secret that cannot be loaded fails the deployment
rather than passing a literal placeholder to the module.

Docs are a planned follow-up.

Signed-off-by: willdavsmith <[email protected]>
…ecipes

Introduce the x-radius-secret-binding schema annotation, which marks an
array property listing the Radius.Security/secrets resource IDs a resource
depends on. The engine parses each ID, loads every key of each listed
secret, and exposes the values to recipes (through the Bicep @secure() /
Terraform sensitive lanes) as context.resource.secrets.<secretName>.<key>,
where <secretName> is the secret resource's name.

This lets direct-module recipes (for example Azure AVM or AWS Terraform
modules) consume developer-authored Radius secrets without the module
performing its own Kubernetes secret lookup.

- schema: register the annotation and add the ExtractSecretBindingPaths
  extractor (the marked array property is treated as a leaf)
- controller: buildSecretBindings reads the array of secret IDs and
  validates each, fail-closed on malformed input
- engine: enrichSecretBindings loads all keys of each bound secret,
  namespaces them by the secret resource name, and detects collisions
- types: ResourceMetadata.SecretBindings is now []string

Signed-off-by: willdavsmith <[email protected]>
Add an x-radius-retain schema annotation so Radius.Security/secrets values
persist as ciphertext in the control-plane store instead of being redacted to
nil after recipe execution. The dynamic-rp secrets loader now decrypts secret
values from Radius's own store, falling back to reading the target-cluster
Kubernetes Secret only for pre-retain (nil-at-rest) secrets. This fixes
secret resolution for multi-cluster deployments, where the created Kubernetes
Secret lives on the application's target cluster rather than the cluster
running the control plane.

- pkg/schema: x-radius-retain annotation, Extract/GetRetainFieldPaths, and a
  validator invariant that retain fields must also be sensitive.
- pkg/portableresources/backend: persist the encrypted envelope for retain
  fields (redact only sensitive-minus-retain); recipes still get cleartext.
- pkg/dynamicrp/frontend: redact retain fields on GET and LIST even in the
  Succeeded state, since they survive as ciphertext.
- pkg/dynamicrp/secretsloader: decrypt from store with Kubernetes fallback.
- pkg/dynamicrp/options: thread the UCP schema client into the loader.

Signed-off-by: willdavsmith <[email protected]>
The udtSecretsLoader previously fell back to reading the value from a
single cluster's Kubernetes Secret when a Radius.Security/secrets value
was not retained encrypted at rest (e.g. a pre-retain secret). That
fallback is not multi-cluster safe and silently masked secrets that were
never persisted encrypted.

Remove the fallback so the loader fails closed: a secret with no value
stored at rest, missing properties, or no available schema now returns an
operator-facing error directing them to redeploy the secret, rather than
quietly reading from the control-plane cluster. The decryption key read
from radius-system is retained, since it lives on the control-plane
cluster and is not part of the fallback.

Signed-off-by: willdavsmith <[email protected]>
The Radius.Security/secrets value field was x-radius-sensitive but never
x-radius-retain, so dynamic-rp redacted data[*].value to nil at rest
(retain set minus sensitive set was the full sensitive set). The secrets
loader then had no encrypted value to decrypt from the store and relied on
the (now removed) Kubernetes Secret fallback in every deployment, not just
migration.

Adding x-radius-retain to the built-in dev and self-hosted manifests makes
the encrypted value persist at rest so the loader can decrypt it from
Radius's own store, which is required for multi-cluster-safe secret
resolution.

Signed-off-by: willdavsmith <[email protected]>
@github-actions

Copy link
Copy Markdown

This PR requires exactly 1 of the following labels: pr:standard, pr:important.
Currently applied labels: .

Label descriptions:

  • pr:important - Major features, breaking changes, deprecations, or other high-impact changes that need special attention during release.
  • pr:standard - Ongoing maintenance, minor improvements, documentation updates, and routine development work.

@willdavsmith, please add the appropriate label to this PR before merging.

@github-actions

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@github-actions

github-actions Bot commented Jun 30, 2026

Copy link
Copy Markdown

Unit Tests

    2 files  ±  0    453 suites  +2   7m 32s ⏱️ + 1m 1s
5 773 tests +148  5 771 ✅ +148  2 💤 ±0  0 ❌ ±0 
6 970 runs  +148  6 968 ✅ +148  2 💤 ±0  0 ❌ ±0 

Results for commit 9537082. ± Comparison against base commit 9b5a681.

♻️ This comment has been updated with latest results.

@codecov

codecov Bot commented Jun 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 73.82646% with 184 lines in your changes missing coverage. Please review.
✅ Project coverage is 53.22%. Comparing base (98f5623) to head (9537082).
⚠️ Report is 18 commits behind head on main.

Files with missing lines Patch % Lines
...urces/backend/controller/createorupdateresource.go 63.25% 52 Missing and 27 partials ⚠️
pkg/recipes/configloader/environment.go 0.00% 17 Missing ⚠️
pkg/recipes/driver/bicep/bicep.go 6.25% 14 Missing and 1 partial ⚠️
pkg/schema/annotations.go 74.57% 11 Missing and 4 partials ⚠️
pkg/dynamicrp/secretsloader/secretsloader.go 87.09% 6 Missing and 6 partials ⚠️
pkg/recipes/engine/engine.go 86.48% 5 Missing and 5 partials ⚠️
pkg/recipes/driver/terraform/terraform.go 18.18% 8 Missing and 1 partial ⚠️
pkg/recipes/terraform/execute.go 0.00% 7 Missing ⚠️
pkg/dynamicrp/options.go 0.00% 5 Missing ⚠️
pkg/schema/validator.go 76.19% 4 Missing and 1 partial ⚠️
... and 3 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #12286      +/-   ##
==========================================
+ Coverage   52.88%   53.22%   +0.34%     
==========================================
  Files         751      757       +6     
  Lines       48353    49321     +968     
==========================================
+ Hits        25570    26250     +680     
- Misses      20385    20593     +208     
- Partials     2398     2478      +80     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Implements the bidirectional ("backwards") secret flow (radius#12288): a recipe
can declare a secretOutputs mapping from a bound Radius.Security/secrets resource's
data keys to a module's secure outputs. Radius resolves these against the raw module
outputs, writes the values into the bound secret (encrypted at rest via x-radius-retain),
and refreshes the secret's backing Kubernetes Secret so connected containers consume
them through a plain secretKeyRef.

- pkg/recipes/secretwriteback.go: ResolveSecretWriteBacks resolver (fail-closed)
- pkg/recipes/types.go: SecretWriteBack type and RecipeOutput.SecretWriteBacks
- pkg/recipes/driver/{bicep,terraform}: resolve secretOutputs on raw module outputs,
  routing secure-typed outputs into secrets so they are not surfaced as plain values
- pkg/portableresources/backend/controller: applySecretWriteBacks executor
- pkg/corerp + swagger + typespec: recipePack secretOutputs API field and conversions

Signed-off-by: willdavsmith <[email protected]>
@radius-functional-tests

radius-functional-tests Bot commented Jul 1, 2026

Copy link
Copy Markdown

Radius functional test overview

🔍 Go to test action run

Click here to see the test run details
Name Value
Repository radius-project/radius
Commit ref 9537082
Unique ID funcc15b62a5cb
Image tag pr-funcc15b62a5cb
  • Dapr: 1.14.4
  • Azure KeyVault CSI driver: 1.4.2
  • Azure Workload identity webhook: 1.3.0
  • Bicep recipe location ghcr.io/radius-project/dev/test/testrecipes/test-bicep-recipes/<name>:pr-funcc15b62a5cb
  • Terraform recipe location http://tf-module-server.radius-test-tf-module-server.svc.cluster.local/<name>.zip (in cluster)
  • applications-rp test image location: ghcr.io/radius-project/dev/applications-rp:pr-funcc15b62a5cb
  • dynamic-rp test image location: ghcr.io/radius-project/dev/dynamic-rp:pr-funcc15b62a5cb
  • controller test image location: ghcr.io/radius-project/dev/controller:pr-funcc15b62a5cb
  • ucp test image location: ghcr.io/radius-project/dev/ucpd:pr-funcc15b62a5cb
  • deployment-engine test image location: ghcr.io/radius-project/deployment-engine:latest

Test Status

⌛ Building Radius and pushing container images for functional tests...
✅ Container images build succeeded
⌛ Publishing Bicep Recipes for functional tests...
❌ Test recipe publishing failed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant