From dd1ae9ad8c4968c57174467108a134bc327a6c47 Mon Sep 17 00:00:00 2001 From: Bill Havanki Date: Thu, 1 Aug 2024 16:43:57 -0400 Subject: [PATCH] feat!: Add store configuration support for SSM store (#532) The new Config and SetConfig methods on the Store interface allow implementations to maintain their own configurations. Only the SSM store fully implements the methods; the others return an empty configuration and do not support setting one. The store configuration itself holds a list of required tags for each written secret. Enforcement of the tags is not yet implemented. Although the configuration struct is exported from its package, it is not part of chamber's client interface and is subject to change at any time. The SSM store implementation stores its configuration as a secret inside the newly reserved "_chamber" service as a JSON document. The schema is not exported, so users shouldn't build anything from it. --- store/nullstore.go | 10 ++++++ store/s3store.go | 10 ++++++ store/secretsmanagerstore.go | 10 ++++++ store/secretsmanagerstore_test.go | 10 ++++++ store/ssmstore.go | 44 ++++++++++++++++++++++++ store/ssmstore_test.go | 57 +++++++++++++++++++++++++++++++ store/store.go | 14 ++++++++ 7 files changed, 155 insertions(+) diff --git a/store/nullstore.go b/store/nullstore.go index 1750e3c..6e7bb9e 100644 --- a/store/nullstore.go +++ b/store/nullstore.go @@ -13,6 +13,16 @@ func NewNullStore() *NullStore { return &NullStore{} } +func (s *NullStore) Config(ctx context.Context) (StoreConfig, error) { + return StoreConfig{ + Version: LatestStoreConfigVersion, + }, nil +} + +func (s *NullStore) SetConfig(ctx context.Context, config StoreConfig) error { + return errors.New("SetConfig is not implemented for Null Store") +} + func (s *NullStore) Write(ctx context.Context, id SecretId, value string) error { return errors.New("Write is not implemented for Null Store") } diff --git a/store/s3store.go b/store/s3store.go index e11b38f..d1c5b98 100644 --- a/store/s3store.go +++ b/store/s3store.go @@ -72,6 +72,16 @@ func NewS3StoreWithBucket(ctx context.Context, numRetries int, bucket string) (* }, nil } +func (s *S3Store) Config(ctx context.Context) (StoreConfig, error) { + return StoreConfig{ + Version: LatestStoreConfigVersion, + }, nil +} + +func (s *S3Store) SetConfig(ctx context.Context, config StoreConfig) error { + return errors.New("Not implemented for S3 Store") +} + func (s *S3Store) Write(ctx context.Context, id SecretId, value string) error { index, err := s.readLatest(ctx, id.Service) if err != nil { diff --git a/store/secretsmanagerstore.go b/store/secretsmanagerstore.go index 05b20e5..3337c0e 100644 --- a/store/secretsmanagerstore.go +++ b/store/secretsmanagerstore.go @@ -114,6 +114,16 @@ func NewSecretsManagerStore(ctx context.Context, numRetries int) (*SecretsManage }, nil } +func (s *SecretsManagerStore) Config(ctx context.Context) (StoreConfig, error) { + return StoreConfig{ + Version: LatestStoreConfigVersion, + }, nil +} + +func (s *SecretsManagerStore) SetConfig(ctx context.Context, config StoreConfig) error { + return errors.New("Not implemented for Secrets Manager Store") +} + // Write writes a given value to a secret identified by id. If the secret // already exists, then write a new version. func (s *SecretsManagerStore) Write(ctx context.Context, id SecretId, value string) error { diff --git a/store/secretsmanagerstore_test.go b/store/secretsmanagerstore_test.go index 7406c9d..47028ce 100644 --- a/store/secretsmanagerstore_test.go +++ b/store/secretsmanagerstore_test.go @@ -477,3 +477,13 @@ func uniqueID() string { _, _ = rand.Read(uuid) return fmt.Sprintf("%x", uuid) } + +func TestSecretsManagerStoreConfig(t *testing.T) { + store := &SecretsManagerStore{} + + config, err := store.Config(context.Background()) + + assert.NoError(t, err) + assert.Equal(t, LatestStoreConfigVersion, config.Version) + assert.Empty(t, config.RequiredTags) +} diff --git a/store/ssmstore.go b/store/ssmstore.go index 107b9b3..aca5171 100644 --- a/store/ssmstore.go +++ b/store/ssmstore.go @@ -2,6 +2,7 @@ package store import ( "context" + "encoding/json" "errors" "fmt" "os" @@ -93,6 +94,49 @@ func (s *SSMStore) KMSKey() string { return fromEnv } +const ( + storeConfigKey = "store-config" +) + +var ( + storeConfigID = SecretId{ + Service: ChamberService, + Key: storeConfigKey, + } +) + +func (s *SSMStore) Config(ctx context.Context) (StoreConfig, error) { + configSecret, err := s.readLatest(ctx, storeConfigID) + if err != nil { + if err == ErrSecretNotFound { + return StoreConfig{ + Version: LatestStoreConfigVersion, + }, nil + } else { + return StoreConfig{}, err + } + } + + var config StoreConfig + if err := json.Unmarshal([]byte(*configSecret.Value), &config); err != nil { + return StoreConfig{}, fmt.Errorf("failed to unmarshal store config: %w", err) + } + return config, nil +} + +func (s *SSMStore) SetConfig(ctx context.Context, config StoreConfig) error { + configBytes, err := json.Marshal(config) + if err != nil { + return fmt.Errorf("failed to marshal store config: %w", err) + } + + err = s.write(ctx, storeConfigID, string(configBytes), nil) + if err != nil { + return fmt.Errorf("failed to write store config: %w", err) + } + return nil +} + // Write writes a given value to a secret identified by id. If the secret // already exists, then write a new version. func (s *SSMStore) Write(ctx context.Context, id SecretId, value string) error { diff --git a/store/ssmstore_test.go b/store/ssmstore_test.go index f95dd60..e4e69b1 100644 --- a/store/ssmstore_test.go +++ b/store/ssmstore_test.go @@ -3,6 +3,7 @@ package store import ( "context" "errors" + "fmt" "os" "sort" "strings" @@ -853,3 +854,59 @@ type ByKeyRaw []RawSecret func (a ByKeyRaw) Len() int { return len(a) } func (a ByKeyRaw) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a ByKeyRaw) Less(i, j int) bool { return a[i].Key < a[j].Key } + +func TestSSMStoreConfig(t *testing.T) { + storeConfigName := fmt.Sprintf("/%s/%s", ChamberService, storeConfigKey) + parameters := map[string]mockParameter{ + storeConfigName: { + currentParam: &types.Parameter{ + Name: aws.String(storeConfigName), + Type: types.ParameterTypeSecureString, + Value: aws.String(`{"version":"2","requiredTags":["key1", "key2"]}`), + }, + meta: &types.ParameterMetadata{ + Name: aws.String(storeConfigName), + Description: aws.String("1"), + LastModifiedDate: aws.Time(time.Now()), + LastModifiedUser: aws.String("test"), + }, + }, + } + store := NewTestSSMStore(parameters) + + config, err := store.Config(context.Background()) + + assert.NoError(t, err) + assert.Equal(t, "2", config.Version) + assert.Equal(t, []string{"key1", "key2"}, config.RequiredTags) +} + +func TestSSMStoreConfig_Missing(t *testing.T) { + parameters := map[string]mockParameter{} + store := NewTestSSMStore(parameters) + + config, err := store.Config(context.Background()) + + assert.NoError(t, err) + assert.Equal(t, LatestStoreConfigVersion, config.Version) + assert.Empty(t, config.RequiredTags) +} + +func TestSSMStoreSetConfig(t *testing.T) { + parameters := map[string]mockParameter{} + store := NewTestSSMStore(parameters) + + config := StoreConfig{ + Version: "2.1", + RequiredTags: []string{"key1.1", "key2.1"}, + } + err := store.SetConfig(context.Background(), config) + + assert.NoError(t, err) + + config, err = store.Config(context.Background()) + + assert.NoError(t, err) + assert.Equal(t, "2.1", config.Version) + assert.Equal(t, []string{"key1.1", "key2.1"}, config.RequiredTags) +} diff --git a/store/store.go b/store/store.go index f62495c..5710292 100644 --- a/store/store.go +++ b/store/store.go @@ -15,6 +15,18 @@ func ReservedService(service string) bool { return service == ChamberService } +const ( + LatestStoreConfigVersion = "1" +) + +// StoreConfig holds configuration information for a store. WARNING: Despite +// its public visibility, the contents of this struct are subject to change at +// any time, and are not part of the public interface for chamber. +type StoreConfig struct { + Version string `json:"version"` + RequiredTags []string `json:"requiredTags,omitempty"` +} + type ChangeEventType int const ( @@ -73,6 +85,8 @@ type ChangeEvent struct { // Store is an interface for a secret store. type Store interface { + Config(ctx context.Context) (StoreConfig, error) + SetConfig(ctx context.Context, config StoreConfig) error Write(ctx context.Context, id SecretId, value string) error WriteWithTags(ctx context.Context, id SecretId, value string, tags map[string]string) error Read(ctx context.Context, id SecretId, version int) (Secret, error)