diff --git a/secretstores/Readme.md b/secretstores/Readme.md index 5408c3fc7..41b9bdc4b 100644 --- a/secretstores/Readme.md +++ b/secretstores/Readme.md @@ -6,6 +6,7 @@ Currently supported secret stores are: * Kubernetes * Azure KeyVault +* AWS Secret manager ## Implementing a new Secret Store diff --git a/secretstores/aws/secretmanager/secretmanager.go b/secretstores/aws/secretmanager/secretmanager.go new file mode 100644 index 000000000..c38ad80b9 --- /dev/null +++ b/secretstores/aws/secretmanager/secretmanager.go @@ -0,0 +1,113 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// ------------------------------------------------------------ + +package secretmanager + +import ( + "encoding/json" + "fmt" + + "github.com/aws/aws-sdk-go/service/secretsmanager" + "github.com/aws/aws-sdk-go/service/secretsmanager/secretsmanageriface" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/dapr/components-contrib/secretstores" +) + +const ( + VersionID = "VersionID" + VersionStage = "VersionStage" +) + +// NewSecretManager returns a new secret manager store +func NewSecretManager() secretstores.SecretStore { + return &smSecretStore{} +} + +type secretManagerMetaData struct { + Region string `json:"region"` + AccessKey string `json:"accessKey"` + SecretKey string `json:"secretKey"` + SessionToken string `json:"sessionToken"` +} + +type smSecretStore struct { + client secretsmanageriface.SecretsManagerAPI +} + +// Init creates a AWS secret manager client +func (s *smSecretStore) Init(metadata secretstores.Metadata) error { + meta, err := s.getSecretManagerMetadata(metadata) + if err != nil { + return err + } + + client, err := s.getClient(meta) + if err != nil { + return err + } + s.client = client + return nil +} + +// GetSecret retrieves a secret using a key and returns a map of decrypted string/string values +func (s *smSecretStore) GetSecret(req secretstores.GetSecretRequest) (secretstores.GetSecretResponse, error) { + var versionID *string + if value, ok := req.Metadata[VersionID]; ok { + versionID = &value + } + var versionStage *string + if value, ok := req.Metadata[VersionStage]; ok { + versionStage = &value + } + + output, err := s.client.GetSecretValue(&secretsmanager.GetSecretValueInput{ + SecretId: &req.Name, + VersionId: versionID, + VersionStage: versionStage, + }) + + if err != nil { + return secretstores.GetSecretResponse{Data: nil}, fmt.Errorf("couldn't get secret: %s", err) + } + + resp := secretstores.GetSecretResponse{ + Data: map[string]string{}, + } + if output.Name != nil && output.SecretString != nil { + resp.Data[*output.Name] = *output.SecretString + } + return resp, nil +} + +func (s *smSecretStore) getClient(metadata *secretManagerMetaData) (*secretsmanager.SecretsManager, error) { + sess, err := session.NewSession(aws.NewConfig(). + WithRegion(*aws.String(metadata.Region)). + WithCredentials(credentials.NewStaticCredentials(metadata.AccessKey, metadata.SecretKey, metadata.SessionToken))) + + if err != nil { + return nil, err + } + return secretsmanager.New(sess), nil +} + +func (s *smSecretStore) getSecretManagerMetadata(spec secretstores.Metadata) (*secretManagerMetaData, error) { + b, err := json.Marshal(spec.Properties) + if err != nil { + return nil, err + } + + var meta secretManagerMetaData + err = json.Unmarshal(b, &meta) + if err != nil { + return nil, err + } + if meta.SecretKey == "" || meta.AccessKey == "" || meta.Region == "" || meta.SessionToken == "" { + return nil, fmt.Errorf("missing aws credentials in metadata") + } + return &meta, nil +} diff --git a/secretstores/aws/secretmanager/secretmanager_integ_test.go b/secretstores/aws/secretmanager/secretmanager_integ_test.go new file mode 100644 index 000000000..9a9cf2d2d --- /dev/null +++ b/secretstores/aws/secretmanager/secretmanager_integ_test.go @@ -0,0 +1,36 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// ------------------------------------------------------------ +// +build integration + +package secretmanager + +import ( + "github.com/dapr/components-contrib/secretstores" + "github.com/stretchr/testify/assert" + "os" + "testing" +) + +// TestIntegrationGetSecret requires AWS specific environments for authentication AWS_DEFAULT_REGION AWS_ACCESS_KEY_ID, +// AWS_SECRET_ACCESS_KkEY and AWS_SESSION_TOKEN +func TestIntegrationGetSecret(t *testing.T) { + secretName := "/aws/secret/testing" + sm := NewSecretManager() + err := sm.Init(secretstores.Metadata{ + Properties: map[string]string{ + "Region": os.Getenv("AWS_DEFAULT_REGION"), + "AccessKey": os.Getenv("AWS_ACCESS_KEY_ID"), + "SecretKey": os.Getenv("AWS_SECRET_ACCESS_KEY"), + "SessionToken": os.Getenv("AWS_SESSION_TOKEN"), + }, + }) + assert.Nil(t, err) + response, err := sm.GetSecret(secretstores.GetSecretRequest{ + Name: secretName, + Metadata: map[string]string{}, + }) + assert.Nil(t, err) + assert.NotNil(t, response) +} diff --git a/secretstores/aws/secretmanager/secretmanager_test.go b/secretstores/aws/secretmanager/secretmanager_test.go new file mode 100644 index 000000000..c0340812d --- /dev/null +++ b/secretstores/aws/secretmanager/secretmanager_test.go @@ -0,0 +1,145 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// ------------------------------------------------------------ +package secretmanager + +import ( + "fmt" + "testing" + + "github.com/aws/aws-sdk-go/service/secretsmanager" + "github.com/aws/aws-sdk-go/service/secretsmanager/secretsmanageriface" + + "github.com/dapr/components-contrib/secretstores" + "github.com/stretchr/testify/assert" +) + +const secretValue = "secret" + +type mockedSM struct { + GetSecretValueFn func(*secretsmanager.GetSecretValueInput) (*secretsmanager.GetSecretValueOutput, error) + secretsmanageriface.SecretsManagerAPI +} + +func (m *mockedSM) GetSecretValue(input *secretsmanager.GetSecretValueInput) (*secretsmanager.GetSecretValueOutput, error) { + return m.GetSecretValueFn(input) +} + +func TestInit(t *testing.T) { + m := secretstores.Metadata{} + s := NewSecretManager() + t.Run("Init with valid metadata", func(t *testing.T) { + m.Properties = map[string]string{ + "AccessKey": "a", + "Region": "a", + "SecretKey": "a", + "SessionToken": "a", + } + err := s.Init(m) + assert.Nil(t, err) + }) + + t.Run("Init with missing metadata", func(t *testing.T) { + m.Properties = map[string]string{ + "Dummy": "a", + } + err := s.Init(m) + assert.NotNil(t, err) + assert.Equal(t, err, fmt.Errorf("missing aws credentials in metadata")) + }) +} + +func TestGetSecret(t *testing.T) { + t.Run("successfully retrieve secret", func(t *testing.T) { + t.Run("without version id and version stage", func(t *testing.T) { + s := smSecretStore{ + client: &mockedSM{ + GetSecretValueFn: func(input *secretsmanager.GetSecretValueInput) (*secretsmanager.GetSecretValueOutput, error) { + assert.Nil(t, input.VersionId) + assert.Nil(t, input.VersionStage) + secret := secretValue + return &secretsmanager.GetSecretValueOutput{ + Name: input.SecretId, + SecretString: &secret, + }, nil + }, + }, + } + + req := secretstores.GetSecretRequest{ + Name: "/aws/secret/testing", + Metadata: map[string]string{}, + } + output, e := s.GetSecret(req) + assert.Nil(t, e) + assert.Equal(t, "secret", output.Data[req.Name]) + }) + + t.Run("with version id", func(t *testing.T) { + s := smSecretStore{ + client: &mockedSM{ + GetSecretValueFn: func(input *secretsmanager.GetSecretValueInput) (*secretsmanager.GetSecretValueOutput, error) { + assert.NotNil(t, input.VersionId) + secret := secretValue + return &secretsmanager.GetSecretValueOutput{ + Name: input.SecretId, + SecretString: &secret, + }, nil + }, + }, + } + + req := secretstores.GetSecretRequest{ + Name: "/aws/secret/testing", + Metadata: map[string]string{ + VersionID: "1", + }, + } + output, e := s.GetSecret(req) + assert.Nil(t, e) + assert.Equal(t, secretValue, output.Data[req.Name]) + }) + + t.Run("with version stage", func(t *testing.T) { + s := smSecretStore{ + client: &mockedSM{ + GetSecretValueFn: func(input *secretsmanager.GetSecretValueInput) (*secretsmanager.GetSecretValueOutput, error) { + assert.NotNil(t, input.VersionStage) + secret := secretValue + return &secretsmanager.GetSecretValueOutput{ + Name: input.SecretId, + SecretString: &secret, + }, nil + }, + }, + } + + req := secretstores.GetSecretRequest{ + Name: "/aws/secret/testing", + Metadata: map[string]string{ + VersionStage: "dev", + }, + } + output, e := s.GetSecret(req) + assert.Nil(t, e) + assert.Equal(t, secretValue, output.Data[req.Name]) + }) + }) + + t.Run("unsuccessfully retrieve secret", func(t *testing.T) { + s := smSecretStore{ + client: &mockedSM{ + GetSecretValueFn: func(input *secretsmanager.GetSecretValueInput) (*secretsmanager.GetSecretValueOutput, error) { + return nil, fmt.Errorf("failed due to any reason") + }, + }, + } + req := secretstores.GetSecretRequest{ + Name: "/aws/secret/testing", + Metadata: map[string]string{}, + } + _, err := s.GetSecret(req) + assert.NotNil(t, err) + }) +}