Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions deploy/manifest/built-in-providers/dev/secrets.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ types:
value:
type: string
x-radius-sensitive: true
x-radius-retain: true
description: (Required) The string value of the secret unless encoding is set to 'base64'.
required: [value]
required:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ types:
value:
type: string
x-radius-sensitive: true
x-radius-retain: true
description: (Required) The string value of the secret unless encoding is set to 'base64'.
required: [value]
required:
Expand Down
39 changes: 39 additions & 0 deletions pkg/corerp/api/v20250801preview/recipepack_conversion.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ func toRecipesDataModel(recipes map[string]*RecipeDefinition) map[string]*datamo
if recipe.Outputs != nil {
definition.Outputs = to.StringMap(recipe.Outputs)
}
if recipe.SecretOutputs != nil {
definition.SecretOutputs = toSecretOutputsDataModel(recipe.SecretOutputs)
}
result[key] = definition
}
}
Expand All @@ -126,12 +129,48 @@ func fromRecipesDataModel(recipes map[string]*datamodel.RecipeDefinition) map[st
if recipe.Outputs != nil {
definition.Outputs = *to.StringMapPtr(recipe.Outputs)
}
if recipe.SecretOutputs != nil {
definition.SecretOutputs = fromSecretOutputsDataModel(recipe.SecretOutputs)
}
result[key] = definition
}
}
return result
}

// toSecretOutputsDataModel converts a versioned secretOutputs map (property name -> secret data key ->
// module output name, with pointer values) into the version-agnostic datamodel representation.
func toSecretOutputsDataModel(in map[string]map[string]*string) map[string]map[string]string {
if in == nil {
return nil
}
out := make(map[string]map[string]string, len(in))
for property, keys := range in {
inner := make(map[string]string, len(keys))
for secretKey, outputName := range keys {
inner[secretKey] = to.String(outputName)
}
out[property] = inner
}
return out
}

// fromSecretOutputsDataModel converts a datamodel secretOutputs map into the versioned representation.
func fromSecretOutputsDataModel(in map[string]map[string]string) map[string]map[string]*string {
if in == nil {
return nil
}
out := make(map[string]map[string]*string, len(in))
for property, keys := range in {
inner := make(map[string]*string, len(keys))
for secretKey, outputName := range keys {
inner[secretKey] = to.Ptr(outputName)
}
out[property] = inner
}
return out
}

func toRecipeKindDataModel(kind *RecipeKind) string {
if kind == nil {
return ""
Expand Down
27 changes: 27 additions & 0 deletions pkg/corerp/api/v20250801preview/recipepack_conversion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,30 @@ func TestRecipePackConvertInvalidModel(t *testing.T) {
require.Equal(t, v1.ErrInvalidModelConversion, err)
})
}

func TestSecretOutputsConversion(t *testing.T) {
t.Run("round-trips a nested secretOutputs map", func(t *testing.T) {
versioned := map[string]map[string]*string{
"secretName": {
"CONNECTIONSTRING": to.Ptr("primaryConnectionString"),
"HOST": to.Ptr("host"),
},
}

dm := toSecretOutputsDataModel(versioned)
require.Equal(t, map[string]map[string]string{
"secretName": {
"CONNECTIONSTRING": "primaryConnectionString",
"HOST": "host",
},
}, dm)

back := fromSecretOutputsDataModel(dm)
require.Equal(t, versioned, back)
})

t.Run("nil maps convert to nil", func(t *testing.T) {
require.Nil(t, toSecretOutputsDataModel(nil))
require.Nil(t, fromSecretOutputsDataModel(nil))
})
}
6 changes: 6 additions & 0 deletions pkg/corerp/api/v20250801preview/zz_generated_models.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions pkg/corerp/datamodel/recipepack.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ type RecipeDefinition struct {
// directly at a Bicep or Terraform module to map the module's outputs onto resource properties.
Outputs map[string]string `json:"outputs,omitempty"`

// SecretOutputs maps resource-type property names to a map of Kubernetes Secret data keys to
// module output names. Used to materialize sensitive module outputs into a Kubernetes Secret in
// the application's namespace; the named resource property is set to the generated Secret's name.
SecretOutputs map[string]map[string]string `json:"secretOutputs,omitempty"`

// PlainHTTP connects to the source using HTTP (not-HTTPS).
PlainHTTP bool `json:"plainHTTP,omitempty"`
}
24 changes: 24 additions & 0 deletions pkg/dynamicrp/frontend/encryptionfilter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,30 @@ func testUCPClientFactoryWithNestedSensitiveFields() (*v20231001preview.ClientFa
})
}

// testUCPClientFactoryWithRetainFields returns a schema with one retain field (password: sensitive +
// retain) and one sensitive-only field (apikey: sensitive). Retain fields keep their encrypted value at
// rest and must be redacted on read even at Succeeded; sensitive-only fields are already nil at rest at
// Succeeded and are only redacted in non-terminal states.
func testUCPClientFactoryWithRetainFields() (*v20231001preview.ClientFactory, error) {
return createFakeUCPClientFactory(map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{
"type": "string",
},
"password": map[string]any{
"type": "string",
"x-radius-sensitive": true,
"x-radius-retain": true,
},
"apikey": map[string]any{
"type": "string",
"x-radius-sensitive": true,
},
},
})
}

func testUCPClientFactoryWithError() (*v20231001preview.ClientFactory, error) {
apiVersionsServer := fake.APIVersionsServer{
Get: func(ctx context.Context, planeName, resourceProviderName, resourceTypeName, apiVersionName string, options *v20231001preview.APIVersionsClientGetOptions) (resp azfake.Responder[v20231001preview.APIVersionsClientGetResponse], errResp azfake.ErrorResponder) {
Expand Down
37 changes: 16 additions & 21 deletions pkg/dynamicrp/frontend/getresource.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,13 @@ func NewGetResourceWithRedaction(

// Run returns the requested resource with sensitive fields redacted.
//
// Design consideration (GET Operation Update): When provisioningState is "Succeeded",
// the backend has already redacted sensitive data from the database, so we skip the
// schema fetch and redaction (fast path). For all other states (e.g., "Updating",
// "Accepted", "Failed"), the resource may still contain encrypted data, so we fetch
// the schema and redact sensitive fields to prevent exposure.
// Redaction is schema-driven. When provisioningState is "Succeeded" the backend has already redacted
// every non-retain sensitive field to nil, but retain fields (x-radius-retain, e.g. the secret value
// of Radius.Security/secrets) are persisted encrypted at rest so the secrets loader can decrypt them
// from the store. Those retain fields must be redacted on read so the API never returns the retained
// ciphertext. For all other states the resource may still contain encrypted data for any sensitive
// field, so every sensitive field is redacted. Because retain fields can survive into Succeeded, the
// schema is fetched on every read (the previous Succeeded fast-path that skipped the fetch is gone).
func (c *GetResourceWithRedaction) Run(ctx context.Context, w http.ResponseWriter, req *http.Request) (rest.Response, error) {
serviceCtx := v1.ARMRequestContextFromContext(ctx)
logger := ucplog.FromContextOrDiscard(ctx)
Expand All @@ -66,26 +68,17 @@ func (c *GetResourceWithRedaction) Run(ctx context.Context, w http.ResponseWrite
return rest.NewNotFoundResponse(serviceCtx.ResourceID), nil
}

// Fast path: if provisioningState is Succeeded, the backend has already redacted
// sensitive fields. Skip the schema fetch for better performance.
provisioningState := resource.ProvisioningState()
if provisioningState != v1.ProvisioningStateSucceeded && resource.Properties != nil {
if resource.Properties != nil {
resourceID := serviceCtx.ResourceID.String()
resourceType := serviceCtx.ResourceID.Type()

// Use the API version the resource was last updated with to ensure
// encryption and redaction use the same schema
apiVersion := resource.InternalMetadata.UpdatedAPIVersion

sensitiveFieldPaths, err := schema.GetSensitiveFieldPaths(
ctx,
c.ucpClient,
resourceID,
resourceType,
apiVersion,
)
paths, err := fetchRedactionPaths(ctx, c.ucpClient, resourceID, resourceType, apiVersion)
if err != nil {
logger.Error(err, "Failed to fetch sensitive field paths for GET redaction",
logger.Error(err, "Failed to fetch field paths for GET redaction",
"resourceType", resourceType, "apiVersion", apiVersion)
// Fail-safe: return error to prevent potential exposure of sensitive data
// This is consistent with the write path (encryption filter)
Expand All @@ -97,11 +90,13 @@ func (c *GetResourceWithRedaction) Run(ctx context.Context, w http.ResponseWrite
}), nil
}

if len(sensitiveFieldPaths) > 0 {
schema.RedactFields(resource.Properties, sensitiveFieldPaths)
logger.V(ucplog.LevelDebug).Info("Redacted sensitive fields in GET response",
provisioningState := resource.ProvisioningState()
fieldPaths := paths.forState(provisioningState)
if len(fieldPaths) > 0 {
schema.RedactFields(resource.Properties, fieldPaths)
logger.V(ucplog.LevelDebug).Info("Redacted fields in GET response",
"provisioningState", provisioningState,
"count", len(sensitiveFieldPaths), "resourceType", resourceType)
"count", len(fieldPaths), "resourceType", resourceType)
}
}

Expand Down
47 changes: 47 additions & 0 deletions pkg/dynamicrp/frontend/getresource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,53 @@ func newGetTestDynamicResource(provisioningState v1.ProvisioningState, propertie
}
}

func TestGetResourceWithRedaction_SucceededRedactsRetainOnly(t *testing.T) {
// At Succeeded a retain field still holds its encrypted value, so it MUST be redacted on read. A
// sensitive-only field was already redacted to nil at rest by the backend, so it is returned as-is
// (here, absent because the stored resource no longer carries it).
mctrl := gomock.NewController(t)
defer mctrl.Finish()

resource := newGetTestDynamicResource(v1.ProvisioningStateSucceeded, map[string]any{
"password": "still-encrypted",
"apikey": "already-nil-at-rest",
})

storeObject := rpctest.FakeStoreObject(resource)
storeObject.Metadata = database.Metadata{ID: testResourceID, ETag: "etag-1"}

databaseClient := database.NewMockClient(mctrl)
databaseClient.EXPECT().
Get(gomock.Any(), testResourceID).
Return(storeObject, nil)

ucpClient, err := testUCPClientFactoryWithRetainFields()
require.NoError(t, err)

c := newTestGetController(t, databaseClient, ucpClient)

req, err := http.NewRequest(http.MethodGet, testGetURL, nil)
require.NoError(t, err)
ctx := rpctest.NewARMRequestContext(req)
w := httptest.NewRecorder()

resp, err := c.Run(ctx, w, req)
require.NoError(t, err)
require.NotNil(t, resp)

_ = resp.Apply(ctx, w, req)
require.Equal(t, http.StatusOK, w.Result().StatusCode)

var body map[string]any
require.NoError(t, json.NewDecoder(w.Body).Decode(&body))
properties, ok := body["properties"].(map[string]any)
require.True(t, ok)
// Retain field is redacted even at Succeeded.
require.Nil(t, properties["password"])
// Sensitive-only field is not redacted at Succeeded (it is already nil at rest in practice).
require.Equal(t, "already-nil-at-rest", properties["apikey"])
}

func TestGetResourceWithRedaction_NonSucceededRedacts(t *testing.T) {
mctrl := gomock.NewController(t)
defer mctrl.Finish()
Expand Down
38 changes: 19 additions & 19 deletions pkg/dynamicrp/frontend/listresources.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,9 @@ func (c *ListResourcesWithRedaction) Run(ctx context.Context, w http.ResponseWri
return nil, err
}

// Cache sensitive field paths per API version
// Different resources in the list may have been created with different API versions
sensitiveFieldPathsCache := make(map[string][]string)
// Cache redaction paths per API version.
// Different resources in the list may have been created with different API versions.
redactionPathsCache := make(map[string]redactionPaths)

items := []any{}
for _, item := range result.Items {
Expand All @@ -77,27 +77,26 @@ func (c *ListResourcesWithRedaction) Run(ctx context.Context, w http.ResponseWri
return nil, err
}

// Redact sensitive fields before adding to the response.
// Fast path: if provisioningState is Succeeded, the backend has already redacted
// sensitive fields. Skip redaction for these items.
provisioningState := resource.ProvisioningState()
if provisioningState != v1.ProvisioningStateSucceeded && resource.Properties != nil {
// Redact fields before adding to the response. Retain fields (x-radius-retain) survive into
// Succeeded as ciphertext and must be redacted on read, so unlike sensitive-only redaction we
// can't skip Succeeded items; we fetch the schema for every item with properties.
if resource.Properties != nil {
// Use the API version the resource was last updated with to ensure
// encryption and redaction use the same schema
apiVersion := resource.InternalMetadata.UpdatedAPIVersion

// Check cache first to avoid redundant schema fetches for same API version
sensitiveFieldPaths, cached := sensitiveFieldPathsCache[apiVersion]
paths, cached := redactionPathsCache[apiVersion]
if !cached {
sensitiveFieldPaths, err = schema.GetSensitiveFieldPaths(
paths, err = fetchRedactionPaths(
ctx,
c.ucpClient,
resource.ID,
serviceCtx.ResourceID.Type(),
apiVersion,
)
if err != nil {
logger.Error(err, "Failed to fetch sensitive field paths for LIST redaction",
logger.Error(err, "Failed to fetch field paths for LIST redaction",
"resourceType", serviceCtx.ResourceID.Type(), "apiVersion", apiVersion)
// Fail-safe: return error to prevent potential exposure of sensitive data
// This is consistent with the write path (encryption filter)
Expand All @@ -108,11 +107,12 @@ func (c *ListResourcesWithRedaction) Run(ctx context.Context, w http.ResponseWri
},
}), nil
}
sensitiveFieldPathsCache[apiVersion] = sensitiveFieldPaths
redactionPathsCache[apiVersion] = paths
}

if len(sensitiveFieldPaths) > 0 {
schema.RedactFields(resource.Properties, sensitiveFieldPaths)
fieldPaths := paths.forState(resource.ProvisioningState())
if len(fieldPaths) > 0 {
schema.RedactFields(resource.Properties, fieldPaths)
}
}

Expand All @@ -124,14 +124,14 @@ func (c *ListResourcesWithRedaction) Run(ctx context.Context, w http.ResponseWri
}

// Log redaction summary if any schemas were fetched
if len(sensitiveFieldPathsCache) > 0 {
if len(redactionPathsCache) > 0 {
totalSensitiveFields := 0
for _, paths := range sensitiveFieldPathsCache {
totalSensitiveFields += len(paths)
for _, paths := range redactionPathsCache {
totalSensitiveFields += len(paths.sensitive)
}
logger.V(ucplog.LevelDebug).Info("Redacted sensitive fields in LIST response",
logger.V(ucplog.LevelDebug).Info("Redacted fields in LIST response",
"totalSensitiveFields", totalSensitiveFields,
"apiVersions", len(sensitiveFieldPathsCache),
"apiVersions", len(redactionPathsCache),
"resourceType", serviceCtx.ResourceID.Type(),
"itemCount", len(items))
}
Expand Down
Loading
Loading