From f7c5709046cb7454b35ae5fa74b248bdf3de8ab4 Mon Sep 17 00:00:00 2001 From: Chris Marchesi Date: Thu, 30 Sep 2021 17:05:27 -0700 Subject: [PATCH] WIP - more unit testing --- .gitignore | 1 + plugin/plugin.go | 175 ---------------------------------------- plugin/state.go | 188 +++++++++++++++++++++++++++++++++++++++++++ plugin/state_test.go | 110 +++++++++++++++++++++++++ 4 files changed, 299 insertions(+), 175 deletions(-) create mode 100644 plugin/state.go create mode 100644 plugin/state_test.go diff --git a/.gitignore b/.gitignore index 5f164bc..f48b334 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .terraform* terraform.tfstate* plan.tfplan +*.log diff --git a/plugin/plugin.go b/plugin/plugin.go index a92130b..826aa8e 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -9,11 +9,8 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/aws/endpoints" - "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/ec2" - "github.com/aws/aws-sdk-go/service/iam" pb "github.com/hashicorp/boundary/sdk/pbs/plugin" - "github.com/hashicorp/go-secure-stdlib/awsutil" "google.golang.org/protobuf/types/known/structpb" ) @@ -455,178 +452,6 @@ func (p *AwsPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*p }, nil } -type awsCatalogPersistedState struct { - AccessKeyId string - SecretAccessKey string - CredsLastRotatedTime time.Time -} - -func awsCatalogPersistedStateFromProto(in *pb.HostCatalogPersisted) (*awsCatalogPersistedState, error) { - data := in.GetSecrets() - if data == nil { - return nil, errors.New("missing persisted secrets") - } - - accessKeyId, err := getStringValue(data, constAccessKeyId, true) - if err != nil { - return nil, fmt.Errorf("persisted state integrity error: %w", err) - } - - secretAccessKey, err := getStringValue(data, constSecretAccessKey, true) - if err != nil { - return nil, fmt.Errorf("persisted state integrity error: %w", err) - } - - credsLastRotatedTime, err := getTimeValue(data, constCredsLastRotatedTime) - if err != nil { - return nil, fmt.Errorf("persisted state integrity error: %w", err) - } - - return &awsCatalogPersistedState{ - AccessKeyId: accessKeyId, - SecretAccessKey: secretAccessKey, - CredsLastRotatedTime: credsLastRotatedTime, - }, nil -} - -func (s *awsCatalogPersistedState) ToProto() (*pb.HostCatalogPersisted, error) { - data, err := structpb.NewStruct(map[string]interface{}{ - constAccessKeyId: s.AccessKeyId, - constSecretAccessKey: s.SecretAccessKey, - constCredsLastRotatedTime: s.CredsLastRotatedTime.Format(time.RFC3339Nano), - }) - if err != nil { - return nil, fmt.Errorf("error converting state to structpb.Struct: %w", err) - } - - return &pb.HostCatalogPersisted{Secrets: data}, nil -} - -func (s *awsCatalogPersistedState) ValidateCreds() error { - c, err := awsutil.NewCredentialsConfig( - awsutil.WithAccessKey(s.AccessKeyId), - awsutil.WithSecretKey(s.SecretAccessKey), - ) - if err != nil { - return fmt.Errorf("error loading credentials: %w", err) - } - - if _, err := c.GetCallerIdentity(); err != nil { - return fmt.Errorf("error validating credentials: %w", err) - } - - return nil -} - -func (s *awsCatalogPersistedState) RotateCreds() error { - c, err := awsutil.NewCredentialsConfig( - awsutil.WithAccessKey(s.AccessKeyId), - awsutil.WithSecretKey(s.SecretAccessKey), - ) - if err != nil { - return fmt.Errorf("error loading credentials: %w", err) - } - - if err := c.RotateKeys(awsutil.WithValidityCheckTimeout(rotationWaitTimeout)); err != nil { - return fmt.Errorf("error rotating credentials: %w", err) - } - - s.AccessKeyId = c.AccessKey - s.SecretAccessKey = c.SecretKey - s.CredsLastRotatedTime = time.Now() - - return nil -} - -// ReplaceCreds replaces the access key in the state with a new key. -// If the existing key was rotated at any point in time, it is -// deleted first, otherwise it's left alone. -func (s *awsCatalogPersistedState) ReplaceCreds(accessKeyId, secretAccessKey string) error { - if accessKeyId == "" { - return errors.New("access key id cannot be empty") - } - - if secretAccessKey == "" { - return errors.New("secret access key cannot be empty") - } - - if accessKeyId == s.AccessKeyId { - return errors.New("attempting to replace access key with the same one") - } - - if !s.CredsLastRotatedTime.IsZero() { - // Delete old/existing credentials. This is done with the same - // credentials to ensure that it has the proper permissions to do - // it. - if err := s.DeleteCreds(); err != nil { - return err - } - } - - // Set the new attributes and clear the rotated time. - s.AccessKeyId = accessKeyId - s.SecretAccessKey = secretAccessKey - s.CredsLastRotatedTime = time.Time{} - return nil -} - -// DeleteCreds deletes the credentials in the state. The access key -// ID, secret access key, and rotation time fields are zeroed out in -// the state just to ensure that they cannot be re-used after. -func (s *awsCatalogPersistedState) DeleteCreds() error { - c, err := awsutil.NewCredentialsConfig( - awsutil.WithAccessKey(s.AccessKeyId), - awsutil.WithSecretKey(s.SecretAccessKey), - ) - if err != nil { - return fmt.Errorf("error loading credentials: %w", err) - } - - if err := c.DeleteAccessKey(s.AccessKeyId); err != nil { - // Determine if the deletion error was due to a missing - // resource. If it was, just pass it. - if awsErr, ok := err.(awserr.Error); ok && awsErr.Code() == iam.ErrCodeNoSuchEntityException { - s.AccessKeyId = "" - s.SecretAccessKey = "" - s.CredsLastRotatedTime = time.Time{} - return nil - } - - // Otherwise treat it as an actual error. - return fmt.Errorf("error deleting old access key: %w", err) - } - - s.AccessKeyId = "" - s.SecretAccessKey = "" - s.CredsLastRotatedTime = time.Time{} - return nil -} - -// GetSession returns a configured AWS session for the credentials in -// the state. -func (s *awsCatalogPersistedState) GetSession() (*session.Session, error) { - c, err := awsutil.NewCredentialsConfig( - awsutil.WithAccessKey(s.AccessKeyId), - awsutil.WithSecretKey(s.SecretAccessKey), - ) - if err != nil { - return nil, err - } - - return c.GetSession() -} - -// EC2Client returns a configured EC2 client based on the session -// information stored in the state. -func (s *awsCatalogPersistedState) EC2Client(region string) (*ec2.EC2, error) { - sess, err := s.GetSession() - if err != nil { - return nil, fmt.Errorf("error getting AWS session: %w", err) - } - - return ec2.New(sess, aws.NewConfig().WithRegion(region)), nil -} - func getStringValue(in *structpb.Struct, k string, required bool) (string, error) { mv := in.AsMap() v, ok := mv[k] diff --git a/plugin/state.go b/plugin/state.go new file mode 100644 index 0000000..2f2d7e6 --- /dev/null +++ b/plugin/state.go @@ -0,0 +1,188 @@ +package plugin + +import ( + "errors" + "fmt" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/aws/aws-sdk-go/service/iam" + pb "github.com/hashicorp/boundary/sdk/pbs/plugin" + "github.com/hashicorp/go-secure-stdlib/awsutil" + "google.golang.org/protobuf/types/known/structpb" +) + +type awsCatalogPersistedState struct { + AccessKeyId string + SecretAccessKey string + CredsLastRotatedTime time.Time +} + +func awsCatalogPersistedStateFromProto(in *pb.HostCatalogPersisted) (*awsCatalogPersistedState, error) { + data := in.GetSecrets() + if data == nil { + return nil, errors.New("missing persisted secrets") + } + + accessKeyId, err := getStringValue(data, constAccessKeyId, true) + if err != nil { + return nil, fmt.Errorf("persisted state integrity error: %w", err) + } + + secretAccessKey, err := getStringValue(data, constSecretAccessKey, true) + if err != nil { + return nil, fmt.Errorf("persisted state integrity error: %w", err) + } + + credsLastRotatedTime, err := getTimeValue(data, constCredsLastRotatedTime) + if err != nil { + return nil, fmt.Errorf("persisted state integrity error: %w", err) + } + + return &awsCatalogPersistedState{ + AccessKeyId: accessKeyId, + SecretAccessKey: secretAccessKey, + CredsLastRotatedTime: credsLastRotatedTime, + }, nil +} + +func (s *awsCatalogPersistedState) ToProto() (*pb.HostCatalogPersisted, error) { + data, err := structpb.NewStruct(map[string]interface{}{ + constAccessKeyId: s.AccessKeyId, + constSecretAccessKey: s.SecretAccessKey, + constCredsLastRotatedTime: s.CredsLastRotatedTime.Format(time.RFC3339Nano), + }) + if err != nil { + return nil, fmt.Errorf("error converting state to structpb.Struct: %w", err) + } + + return &pb.HostCatalogPersisted{Secrets: data}, nil +} + +func (s *awsCatalogPersistedState) ValidateCreds() error { + c, err := awsutil.NewCredentialsConfig( + awsutil.WithAccessKey(s.AccessKeyId), + awsutil.WithSecretKey(s.SecretAccessKey), + ) + if err != nil { + return fmt.Errorf("error loading credentials: %w", err) + } + + if _, err := c.GetCallerIdentity(); err != nil { + return fmt.Errorf("error validating credentials: %w", err) + } + + return nil +} + +func (s *awsCatalogPersistedState) RotateCreds() error { + c, err := awsutil.NewCredentialsConfig( + awsutil.WithAccessKey(s.AccessKeyId), + awsutil.WithSecretKey(s.SecretAccessKey), + ) + if err != nil { + return fmt.Errorf("error loading credentials: %w", err) + } + + if err := c.RotateKeys(awsutil.WithValidityCheckTimeout(rotationWaitTimeout)); err != nil { + return fmt.Errorf("error rotating credentials: %w", err) + } + + s.AccessKeyId = c.AccessKey + s.SecretAccessKey = c.SecretKey + s.CredsLastRotatedTime = time.Now() + + return nil +} + +// ReplaceCreds replaces the access key in the state with a new key. +// If the existing key was rotated at any point in time, it is +// deleted first, otherwise it's left alone. +func (s *awsCatalogPersistedState) ReplaceCreds(accessKeyId, secretAccessKey string) error { + if accessKeyId == "" { + return errors.New("access key id cannot be empty") + } + + if secretAccessKey == "" { + return errors.New("secret access key cannot be empty") + } + + if accessKeyId == s.AccessKeyId { + return errors.New("attempting to replace access key with the same one") + } + + if !s.CredsLastRotatedTime.IsZero() { + // Delete old/existing credentials. This is done with the same + // credentials to ensure that it has the proper permissions to do + // it. + if err := s.DeleteCreds(); err != nil { + return err + } + } + + // Set the new attributes and clear the rotated time. + s.AccessKeyId = accessKeyId + s.SecretAccessKey = secretAccessKey + s.CredsLastRotatedTime = time.Time{} + return nil +} + +// DeleteCreds deletes the credentials in the state. The access key +// ID, secret access key, and rotation time fields are zeroed out in +// the state just to ensure that they cannot be re-used after. +func (s *awsCatalogPersistedState) DeleteCreds() error { + c, err := awsutil.NewCredentialsConfig( + awsutil.WithAccessKey(s.AccessKeyId), + awsutil.WithSecretKey(s.SecretAccessKey), + ) + if err != nil { + return fmt.Errorf("error loading credentials: %w", err) + } + + if err := c.DeleteAccessKey(s.AccessKeyId); err != nil { + // Determine if the deletion error was due to a missing + // resource. If it was, just pass it. + if awsErr, ok := err.(awserr.Error); ok && awsErr.Code() == iam.ErrCodeNoSuchEntityException { + s.AccessKeyId = "" + s.SecretAccessKey = "" + s.CredsLastRotatedTime = time.Time{} + return nil + } + + // Otherwise treat it as an actual error. + return fmt.Errorf("error deleting old access key: %w", err) + } + + s.AccessKeyId = "" + s.SecretAccessKey = "" + s.CredsLastRotatedTime = time.Time{} + return nil +} + +// GetSession returns a configured AWS session for the credentials in +// the state. +func (s *awsCatalogPersistedState) GetSession() (*session.Session, error) { + c, err := awsutil.NewCredentialsConfig( + awsutil.WithAccessKey(s.AccessKeyId), + awsutil.WithSecretKey(s.SecretAccessKey), + ) + if err != nil { + return nil, err + } + + return c.GetSession() +} + +// EC2Client returns a configured EC2 client based on the session +// information stored in the state. +func (s *awsCatalogPersistedState) EC2Client(region string) (*ec2.EC2, error) { + sess, err := s.GetSession() + if err != nil { + return nil, fmt.Errorf("error getting AWS session: %w", err) + } + + return ec2.New(sess, aws.NewConfig().WithRegion(region)), nil +} diff --git a/plugin/state_test.go b/plugin/state_test.go new file mode 100644 index 0000000..3840ced --- /dev/null +++ b/plugin/state_test.go @@ -0,0 +1,110 @@ +package plugin + +import ( + "testing" + "time" + + pb "github.com/hashicorp/boundary/sdk/pbs/plugin" + "github.com/stretchr/testify/require" +) + +func TestAwsCatalogPersistedStateFromProto(t *testing.T) { + cases := []struct { + name string + in *pb.HostCatalogPersisted + expected *awsCatalogPersistedState + expectedErr string + }{ + { + name: "no secrets", + in: &pb.HostCatalogPersisted{ + Secrets: nil, + }, + expectedErr: "missing persisted secrets", + }, + { + name: "missing access key ID", + in: &pb.HostCatalogPersisted{ + // Nil should be the same as empty map here + Secrets: mustStruct(nil), + }, + expectedErr: "persisted state integrity error: missing required value \"access_key_id\"", + }, + { + name: "missing secret access key", + in: &pb.HostCatalogPersisted{ + // Nil should be the same as empty map here + Secrets: mustStruct(map[string]interface{}{ + constAccessKeyId: "foobar", + }), + }, + expectedErr: "persisted state integrity error: missing required value \"secret_access_key\"", + }, + { + name: "bad last rotated time", + in: &pb.HostCatalogPersisted{ + // Nil should be the same as empty map here + Secrets: mustStruct(map[string]interface{}{ + constAccessKeyId: "foobar", + constSecretAccessKey: "bazqux", + constCredsLastRotatedTime: "notatime", + }), + }, + expectedErr: "persisted state integrity error: could not parse time in value \"creds_last_rotated_time\": parsing time \"notatime\" as \"2006-01-02T15:04:05.999999999Z07:00\": cannot parse \"notatime\" as \"2006\"", + }, + { + name: "good with non-zero timestamp", + in: &pb.HostCatalogPersisted{ + // Nil should be the same as empty map here + Secrets: mustStruct(map[string]interface{}{ + constAccessKeyId: "foobar", + constSecretAccessKey: "bazqux", + constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", + }), + }, + expected: &awsCatalogPersistedState{ + AccessKeyId: "foobar", + SecretAccessKey: "bazqux", + CredsLastRotatedTime: func() time.Time { + t, err := time.Parse(time.RFC3339, "2006-01-02T15:04:05+07:00") + if err != nil { + panic(err) + } + + return t + }(), + }, + }, + { + name: "good with zero timestamp", + in: &pb.HostCatalogPersisted{ + // Nil should be the same as empty map here + Secrets: mustStruct(map[string]interface{}{ + constAccessKeyId: "foobar", + constSecretAccessKey: "bazqux", + constCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano), + }), + }, + expected: &awsCatalogPersistedState{ + AccessKeyId: "foobar", + SecretAccessKey: "bazqux", + CredsLastRotatedTime: time.Time{}, + }, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + require := require.New(t) + actual, err := awsCatalogPersistedStateFromProto(tc.in) + if tc.expectedErr != "" { + require.EqualError(err, tc.expectedErr) + return + } + + require.NoError(err) + require.Equal(tc.expected, actual) + }) + } +}