Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add alicloud secrets manager based secret store provider #665

Merged
merged 2 commits into from
Dec 14, 2023
Merged
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
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()
})
}
Loading