Skip to content

Commit 317361a

Browse files
committed
feat(secretsmanager): remove RedactSensitive branch; raise minFormaeVersion to 0.89.0
The RedactSensitive field has been removed from the formae plugin SDK (ReadRequest and ReadResource structs). The field was the only mechanism suppressing secret enrichment during discovery reads; secrets are now hashed at rest and resolved in-flight, making the suppression unnecessary. Secret.Read now unconditionally calls GetSecretValue — the enrichment path that was already taken for non-discovery reads. The pre-Read version gate in the formae agent (FilterCompatiblePlugins) ensures an old agent cannot load this plugin: a plugin declaring minFormaeVersion=0.89.0 is rejected at startup by any agent running an older version, so no Read reaches an agent that does not know to hash the secret value. minFormaeVersion is raised to 0.89.0 — the formae release that ships the SDK removal (assumption: confirm this matches the actual release version at release time; this plugin must not be released until the corresponding formae release is published). The Read implementation is refactored to accept injectable ccxReader and secretValueGetter interfaces, enabling the new unit tests to exercise the enrichment path without live AWS credentials.
1 parent 85c92c8 commit 317361a

3 files changed

Lines changed: 166 additions & 13 deletions

File tree

formae-plugin.pkl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ summary = "AWS resource plugin (CloudControl-based)"
1111
description = "AWS CloudControl resource plugin for Formae"
1212
category = "cloud"
1313
license = "FSL-1.1-ALv2"
14-
minFormaeVersion = "0.88.0"
14+
minFormaeVersion = "0.89.0"
1515

1616
output {
1717
renderer = new JsonRenderer {}

pkg/cfres/secretsmanager/secret.go

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@ import (
2020
"github.com/platform-engineering-labs/formae-plugin-aws/pkg/config"
2121
)
2222

23+
// ccxReader is the subset of ccx.Client used by Secret.Read.
24+
type ccxReader interface {
25+
ReadResource(ctx context.Context, request *resource.ReadRequest) (*resource.ReadResult, error)
26+
}
27+
28+
// secretValueGetter is the subset of secretsmanager.Client used by Secret.Read.
29+
type secretValueGetter interface {
30+
GetSecretValue(ctx context.Context, params *secretsmanager.GetSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error)
31+
}
32+
2333
type Secret struct {
2434
cfg *config.Config
2535
}
@@ -39,34 +49,32 @@ func init() {
3949
})
4050
}
4151

42-
// Read enhances Cloud Control read with actual secret value
52+
// Read enhances Cloud Control read with actual secret value.
4353
func (s *Secret) Read(ctx context.Context, request *resource.ReadRequest) (*resource.ReadResult, error) {
4454
ccxClient, err := ccx.NewClient(s.cfg)
4555
if err != nil {
4656
plugin.LoggerFromContext(ctx).Error("SecretsManager: Failed to create ccx client", "error", err)
4757
return nil, err
4858
}
4959

50-
result, err := ccxClient.ReadResource(ctx, request)
60+
awsCfg, err := s.cfg.ToAwsConfig(ctx)
5161
if err != nil {
52-
plugin.LoggerFromContext(ctx).Error("SecretsManager: Cloud Control ReadResource failed", "error", err)
62+
plugin.LoggerFromContext(ctx).Error("SecretsManager: Failed to create AWS config", "error", err)
5363
return nil, err
5464
}
5565

56-
// Don't bother enriching with secret value when RedactSensitive is set
57-
if request.RedactSensitive {
58-
return result, nil
59-
}
66+
return s.readWithClients(ctx, ccxClient, secretsmanager.NewFromConfig(awsCfg), request)
67+
}
6068

61-
awsCfg, err := s.cfg.ToAwsConfig(ctx)
69+
// readWithClients performs the enriched read using injectable clients (enables unit testing).
70+
func (s *Secret) readWithClients(ctx context.Context, ccxClient ccxReader, smClient secretValueGetter, request *resource.ReadRequest) (*resource.ReadResult, error) {
71+
result, err := ccxClient.ReadResource(ctx, request)
6272
if err != nil {
63-
plugin.LoggerFromContext(ctx).Error("SecretsManager: Failed to create AWS config", "error", err)
73+
plugin.LoggerFromContext(ctx).Error("SecretsManager: Cloud Control ReadResource failed", "error", err)
6474
return nil, err
6575
}
6676

67-
secretsClient := secretsmanager.NewFromConfig(awsCfg)
68-
69-
secret, err := secretsClient.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{
77+
secret, err := smClient.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{
7078
SecretId: &request.NativeID,
7179
})
7280
if err != nil {
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// © 2025 Platform Engineering Labs Inc.
2+
//
3+
// SPDX-License-Identifier: FSL-1.1-ALv2
4+
5+
//go:build unit
6+
7+
package secretsmanager
8+
9+
import (
10+
"context"
11+
"encoding/json"
12+
"errors"
13+
"testing"
14+
15+
"github.com/aws/aws-sdk-go-v2/aws"
16+
"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
17+
"github.com/stretchr/testify/assert"
18+
"github.com/stretchr/testify/mock"
19+
"github.com/stretchr/testify/require"
20+
21+
"github.com/platform-engineering-labs/formae/pkg/plugin/resource"
22+
"github.com/platform-engineering-labs/formae-plugin-aws/pkg/config"
23+
)
24+
25+
// mockCCXReader mocks the Cloud Control read path.
26+
type mockCCXReader struct {
27+
mock.Mock
28+
}
29+
30+
func (m *mockCCXReader) ReadResource(ctx context.Context, req *resource.ReadRequest) (*resource.ReadResult, error) {
31+
args := m.Called(ctx, req)
32+
if args.Get(0) == nil {
33+
return nil, args.Error(1)
34+
}
35+
return args.Get(0).(*resource.ReadResult), args.Error(1)
36+
}
37+
38+
// mockSMClient mocks the SecretsManager GetSecretValue call.
39+
type mockSMClient struct {
40+
mock.Mock
41+
}
42+
43+
func (m *mockSMClient) GetSecretValue(ctx context.Context, input *secretsmanager.GetSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) {
44+
args := m.Called(ctx, input)
45+
if args.Get(0) == nil {
46+
return nil, args.Error(1)
47+
}
48+
return args.Get(0).(*secretsmanager.GetSecretValueOutput), args.Error(1)
49+
}
50+
51+
func TestSecret_Read_AlwaysEnrichesSecretValue(t *testing.T) {
52+
// Read unconditionally enriches: the secret value is always fetched and
53+
// merged into the result properties.
54+
ctx := context.Background()
55+
ccxMock := &mockCCXReader{}
56+
smMock := &mockSMClient{}
57+
58+
ccxMock.On("ReadResource", ctx, mock.Anything).Return(&resource.ReadResult{
59+
ResourceType: "AWS::SecretsManager::Secret",
60+
Properties: `{"Name":"my-secret","ARN":"arn:aws:secretsmanager:us-east-1:123456789012:secret:my-secret"}`,
61+
}, nil)
62+
63+
secretVal := "super-secret-value"
64+
smMock.On("GetSecretValue", ctx, mock.MatchedBy(func(in *secretsmanager.GetSecretValueInput) bool {
65+
return in.SecretId != nil && *in.SecretId == "my-secret-id"
66+
})).Return(&secretsmanager.GetSecretValueOutput{
67+
SecretString: aws.String(secretVal),
68+
}, nil)
69+
70+
s := &Secret{cfg: &config.Config{}}
71+
result, err := s.readWithClients(ctx, ccxMock, smMock, &resource.ReadRequest{
72+
NativeID: "my-secret-id",
73+
ResourceType: "AWS::SecretsManager::Secret",
74+
})
75+
76+
require.NoError(t, err)
77+
require.NotNil(t, result)
78+
79+
var props map[string]any
80+
require.NoError(t, json.Unmarshal([]byte(result.Properties), &props))
81+
assert.Equal(t, secretVal, props["SecretString"], "SecretString must be present in result")
82+
83+
ccxMock.AssertExpectations(t)
84+
smMock.AssertExpectations(t)
85+
}
86+
87+
func TestSecret_Read_EnrichesEvenWhenCCXReturnsEmptyProperties(t *testing.T) {
88+
// Enrichment works even when Cloud Control returns an empty properties blob.
89+
ctx := context.Background()
90+
ccxMock := &mockCCXReader{}
91+
smMock := &mockSMClient{}
92+
93+
ccxMock.On("ReadResource", ctx, mock.Anything).Return(&resource.ReadResult{
94+
ResourceType: "AWS::SecretsManager::Secret",
95+
Properties: "",
96+
}, nil)
97+
98+
secretVal := "another-secret"
99+
smMock.On("GetSecretValue", ctx, mock.Anything).Return(&secretsmanager.GetSecretValueOutput{
100+
SecretString: aws.String(secretVal),
101+
}, nil)
102+
103+
s := &Secret{cfg: &config.Config{}}
104+
result, err := s.readWithClients(ctx, ccxMock, smMock, &resource.ReadRequest{
105+
NativeID: "my-secret-id",
106+
ResourceType: "AWS::SecretsManager::Secret",
107+
})
108+
109+
require.NoError(t, err)
110+
var props map[string]any
111+
require.NoError(t, json.Unmarshal([]byte(result.Properties), &props))
112+
assert.Equal(t, secretVal, props["SecretString"])
113+
}
114+
115+
func TestSecret_Read_GetSecretValueFailureReturnsPartialResult(t *testing.T) {
116+
// If GetSecretValue fails, Read still returns the Cloud Control result
117+
// (no secret enrichment) rather than propagating an error.
118+
ctx := context.Background()
119+
ccxMock := &mockCCXReader{}
120+
smMock := &mockSMClient{}
121+
122+
ccxMock.On("ReadResource", ctx, mock.Anything).Return(&resource.ReadResult{
123+
ResourceType: "AWS::SecretsManager::Secret",
124+
Properties: `{"Name":"my-secret"}`,
125+
}, nil)
126+
127+
smMock.On("GetSecretValue", ctx, mock.Anything).Return(
128+
(*secretsmanager.GetSecretValueOutput)(nil),
129+
errors.New("access denied"),
130+
)
131+
132+
s := &Secret{cfg: &config.Config{}}
133+
result, err := s.readWithClients(ctx, ccxMock, smMock, &resource.ReadRequest{
134+
NativeID: "my-secret-id",
135+
ResourceType: "AWS::SecretsManager::Secret",
136+
})
137+
138+
require.NoError(t, err, "GetSecretValue failure must not propagate as an error")
139+
require.NotNil(t, result)
140+
141+
var props map[string]any
142+
require.NoError(t, json.Unmarshal([]byte(result.Properties), &props))
143+
_, hasSecret := props["SecretString"]
144+
assert.False(t, hasSecret, "SecretString must be absent when GetSecretValue fails")
145+
}

0 commit comments

Comments
 (0)