Skip to content

Commit

Permalink
feat: add alicloud secrets manager based secret store provider (#665)
Browse files Browse the repository at this point in the history
* feat: add alicloud secrets manager based secret store provider

* test: add unit tests for alicloud secrets manager provider
  • Loading branch information
liu-hm19 committed Dec 14, 2023
1 parent 3d09a64 commit 4a15a1e
Show file tree
Hide file tree
Showing 7 changed files with 434 additions and 0 deletions.
20 changes: 20 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,25 @@ require (
sigs.k8s.io/controller-runtime v0.15.1
)

require (
github.com/alibabacloud-go/darabonba-array v0.1.0 // indirect
github.com/alibabacloud-go/darabonba-encode-util v0.0.2 // indirect
github.com/alibabacloud-go/darabonba-map v0.0.2 // indirect
github.com/alibabacloud-go/darabonba-string v1.0.2 // indirect
github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68 // indirect
github.com/alibabacloud-go/openapi-util v0.1.0 // indirect
github.com/alibabacloud-go/tea v1.2.1 // indirect
github.com/alibabacloud-go/tea-utils v1.3.1 // indirect
github.com/alibabacloud-go/tea-utils/v2 v2.0.3 // indirect
github.com/aliyun/alibaba-cloud-sdk-go v1.61.1800 // indirect
github.com/aliyun/alibabacloud-dkms-gcs-go-sdk v0.5.1 // indirect
github.com/aliyun/alibabacloud-dkms-transfer-go-sdk v0.1.8 // indirect
github.com/deckarep/golang-set v1.7.1 // indirect
github.com/orcaman/concurrent-map v0.0.0-20210501183033-44dafcb38ecc // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
gopkg.in/ini.v1 v1.66.2 // indirect
)

require (
atomicgo.dev/cursor v0.1.1 // indirect
atomicgo.dev/keyboard v0.2.9 // indirect
Expand All @@ -85,6 +104,7 @@ require (
github.com/acomagu/bufpipe v1.0.4 // indirect
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da // indirect
github.com/agext/levenshtein v1.2.1 // indirect
github.com/aliyun/aliyun-secretsmanager-client-go v1.1.4
github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.16.6 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.6 // indirect
Expand Down
69 changes: 69 additions & 0 deletions go.sum

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions pkg/apis/secrets/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,21 @@ type SecretStoreSpec struct {

// ProviderSpec contains provider-specific configuration.
type ProviderSpec struct {
// Alicloud configures a store to retrieve secrets from Alicloud Secrets Manager.
Alicloud *AlicloudProvider `yaml:"alicloud,omitempty" json:"alicloud,omitempty"`
// AWS configures a store to retrieve secrets from AWS Secrets Manager.
AWS *AWSProvider `yaml:"aws,omitempty" json:"aws,omitempty"`
// Vault configures a store to retrieve secrets from HashiCorp Vault.
Vault *VaultProvider `yaml:"vault,omitempty" json:"vault,omitempty"`
}

// AlicloudProvider configures a store to retrieve secrets from Alicloud Secrets Manager.
type AlicloudProvider struct {
// Alicloud Region to be used to interact with Alicloud Secrets Manager.
// Examples are cn-beijing, cn-shanghai, etc.
Region string `yaml:"region" json:"region"`
}

// AWSProvider configures a store to retrieve secrets from AWS Secrets Manager.
type AWSProvider struct {
// AWS Region to be used to interact with AWS Secrets Manager.
Expand Down
38 changes: 38 additions & 0 deletions pkg/secrets/providers/alicloud/secretsmanager/fake/fake.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package fake

import (
"github.com/aliyun/aliyun-secretsmanager-client-go/sdk/models"
)

type (
GetSecretInfoFn func(secretName string) (*models.SecretInfo, error)
SecretsManagerClient struct {
GetSecretInfoFn GetSecretInfoFn
}
)

func NewGetSecretInfoFn(secretValue interface{}, secretDataType string, err error) GetSecretInfoFn {
return func(secretName string) (*models.SecretInfo, error) {
if secretValue == nil {
return nil, err
}
if secretDataType == "text" {
secretString := secretValue.(string)
return &models.SecretInfo{
SecretValue: secretString,
}, err
}
if secretDataType == "binary" {
secretBinary := secretValue.([]byte)
return &models.SecretInfo{
SecretValueByteBuffer: secretBinary,
}, err
}

return nil, err
}
}

func (sc *SecretsManagerClient) GetSecretInfo(secretName string) (*models.SecretInfo, error) {
return sc.GetSecretInfoFn(secretName)
}
10 changes: 10 additions & 0 deletions pkg/secrets/providers/alicloud/secretsmanager/interface.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package secretsmanager

import (
"github.com/aliyun/aliyun-secretsmanager-client-go/sdk/models"
)

// Client is a testable interface for making operations call for Alicloud Secrets Manager.
type Client interface {
GetSecretInfo(secretName string) (*models.SecretInfo, error)
}
121 changes: 121 additions & 0 deletions pkg/secrets/providers/alicloud/secretsmanager/secretsmanager.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package secretsmanager

import (
"context"
"fmt"
"os"
"strings"

secretsapi "kusionstack.io/kusion/pkg/apis/secrets"
"kusionstack.io/kusion/pkg/secrets"

"github.com/aliyun/aliyun-secretsmanager-client-go/sdk"
"github.com/aliyun/aliyun-secretsmanager-client-go/sdk/models"
"github.com/aliyun/aliyun-secretsmanager-client-go/sdk/service"
"github.com/tidwall/gjson"
)

const (
errMissingProviderSpec = "store spec is missing provider"
errMissingAlicloudProvider = "invalid provider spec. Missing Alicloud field in store provider spec"
errFailedToCreateClient = "failed to create Alicloud Secrets Manager client: %w"
)

var (
accessKeyID = os.Getenv("credentials_access_key_id")
accessKeySecret = os.Getenv("credentials_access_secret")
)

// DefaultFactory should implement the secrets.SecretStoreFactory interface.
var _ secrets.SecretStoreFactory = &DefaultFactory{}

// smSecretStore should implement the secrets.SecretStore interface.
var _ secrets.SecretStore = &smSecretStore{}

// DefaultFactory implements the secrets.SecretStoreFactory interface.
type DefaultFactory struct{}

// smSecretStore implements the secrets.SecretStore interface.
type smSecretStore struct {
client Client
}

// NewSecretStore constructs a Vault based secret store with specific secret store spec.
func (p *DefaultFactory) NewSecretStore(spec secretsapi.SecretStoreSpec) (secrets.SecretStore, error) {
providerSpec := spec.Provider
if providerSpec == nil {
return nil, fmt.Errorf(errMissingProviderSpec)
}
if providerSpec.Alicloud == nil {
return nil, fmt.Errorf(errMissingAlicloudProvider)
}

client, err := getAlicloudClient(providerSpec.Alicloud.Region)
if err != nil {
return nil, fmt.Errorf(errFailedToCreateClient, err)
}

return &smSecretStore{
client: client,
}, nil
}

// getAlicloudClient returns an Alicloud Secrets Manager client with the specified region.
// Ref: https://github.com/aliyun/aliyun-secretsmanager-client-go/blob/v1.1.4/README.md
func getAlicloudClient(region string) (*sdk.SecretManagerCacheClient, error) {
return sdk.NewSecretCacheClientBuilder(
service.NewDefaultSecretManagerClientBuilder().Standard().WithAccessKey(
accessKeyID, accessKeySecret,
).WithRegion(region).Build()).Build()
}

// GetSecret retrieves ref secret value from Alicloud Secrets Manager.
func (s *smSecretStore) GetSecret(ctx context.Context, ref secretsapi.ExternalSecretRef) ([]byte, error) {
secretInfo, err := s.client.GetSecretInfo(ref.Name)
if err != nil {
return nil, err
}
if ref.Property == "" {
if secretInfo.SecretValue != "" {
return []byte(secretInfo.SecretValue), nil
}
if secretInfo.SecretValueByteBuffer != nil {
return secretInfo.SecretValueByteBuffer, nil
}
return nil, fmt.Errorf("invalid secret data. no secret value string nor binary for key: %s", ref.Name)
}
val := s.convertSecretToGjson(secretInfo, ref.Property)
if !val.Exists() {
return nil, fmt.Errorf("key %s does not exist in secret %s", ref.Property, ref.Name)
}
return []byte(val.String()), nil
}

func (s *smSecretStore) convertSecretToGjson(secretInfo *models.SecretInfo, refProperty string) gjson.Result {
var payload string
if secretInfo.SecretValue != "" {
payload = secretInfo.SecretValue
}
if secretInfo.SecretValueByteBuffer != nil {
payload = string(secretInfo.SecretValueByteBuffer)
}

// We need to search if a given key with a . exists before using gjson operations.
idx := strings.Index(refProperty, ".")
currentRefProperty := refProperty
if idx > -1 {
currentRefProperty = strings.ReplaceAll(refProperty, ".", "\\.")
val := gjson.Get(payload, currentRefProperty)
if !val.Exists() {
currentRefProperty = refProperty
}
}

return gjson.Get(payload, currentRefProperty)
}

func init() {
secrets.SecretStoreProviders.Register(&DefaultFactory{}, &secretsapi.ProviderSpec{
Alicloud: &secretsapi.AlicloudProvider{},
})
}
167 changes: 167 additions & 0 deletions pkg/secrets/providers/alicloud/secretsmanager/secretsmanager_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package secretsmanager

import (
"context"
"errors"
"fmt"
"reflect"
"testing"

"github.com/google/go-cmp/cmp"

secretsapi "kusionstack.io/kusion/pkg/apis/secrets"
"kusionstack.io/kusion/pkg/secrets/providers/alicloud/secretsmanager/fake"
)

func TestGetSecret(t *testing.T) {
testCases := map[string]struct {
client Client
name string
property string
expected []byte
expectedErr error
}{
"GetSecret": {
client: &fake.SecretsManagerClient{
GetSecretInfoFn: fake.NewGetSecretInfoFn("t0p-Secret", "text", nil),
},
name: "/beep",
expected: []byte(`t0p-Secret`),
expectedErr: nil,
},
"GetSecret_With_Property": {
client: &fake.SecretsManagerClient{
GetSecretInfoFn: fake.NewGetSecretInfoFn(`{"bar": "bang"}`, "text", nil),
},
name: "/beep",
property: "bar",
expected: []byte(`bang`),
expectedErr: nil,
},
"GetSecret_With_NestedProperty": {
client: &fake.SecretsManagerClient{
GetSecretInfoFn: fake.NewGetSecretInfoFn(`{"foobar":{"bar":"bang"}}`, "text", nil),
},
name: "/beep",
property: "foobar.bar",
expected: []byte(`bang`),
expectedErr: nil,
},
"GetSecret_With_Binary": {
client: &fake.SecretsManagerClient{
GetSecretInfoFn: fake.NewGetSecretInfoFn([]byte(`t0p-Secret`), "binary", nil),
},
name: "/beep",
expected: []byte(`t0p-Secret`),
expectedErr: nil,
},
"GetSecret_With_Property_Binary": {
client: &fake.SecretsManagerClient{
GetSecretInfoFn: fake.NewGetSecretInfoFn([]byte(`{"bar":"bang"}`), "binary", nil),
},
name: "/beep",
property: "bar",
expected: []byte(`bang`),
expectedErr: nil,
},
"GetSecret_With_NestedProperty_Binary": {
client: &fake.SecretsManagerClient{
GetSecretInfoFn: fake.NewGetSecretInfoFn([]byte(`{"foobar":{"bar":"bang"}}`), "binary", nil),
},
name: "/beep",
property: "foobar.bar",
expected: []byte(`bang`),
expectedErr: nil,
},
"GetSecret_With_Error": {
client: &fake.SecretsManagerClient{
GetSecretInfoFn: fake.NewGetSecretInfoFn([]byte(`{"foobar":{"bar":"bang"}}`), "binary", errors.New("internal error")),
},
name: "/beep",
expected: nil,
expectedErr: errors.New("internal error"),
},
"GetSecret_Property_NotFound": {
client: &fake.SecretsManagerClient{
GetSecretInfoFn: fake.NewGetSecretInfoFn([]byte(`{"foobar":{"bar":"bang"}}`), "binary", nil),
},
name: "/beep",
property: "foobar.baz",
expected: nil,
expectedErr: fmt.Errorf("key foobar.baz does not exist in secret /beep"),
},
}

for name, tc := range testCases {
store := &smSecretStore{client: tc.client}
ref := secretsapi.ExternalSecretRef{
Name: tc.name,
Property: tc.property,
}
if name != "GetSecret_Property_NotFound" {
continue
}
actual, err := store.GetSecret(context.TODO(), ref)
if diff := cmp.Diff(err, tc.expectedErr, EquateErrors()); diff != "" {
t.Errorf("\n%s\ngot unexpected error:\n%s", name, diff)
}
if diff := cmp.Diff(string(actual), string(tc.expected)); diff != "" {
fmt.Println(diff)
t.Errorf("\n%s\nget unexpected data: \n%s", name, diff)
}
}
}

func TestNewSecretStore(t *testing.T) {
testCases := map[string]struct {
spec secretsapi.SecretStoreSpec
expectedErr error
}{
"InvalidSecretStoreSpec": {
spec: secretsapi.SecretStoreSpec{},
expectedErr: errors.New(errMissingProviderSpec),
},
"InvalidProviderSpec": {
spec: secretsapi.SecretStoreSpec{
Provider: &secretsapi.ProviderSpec{},
},
expectedErr: errors.New(errMissingAlicloudProvider),
},
"ValidVaultProviderSpec": {
spec: secretsapi.SecretStoreSpec{
Provider: &secretsapi.ProviderSpec{
Alicloud: &secretsapi.AlicloudProvider{
Region: "cn-beijing",
},
},
},
expectedErr: nil,
},
}

factory := DefaultFactory{}
for name, tc := range testCases {
_, err := factory.NewSecretStore(tc.spec)
if diff := cmp.Diff(err, tc.expectedErr, EquateErrors()); diff != "" {
t.Errorf("\n%s\ngot unexpected error: \n%s", name, diff)
}
}
}

// EquateErrors returns true if the supplied errors are of the same type and
// produce same error message.
func EquateErrors() cmp.Option {
return cmp.Comparer(func(a, b error) bool {
if a == nil || b == nil {
return a == nil && b == nil
}

av := reflect.ValueOf(a)
bv := reflect.ValueOf(b)
if av.Type() != bv.Type() {
return false
}

return a.Error() == b.Error()
})
}

0 comments on commit 4a15a1e

Please sign in to comment.