diff --git a/.github/jira.yml b/.github/workflows/jira.yaml similarity index 100% rename from .github/jira.yml rename to .github/workflows/jira.yaml diff --git a/.github/tests.yaml b/.github/workflows/tests.yaml similarity index 100% rename from .github/tests.yaml rename to .github/workflows/tests.yaml diff --git a/Makefile b/Makefile index 13a5261..561cb33 100644 --- a/Makefile +++ b/Makefile @@ -36,4 +36,13 @@ fmtcheck: .PHONY: fmt fmt: - gofumpt -l -w . \ No newline at end of file + gofumpt -l -w . + +.PHONY: setup-env +setup-env: + cd bootstrap/terraform && terraform init && terraform apply -auto-approve + + +.PHONY: teardown-env +teardown-env: + cd bootstrap/terraform && terraform init && terraform destroy -auto-approve \ No newline at end of file diff --git a/README.md b/README.md index 4639ff6..47dcda7 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,8 @@ If you wish to work on this plugin, you'll first need Make sure Go is properly installed, including setting up a [GOPATH](https://golang.org/doc/code.html#GOPATH). -To run the tests locally you will need to have write permissions to an [ElastiCache for Redis](https://aws.amazon.com/elasticache/redis/) instance. +To run the tests locally you will need to have write permissions to an [ElastiCache for Redis](https://aws.amazon.com/elasticache/redis/) instance. +A small Terraform project is included to provision one for you if needed. More details in the [Environment Set Up](#environment-set-up) section. ## Building @@ -56,6 +57,35 @@ $ make dev ## Tests +### Environment Set Up + +To test the plugin, you need access to an Elasticache for Redis Cluster. +A Terraform project is included for convenience to initialize a new cluster if needed. +If not already available, you can install Terraform by using [this documentation](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html). + +The setup script tries to find and use available AWS credentials from the environment. You can configure AWS credentials using [this documentation](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html). +Or if you prefer you can edit the provider defined ./bootstrap/terraform/elasticache.tf with your desired set of credentials. + +Note that resources created via the Terraform project cost a small amount of money per hour. + +To set up the test cluster: + +```hcl +$ make set-up-env +... +Apply complete! Resources: 4 added, 0 changed, 0 destroyed. +``` + +### Environment Teardown + +The test cluster created via the set-up-env command can be destroyed using the teardown-env command. + +```hcl +$ make teardown-env +... +Destroy complete! Resources: 4 destroyed. +``` + ### Testing Manually Put the plugin binary into a location of your choice. This directory @@ -114,9 +144,9 @@ Configure a role: ```sh $ vault write database/roles/redis-myrole \ db_name="redis-mydb" \ - creation_statements=$CREATION_STATEMENTS \ - default_ttl=$DEFAULT_TTL \ - max_ttl=$MAX_TTL + creation_statements="on ~* +@all" \ + default_ttl=5m \ + max_ttl=15m ... Success! Data written to: database/roles/redis-myrole @@ -151,3 +181,25 @@ You can also specify a `TESTARGS` variable to filter tests like so: ```sh $ make test TESTARGS='-run=TestConfig' ``` + +### Acceptance Tests + +The majority of tests must communicate with an existing ElastiCache instance. See the [Environment Set Up](#environment-set-up) section for instructions on how to prepare a test cluster. + +Some environment variables are required to run tests expecting to communicate with an ElastiCache cluster. +The username and password should be valid IAM access key and secret key with read and write access to the ElastiCache cluster used for testing. The URL should be the complete configuration endpoint including the port, for example: `vault-plugin-elasticache-test.id.xxx.use1.cache.amazonaws.com:6379`. + +```sh +$ export TEST_ELASTICACHE_USERNAME="AWS ACCESS KEY ID" +$ export TEST_ELASTICACHE_PASSWORD="AWS SECRET ACCESS KEY" +$ export TEST_ELASTICACHE_URL="vault-plugin-elasticache-test.id.xxx.use1.cache.amazonaws.com:6379" +$ export TEST_ELASTICACHE_REGION="us-east-1" + +$ make test +``` + +You can also specify a `TESTARGS` variable to filter tests like so: + +```sh +$ make test TESTARGS='-run=TestConfig' +``` \ No newline at end of file diff --git a/bootstrap/terraform/elasticache.tf b/bootstrap/terraform/elasticache.tf new file mode 100644 index 0000000..ac020bd --- /dev/null +++ b/bootstrap/terraform/elasticache.tf @@ -0,0 +1,78 @@ +provider "aws" { + // Credentials and configuration derived from the environment + // Uncomment if you wish to configure the provider explicitly + + // access_key = "" + // secret_key = "" + // region = "" +} + +resource "aws_elasticache_cluster" "vault_plugin_elasticache_test" { + cluster_id = "vault-plugin-elasticache-test" + engine = "redis" + engine_version = "6.2" + node_type = "cache.t4g.micro" + num_cache_nodes = 1 + parameter_group_name = "default.redis6.x" + + tags = { + "description" : "vault elasticache plugin generated test cluster" + } +} + +resource "aws_iam_user" "vault_plugin_elasticache_test" { + name = "vault-plugin-elasticache-user-test" + + tags = { + "description" : "vault elasticache plugin generated test user" + } +} + +resource "aws_iam_access_key" "vault_plugin_elasticache_test" { + user = aws_iam_user.vault_plugin_elasticache_test.name +} + +resource "aws_iam_user_policy" "vault_plugin_elasticache_test" { + name = "vault-plugin-elasticache-policy-test" + user = aws_iam_user.vault_plugin_elasticache_test.name + + policy = data.aws_iam_policy_document.vault_plugin_elasticache_test.json +} + +data "aws_iam_policy_document" "vault_plugin_elasticache_test" { + statement { + actions = [ + "elasticache:DescribeUsers", + "elasticache:CreateUser", + "elasticache:ModifyUser", + "elasticache:DeleteUser", + ] + resources = ["arn:aws:elasticache:*:*:user:*"] + } +} + +// export TEST_ELASTICACHE_USERNAME=${username} +output "username" { + value = aws_iam_access_key.vault_plugin_elasticache_test.id +} + +// export TEST_ELASTICACHE_PASSWORD=${password} +// Use `terraform output password` to access the value +output "password" { + sensitive = true + value = aws_iam_access_key.vault_plugin_elasticache_test.secret +} + +// export TEST_ELASTICACHE_URL=${url} +output "url" { + value = format( + "%s:%s", + aws_elasticache_cluster.vault_plugin_elasticache_test.cache_nodes[0].address, + aws_elasticache_cluster.vault_plugin_elasticache_test.port) +} + +// export TEST_ELASTICACHE_REGION=${region} +data "aws_region" "current" {} +output "region" { + value = data.aws_region.current.name +} diff --git a/cmd/vault-plugin-database-redis-elasticache/main.go b/cmd/vault-plugin-database-redis-elasticache/main.go index bd49749..ad8aaef 100644 --- a/cmd/vault-plugin-database-redis-elasticache/main.go +++ b/cmd/vault-plugin-database-redis-elasticache/main.go @@ -5,7 +5,6 @@ import ( "os" "github.com/hashicorp/vault-plugin-database-redis-elasticache/internal/plugin" - "github.com/hashicorp/vault/sdk/database/dbplugin/v5" ) @@ -18,10 +17,7 @@ func main() { // Run starts serving the plugin func Run() error { - db, err := plugin.New() - if err != nil { - return err - } - dbplugin.Serve(db.(dbplugin.Database)) + dbplugin.ServeMultiplex(plugin.New) + return nil } diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 5cea433..70cf631 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -24,7 +24,7 @@ type config struct { Region string `mapstructure:"region,omitempty"` } -func New() (dbplugin.Database, error) { +func New() (interface{}, error) { logger := hclog.New(&hclog.LoggerOptions{ Level: hclog.Trace, Output: os.Stderr, diff --git a/internal/plugin/redisElastiCacheClient_test.go b/internal/plugin/redisElastiCacheClient_test.go index 79575a5..86234dd 100644 --- a/internal/plugin/redisElastiCacheClient_test.go +++ b/internal/plugin/redisElastiCacheClient_test.go @@ -1,9 +1,423 @@ package plugin import ( + "context" + "os" + "reflect" + "strings" "testing" + "time" + + "github.com/aws/aws-sdk-go/service/elasticache" + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/vault/sdk/database/dbplugin/v5" ) +type fields struct { + logger hclog.Logger + config config + client *elasticache.ElastiCache +} + +type args struct { + ctx context.Context + req interface{} +} + +type testCases []struct { + name string + fields fields + args args + want interface{} + wantErr bool +} + +func skipIfEnvIsUnset(t *testing.T, config config) { + if config.Username == "" || config.Password == "" || config.Url == "" || config.Region == "" { + t.Skip("Skipping acceptance tests because required environment variables are not configured") + } +} + +func setUpEnvironment() (fields, map[string]interface{}, redisElastiCacheDB) { + username := os.Getenv("TEST_ELASTICACHE_USERNAME") + password := os.Getenv("TEST_ELASTICACHE_PASSWORD") + url := os.Getenv("TEST_ELASTICACHE_URL") + region := os.Getenv("TEST_ELASTICACHE_REGION") + + f := fields{ + logger: hclog.New(&hclog.LoggerOptions{ + Level: hclog.Trace, + Output: os.Stderr, + JSONFormat: true, + }), + config: config{ + Username: username, + Password: password, + Url: url, + Region: region, + }, + client: nil, + } + + c := map[string]interface{}{ + "username": username, + "password": password, + "url": url, + "region": region, + } + + r := redisElastiCacheDB{ + logger: f.logger, + config: f.config, + client: f.client, + } + + return f, c, r +} + +func setUpClient(t *testing.T, r *redisElastiCacheDB, config map[string]interface{}) { + _, err := r.Initialize(nil, dbplugin.InitializeRequest{ + Config: config, + VerifyConnection: true, + }) + if err != nil { + t.Errorf("unable to pre initialize redis client for test cases: %v", err) + } +} + +func setUpTestUser(t *testing.T, r *redisElastiCacheDB) string { + user, err := r.NewUser(nil, dbplugin.NewUserRequest{ + UsernameConfig: dbplugin.UsernameMetadata{ + DisplayName: "display", + RoleName: "role", + }, + Statements: dbplugin.Statements{ + Commands: []string{"on ~test* -@all +@read"}, + }, + Password: "abcdefghijklmnopqrstuvwxyz", + }) + if err != nil { + t.Errorf("unable to provision test user for test cases: %v", err) + } + + return user.Username +} + +func teardownTestUser(t *testing.T, r redisElastiCacheDB, username string) { + if username == "" { + return + } + + // Creating or Modifying users cannot be deleted until they return to Active status + for i := 0; i < 20; i++ { + _, err := r.DeleteUser(nil, dbplugin.DeleteUserRequest{ + Username: username, + }) + + if err == nil { + break + } else { + t.Logf("unable to clean test user '%s' due to: %v; retrying", username, err) + } + + time.Sleep(3 * time.Second) + } +} + +func Test_redisElastiCacheDB_Initialize(t *testing.T) { + f, c, r := setUpEnvironment() + skipIfEnvIsUnset(t, f.config) + + tests := testCases{ + { + name: "initialize and verify connection succeeds", + fields: f, + args: args{ + req: dbplugin.InitializeRequest{ + Config: c, + VerifyConnection: true, + }, + }, + want: dbplugin.InitializeResponse{ + Config: c, + }, + }, + { + name: "initialize with invalid config fails", + fields: f, + args: args{ + req: dbplugin.InitializeRequest{ + Config: map[string]interface{}{ + "username": "wrong", + "password": "wrong", + "url": "wrong", + "region": "wrong", + }, + VerifyConnection: true, + }, + }, + want: dbplugin.InitializeResponse{}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &r + got, err := r.Initialize(tt.args.ctx, tt.args.req.(dbplugin.InitializeRequest)) + if (err != nil) != tt.wantErr { + t.Errorf("Initialize() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Initialize() got = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_redisElastiCacheDB_NewUser(t *testing.T) { + f, c, r := setUpEnvironment() + + skipIfEnvIsUnset(t, f.config) + + setUpClient(t, &r, c) + + tests := testCases{ + { + name: "create new valid user succeeds", + fields: f, + args: args{ + ctx: context.Background(), + req: dbplugin.NewUserRequest{ + UsernameConfig: dbplugin.UsernameMetadata{ + DisplayName: "display", + RoleName: "role", + }, + Statements: dbplugin.Statements{ + Commands: []string{"on ~test* -@all +@read"}, + }, + Password: "abcdefghijklmnopqrstuvwxyz", + }, + }, + want: dbplugin.NewUserResponse{ + Username: "v_displ_role_", + }, + }, + { + name: "create new valid user from multiple commands", + fields: f, + args: args{ + ctx: context.Background(), + req: dbplugin.NewUserRequest{ + UsernameConfig: dbplugin.UsernameMetadata{ + DisplayName: "display", + RoleName: "role", + }, + Statements: dbplugin.Statements{ + Commands: []string{"on", "~test*", "-@all", "+@read"}, + }, + Password: "abcdefghijklmnopqrstuvwxyz", + }, + }, + want: dbplugin.NewUserResponse{ + Username: "v_displ_role_", + }, + }, + { + name: "create user truncates username", + fields: f, + args: args{ + ctx: context.Background(), + req: dbplugin.NewUserRequest{ + UsernameConfig: dbplugin.UsernameMetadata{ + DisplayName: "iAmSupeExtremelyLongThisWillHaveToBeTruncated", + RoleName: "iAmEvenLongerTheApiWillDefinitelyRejectUsIfWeArePassedAsIsWithoutAnyModifications", + }, + Statements: dbplugin.Statements{ + Commands: []string{"on ~test* -@all +@read"}, + }, + Password: "abcdefghijklmnopqrstuvwxyz", + }, + }, + want: dbplugin.NewUserResponse{ + Username: "v_iAmSu_iAmEvenLongerTheApiWillDefinitelyRejec", + }, + }, + { + name: "create user with invalid password fails", + fields: f, + args: args{ + ctx: context.Background(), + req: dbplugin.NewUserRequest{ + UsernameConfig: dbplugin.UsernameMetadata{ + DisplayName: "display", + RoleName: "role", + }, + Statements: dbplugin.Statements{ + Commands: []string{"+@all"}, + }, + Password: "too short", + }, + }, + want: dbplugin.NewUserResponse{}, + wantErr: true, + }, + { + name: "create user with invalid statements fails", + fields: f, + args: args{ + ctx: context.Background(), + req: dbplugin.NewUserRequest{ + UsernameConfig: dbplugin.UsernameMetadata{ + DisplayName: "display", + RoleName: "role", + }, + Statements: dbplugin.Statements{ + Commands: []string{"+@invalid"}, + }, + Password: "abcdefghijklmnopqrstuvwxyz", + }, + }, + want: dbplugin.NewUserResponse{}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := r.NewUser(tt.args.ctx, tt.args.req.(dbplugin.NewUserRequest)) + if (err != nil) != tt.wantErr { + t.Errorf("NewUser() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !strings.HasPrefix(got.Username, tt.want.(dbplugin.NewUserResponse).Username) { + t.Errorf("NewUser() got = %v, want %v", got, tt.want) + } + + teardownTestUser(t, r, got.Username) + }) + } +} + +func Test_redisElastiCacheDB_UpdateUser(t *testing.T) { + f, c, r := setUpEnvironment() + + skipIfEnvIsUnset(t, f.config) + + setUpClient(t, &r, c) + username := setUpTestUser(t, &r) + defer teardownTestUser(t, r, username) + + tests := testCases{ + { + name: "update password of existing user succeeds", + fields: f, + args: args{ + ctx: context.Background(), + req: dbplugin.UpdateUserRequest{ + Username: username, + CredentialType: 0, + Password: &dbplugin.ChangePassword{ + NewPassword: "abcdefghijklmnopqrstuvwxyz1", + }, + }, + }, + want: dbplugin.UpdateUserResponse{}, + }, + { + name: "update password of non-existing user fails", + fields: f, + args: args{ + ctx: context.Background(), + req: dbplugin.UpdateUserRequest{ + Username: "I do not exist", + CredentialType: 0, + Password: &dbplugin.ChangePassword{ + NewPassword: "abcdefghijklmnopqrstuvwxyz1", + }, + }, + }, + want: dbplugin.UpdateUserResponse{}, + wantErr: true, + }, + { + name: "update to invalid password fails", + fields: f, + args: args{ + ctx: context.Background(), + req: dbplugin.UpdateUserRequest{ + Username: username, + CredentialType: 0, + Password: &dbplugin.ChangePassword{ + NewPassword: "too short", + }, + }, + }, + want: dbplugin.UpdateUserResponse{}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := r.UpdateUser(tt.args.ctx, tt.args.req.(dbplugin.UpdateUserRequest)) + if (err != nil) != tt.wantErr { + t.Errorf("UpdateUser() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("UpdateUser() got = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_redisElastiCacheDB_DeleteUser(t *testing.T) { + f, c, r := setUpEnvironment() + + skipIfEnvIsUnset(t, f.config) + + setUpClient(t, &r, c) + username := setUpTestUser(t, &r) + + tests := testCases{ + { + name: "delete existing user succeeds", + fields: f, + args: args{ + ctx: context.Background(), + req: dbplugin.DeleteUserRequest{ + Username: username, + }, + }, + want: dbplugin.DeleteUserResponse{}, + wantErr: false, + }, + { + name: "delete non-existing user fails", + fields: f, + args: args{ + ctx: context.Background(), + req: dbplugin.DeleteUserRequest{ + Username: "I do not exist", + }, + }, + want: dbplugin.DeleteUserResponse{}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := r.DeleteUser(tt.args.ctx, tt.args.req.(dbplugin.DeleteUserRequest)) + if (err != nil) != tt.wantErr { + t.Errorf("DeleteUser() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("DeleteUser() got = %v, want %v", got, tt.want) + } + }) + } +} + func Test_generateUserId(t *testing.T) { type args struct { username string