diff --git a/common/authentication/aws/client_fake.go b/common/authentication/aws/client_fake.go index c9e23641ba..35f752a96a 100644 --- a/common/authentication/aws/client_fake.go +++ b/common/authentication/aws/client_fake.go @@ -43,12 +43,18 @@ func (m *MockParameterStore) DescribeParametersWithContext(ctx context.Context, type MockSecretManager struct { GetSecretValueFn func(context.Context, *secretsmanager.GetSecretValueInput, ...request.Option) (*secretsmanager.GetSecretValueOutput, error) secretsmanageriface.SecretsManagerAPI + + ListSecretsFn func(context.Context, *secretsmanager.ListSecretsInput, ...request.Option) (*secretsmanager.ListSecretsOutput, error) } func (m *MockSecretManager) GetSecretValueWithContext(ctx context.Context, input *secretsmanager.GetSecretValueInput, option ...request.Option) (*secretsmanager.GetSecretValueOutput, error) { return m.GetSecretValueFn(ctx, input, option...) } +func (m *MockSecretManager) ListSecretsWithContext(ctx context.Context, input *secretsmanager.ListSecretsInput, option ...request.Option) (*secretsmanager.ListSecretsOutput, error) { + return m.ListSecretsFn(ctx, input, option...) +} + type MockDynamoDB struct { GetItemWithContextFn func(ctx context.Context, input *dynamodb.GetItemInput, op ...request.Option) (*dynamodb.GetItemOutput, error) PutItemWithContextFn func(ctx context.Context, input *dynamodb.PutItemInput, op ...request.Option) (*dynamodb.PutItemOutput, error) diff --git a/secretstores/aws/secretmanager/metadata.yaml b/secretstores/aws/secretmanager/metadata.yaml index 21bfbd5b2c..5f7769cb76 100644 --- a/secretstores/aws/secretmanager/metadata.yaml +++ b/secretstores/aws/secretmanager/metadata.yaml @@ -16,4 +16,10 @@ metadata: description: | The Secrets manager endpoint. The AWS SDK will generate a default endpoint if not specified. Useful for local testing with AWS LocalStack example: '"http://localhost:4566"' - type: string \ No newline at end of file + type: string + - name: multipleKeyValuesPerSecret + required: false + description: | + A boolean value to indicate if the secrets with multiple key/values should break keys out. + example: "true" + type: bool \ No newline at end of file diff --git a/secretstores/aws/secretmanager/secretmanager.go b/secretstores/aws/secretmanager/secretmanager.go index 979739be5b..2f2cd63cd3 100644 --- a/secretstores/aws/secretmanager/secretmanager.go +++ b/secretstores/aws/secretmanager/secretmanager.go @@ -40,16 +40,18 @@ func NewSecretManager(logger logger.Logger) secretstores.SecretStore { } type SecretManagerMetaData struct { - Region string `json:"region" mapstructure:"region" mdignore:"true"` - AccessKey string `json:"accessKey" mapstructure:"accessKey" mdignore:"true"` - SecretKey string `json:"secretKey" mapstructure:"secretKey" mdignore:"true"` - SessionToken string `json:"sessionToken" mapstructure:"sessionToken" mdignore:"true"` - Endpoint string `json:"endpoint" mapstructure:"endpoint"` + Region string `json:"region" mapstructure:"region" mdignore:"true"` + AccessKey string `json:"accessKey" mapstructure:"accessKey" mdignore:"true"` + SecretKey string `json:"secretKey" mapstructure:"secretKey" mdignore:"true"` + SessionToken string `json:"sessionToken" mapstructure:"sessionToken" mdignore:"true"` + Endpoint string `json:"endpoint" mapstructure:"endpoint"` + MultipleKeyValuesPerSecret bool `json:"multipleKeyValuesPerSecret" mapstructure:"multipleKeyValuesPerSecret"` } type smSecretStore struct { - authProvider awsAuth.Provider - logger logger.Logger + authProvider awsAuth.Provider + logger logger.Logger + multipleKeyValuesPerSecret bool } // Init creates an AWS secret manager client. @@ -67,6 +69,7 @@ func (s *smSecretStore) Init(ctx context.Context, metadata secretstores.Metadata SessionToken: meta.SessionToken, Endpoint: meta.Endpoint, } + s.multipleKeyValuesPerSecret = meta.MultipleKeyValuesPerSecret provider, err := awsAuth.NewProvider(ctx, opts, awsAuth.GetConfig(opts)) if err != nil { @@ -76,6 +79,40 @@ func (s *smSecretStore) Init(ctx context.Context, metadata secretstores.Metadata return nil } +func convertMapAnyToString(m map[string]any) map[string]string { + result := make(map[string]string, len(m)) + for k, v := range m { + switch v := v.(type) { + case string: + result[k] = v + default: + jVal, _ := json.Marshal(v) + result[k] = string(jVal) + } + } + return result +} + +func (s *smSecretStore) formatSecret(output *secretsmanager.GetSecretValueOutput) map[string]string { + result := map[string]string{} + + if output.Name != nil && output.SecretString != nil { + if s.multipleKeyValuesPerSecret { + data := map[string]any{} + if err := json.Unmarshal([]byte(*output.SecretString), &data); err != nil { + result[*output.Name] = *output.SecretString + } else { + // In case of a nested JSON value, we need to stringify it + result = convertMapAnyToString(data) + } + } else { + result[*output.Name] = *output.SecretString + } + } + + return result +} + // GetSecret retrieves a secret using a key and returns a map of decrypted string/string values. func (s *smSecretStore) GetSecret(ctx context.Context, req secretstores.GetSecretRequest) (secretstores.GetSecretResponse, error) { var versionID *string @@ -98,9 +135,7 @@ func (s *smSecretStore) GetSecret(ctx context.Context, req secretstores.GetSecre resp := secretstores.GetSecretResponse{ Data: map[string]string{}, } - if output.Name != nil && output.SecretString != nil { - resp.Data[*output.Name] = *output.SecretString - } + resp.Data = s.formatSecret(output) return resp, nil } @@ -131,9 +166,7 @@ func (s *smSecretStore) BulkGetSecret(ctx context.Context, req secretstores.Bulk return secretstores.BulkGetSecretResponse{Data: nil}, fmt.Errorf("couldn't get secret: %s", *entry.Name) } - if entry.Name != nil && secrets.SecretString != nil { - resp.Data[*entry.Name] = map[string]string{*entry.Name: *secrets.SecretString} - } + resp.Data[*entry.Name] = s.formatSecret(secrets) } nextToken = output.NextToken @@ -160,7 +193,11 @@ func (s *smSecretStore) getSecretManagerMetadata(spec secretstores.Metadata) (*S // Features returns the features available in this secret store. func (s *smSecretStore) Features() []secretstores.Feature { - return []secretstores.Feature{} // No Feature supported. + if s.multipleKeyValuesPerSecret { + return []secretstores.Feature{secretstores.FeatureMultipleKeyValuesPerSecret} + } + + return []secretstores.Feature{} } func (s *smSecretStore) GetComponentMetadata() (metadataInfo metadata.MetadataMap) { diff --git a/secretstores/aws/secretmanager/secretmanager_test.go b/secretstores/aws/secretmanager/secretmanager_test.go index fce8169f62..01fc3dbac1 100644 --- a/secretstores/aws/secretmanager/secretmanager_test.go +++ b/secretstores/aws/secretmanager/secretmanager_test.go @@ -162,6 +162,161 @@ func TestGetSecret(t *testing.T) { require.NoError(t, e) assert.Equal(t, secretValue, output.Data[req.Name]) }) + + t.Run("with multiple keys per secret", func(t *testing.T) { + mockSSM := &awsAuth.MockSecretManager{ + GetSecretValueFn: func(ctx context.Context, input *secretsmanager.GetSecretValueInput, option ...request.Option) (*secretsmanager.GetSecretValueOutput, error) { + assert.Nil(t, input.VersionId) + assert.Nil(t, input.VersionStage) + // #nosec G101: This is a mock secret used for testing purposes. + secret := `{"key1":"value1","key2":"value2","key3":{"nested":"value3"}}` + + return &secretsmanager.GetSecretValueOutput{ + Name: input.SecretId, + SecretString: &secret, + }, nil + }, + } + + secret := awsAuth.SecretManagerClients{ + Manager: mockSSM, + } + + mockedClients := awsAuth.Clients{ + Secret: &secret, + } + mockAuthProvider := &awsAuth.StaticAuth{} + mockAuthProvider.WithMockClients(&mockedClients) + s := smSecretStore{ + authProvider: mockAuthProvider, + multipleKeyValuesPerSecret: true, + } + + req := secretstores.GetSecretRequest{ + Name: "/aws/secret/testing", + Metadata: map[string]string{}, + } + output, e := s.GetSecret(t.Context(), req) + require.NoError(t, e) + assert.Len(t, output.Data, 3) + assert.Equal(t, "value1", output.Data["key1"]) + assert.Equal(t, "value2", output.Data["key2"]) + assert.JSONEq(t, `{"nested":"value3"}`, output.Data["key3"]) + }) + + t.Run("with multiple keys per secret and option disabled", func(t *testing.T) { + mockSSM := &awsAuth.MockSecretManager{ + GetSecretValueFn: func(ctx context.Context, input *secretsmanager.GetSecretValueInput, option ...request.Option) (*secretsmanager.GetSecretValueOutput, error) { + assert.Nil(t, input.VersionId) + assert.Nil(t, input.VersionStage) + // #nosec G101: This is a mock secret used for testing purposes. + secret := `{"key1":"value1","key2":"value2"}` + + return &secretsmanager.GetSecretValueOutput{ + Name: input.SecretId, + SecretString: &secret, + }, nil + }, + } + + secret := awsAuth.SecretManagerClients{ + Manager: mockSSM, + } + + mockedClients := awsAuth.Clients{ + Secret: &secret, + } + mockAuthProvider := &awsAuth.StaticAuth{} + mockAuthProvider.WithMockClients(&mockedClients) + s := smSecretStore{ + authProvider: mockAuthProvider, + } + + req := secretstores.GetSecretRequest{ + Name: "/aws/secret/testing", + Metadata: map[string]string{}, + } + output, e := s.GetSecret(t.Context(), req) + require.NoError(t, e) + assert.Len(t, output.Data, 1) + assert.JSONEq(t, `{"key1":"value1","key2":"value2"}`, output.Data["/aws/secret/testing"]) + }) + + t.Run("with multiple keys per secret and secret is NOT json", func(t *testing.T) { + mockSSM := &awsAuth.MockSecretManager{ + GetSecretValueFn: func(ctx context.Context, input *secretsmanager.GetSecretValueInput, option ...request.Option) (*secretsmanager.GetSecretValueOutput, error) { + assert.Nil(t, input.VersionId) + assert.Nil(t, input.VersionStage) + secret := "not json" + + return &secretsmanager.GetSecretValueOutput{ + Name: input.SecretId, + SecretString: &secret, + }, nil + }, + } + + secret := awsAuth.SecretManagerClients{ + Manager: mockSSM, + } + + mockedClients := awsAuth.Clients{ + Secret: &secret, + } + mockAuthProvider := &awsAuth.StaticAuth{} + mockAuthProvider.WithMockClients(&mockedClients) + s := smSecretStore{ + authProvider: mockAuthProvider, + multipleKeyValuesPerSecret: true, + } + + req := secretstores.GetSecretRequest{ + Name: "/aws/secret/testing", + Metadata: map[string]string{}, + } + output, e := s.GetSecret(t.Context(), req) + require.NoError(t, e) + assert.Len(t, output.Data, 1) + assert.Equal(t, "not json", output.Data["/aws/secret/testing"]) + }) + + t.Run("with multiple keys per secret and secret is json collection", func(t *testing.T) { + mockSSM := &awsAuth.MockSecretManager{ + GetSecretValueFn: func(ctx context.Context, input *secretsmanager.GetSecretValueInput, option ...request.Option) (*secretsmanager.GetSecretValueOutput, error) { + assert.Nil(t, input.VersionId) + assert.Nil(t, input.VersionStage) + secret := `[{"key1":"value1"},{"key2":"value2"}]` // #nosec G101: This is a mock secret used for testing purposes. + + return &secretsmanager.GetSecretValueOutput{ + Name: input.SecretId, + SecretString: &secret, + }, nil + }, + } + + secret := awsAuth.SecretManagerClients{ + Manager: mockSSM, + } + + mockedClients := awsAuth.Clients{ + Secret: &secret, + } + mockAuthProvider := &awsAuth.StaticAuth{} + mockAuthProvider.WithMockClients(&mockedClients) + s := smSecretStore{ + authProvider: mockAuthProvider, + multipleKeyValuesPerSecret: true, + } + + req := secretstores.GetSecretRequest{ + Name: "/aws/secret/testing", + Metadata: map[string]string{}, + } + output, e := s.GetSecret(t.Context(), req) + require.NoError(t, e) + assert.Len(t, output.Data, 1) + assert.JSONEq(t, `[{"key1":"value1"},{"key2":"value2"}]`, output.Data["/aws/secret/testing"]) + }) }) t.Run("unsuccessfully retrieve secret", func(t *testing.T) { @@ -194,9 +349,152 @@ func TestGetSecret(t *testing.T) { }) } +func TestBulkGetSecret(t *testing.T) { + t.Run("returns all secrets in store", func(t *testing.T) { + secret1 := "/aws/secret/testing1" + secretValue1 := "secret1" + secret2 := "/aws/secret/testing2" + secretValue2 := "secret2" + + mockSSM := &awsAuth.MockSecretManager{ + GetSecretValueFn: func(ctx context.Context, input *secretsmanager.GetSecretValueInput, option ...request.Option) (*secretsmanager.GetSecretValueOutput, error) { + assert.Nil(t, input.VersionId) + assert.Nil(t, input.VersionStage) + + if input.SecretId == &secret1 { + return &secretsmanager.GetSecretValueOutput{ + Name: input.SecretId, + SecretString: &secretValue1, + }, nil + } else { + return &secretsmanager.GetSecretValueOutput{ + Name: input.SecretId, + SecretString: &secretValue2, + }, nil + } + }, + + ListSecretsFn: func(ctx context.Context, input *secretsmanager.ListSecretsInput, option ...request.Option) (*secretsmanager.ListSecretsOutput, error) { + return &secretsmanager.ListSecretsOutput{ + SecretList: []*secretsmanager.SecretListEntry{ + {Name: &secret1}, + {Name: &secret2}, + }, + }, nil + }, + } + + secret := awsAuth.SecretManagerClients{ + Manager: mockSSM, + } + + mockedClients := awsAuth.Clients{ + Secret: &secret, + } + mockAuthProvider := &awsAuth.StaticAuth{} + mockAuthProvider.WithMockClients(&mockedClients) + s := smSecretStore{ + authProvider: mockAuthProvider, + } + + req := secretstores.BulkGetSecretRequest{ + Metadata: map[string]string{}, + } + output, e := s.BulkGetSecret(t.Context(), req) + require.NoError(t, e) + assert.Equal(t, map[string]map[string]string{ + secret1: { + secret1: secretValue1, + }, + secret2: { + secret2: secretValue2, + }, + }, output.Data) + }) + + t.Run("when multipleKeyValuesPerSecret = true, returns all secrets in store broken out by key", func(t *testing.T) { + secret1 := "/aws/secret/testing1" + // #nosec G101: This is a mock secret used for testing purposes. + secretValue1 := `{"key1":"value1","key2":"value2"}` + secret2 := "/aws/secret/testing2" + // #nosec G101: This is a mock secret used for testing purposes. + secretValue2 := `{"key3":"value3","key4":{"nested":"value4"}}` + + mockSSM := &awsAuth.MockSecretManager{ + GetSecretValueFn: func(ctx context.Context, input *secretsmanager.GetSecretValueInput, option ...request.Option) (*secretsmanager.GetSecretValueOutput, error) { + assert.Nil(t, input.VersionId) + assert.Nil(t, input.VersionStage) + + if input.SecretId == &secret1 { + return &secretsmanager.GetSecretValueOutput{ + Name: input.SecretId, + SecretString: &secretValue1, + }, nil + } else { + return &secretsmanager.GetSecretValueOutput{ + Name: input.SecretId, + SecretString: &secretValue2, + }, nil + } + }, + + ListSecretsFn: func(ctx context.Context, input *secretsmanager.ListSecretsInput, option ...request.Option) (*secretsmanager.ListSecretsOutput, error) { + return &secretsmanager.ListSecretsOutput{ + SecretList: []*secretsmanager.SecretListEntry{ + {Name: &secret1}, + {Name: &secret2}, + }, + }, nil + }, + } + + secret := awsAuth.SecretManagerClients{ + Manager: mockSSM, + } + + mockedClients := awsAuth.Clients{ + Secret: &secret, + } + mockAuthProvider := &awsAuth.StaticAuth{} + mockAuthProvider.WithMockClients(&mockedClients) + s := smSecretStore{ + authProvider: mockAuthProvider, + multipleKeyValuesPerSecret: true, + } + + req := secretstores.BulkGetSecretRequest{ + Metadata: map[string]string{}, + } + output, e := s.BulkGetSecret(t.Context(), req) + require.NoError(t, e) + assert.Equal(t, map[string]map[string]string{ + secret1: { + "key1": "value1", + "key2": "value2", + }, + secret2: { + "key3": "value3", + "key4": `{"nested":"value4"}`, + }, + }, output.Data) + }) +} + func TestGetFeatures(t *testing.T) { s := smSecretStore{} - t.Run("no features are advertised", func(t *testing.T) { + t.Run("when multipleKeyValuesPerSecret = true, return feature", func(t *testing.T) { + s.multipleKeyValuesPerSecret = true + f := s.Features() + assert.True(t, secretstores.FeatureMultipleKeyValuesPerSecret.IsPresent(f)) + }) + + t.Run("when multipleKeyValuesPerSecret = false, no feature advertised", func(t *testing.T) { + s.multipleKeyValuesPerSecret = false + f := s.Features() + assert.Empty(t, f) + }) + + t.Run("by default, no feature advertised", func(t *testing.T) { f := s.Features() assert.Empty(t, f) })