diff --git a/testing/e2e_host_assumerole_test.go b/testing/e2e_host_assumerole_test.go new file mode 100644 index 0000000..6708976 --- /dev/null +++ b/testing/e2e_host_assumerole_test.go @@ -0,0 +1,273 @@ +// Copyright IBM Corp. 2021, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package testing + +// This file contains table-driven test infrastructure for AssumeRole and +// cross-role (two-hop) Dynamic Host Catalog scenarios. Each plugin operation +// has a corresponding case type and runner function. The orchestrator in +// e2e_host_test.go populates the case tables using the Terraform outputs. + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/hashicorp/boundary-plugin-aws/internal/credential" + "github.com/hashicorp/boundary-plugin-aws/plugin/service/host" + "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostcatalogs" + "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostsets" + pb "github.com/hashicorp/boundary/sdk/pbs/plugin" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" +) + +// --------------------------------------------------------------------------- +// Attr builders +// --------------------------------------------------------------------------- + +// roleArnAttrs builds catalog attributes for a single-hop AssumeRole catalog. +func roleArnAttrs(region, roleArn string) map[string]any { + return map[string]any{ + credential.ConstRegion: region, + credential.ConstDisableCredentialRotation: true, + credential.ConstRoleArn: roleArn, + } +} + +// crossRoleAttrs builds catalog attributes for a two-hop cross-role catalog. +func crossRoleAttrs(region, principalRoleArn, targetRoleArn string) map[string]any { + return map[string]any{ + credential.ConstRegion: region, + credential.ConstDisableCredentialRotation: true, + credential.ConstRoleArn: principalRoleArn, + credential.ConstTargetRoleArn: targetRoleArn, + } +} + +// tagFilterSetAttrs builds set attributes with a single tag-key filter. +func tagFilterSetAttrs(tags []string) map[string]any { + return map[string]any{ + host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("tag-key=%s", strings.Join(tags, ","))}, + } +} + +// --------------------------------------------------------------------------- +// Table-driven runners +// --------------------------------------------------------------------------- + +type onCreateCatalogCase struct { + name string + catalogAttrs map[string]any + wantErr string // empty = expect success with empty persisted secrets +} + +func testOnCreateCatalogCases(ctx context.Context, t *testing.T, p *host.HostPlugin, cases []onCreateCatalogCase) { + t.Helper() + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Helper() + require := require.New(t) + attrs, err := structpb.NewStruct(tc.catalogAttrs) + require.NoError(err) + resp, err := p.OnCreateCatalog(ctx, &pb.OnCreateCatalogRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{Attributes: attrs}, + }, + }) + if tc.wantErr != "" { + require.Error(err) + st, ok := status.FromError(err) + require.True(ok) + require.Contains(st.Message(), tc.wantErr) + return + } + require.NoError(err) + require.NotNil(resp) + // Dynamic credentials are never stored — persisted secrets should be empty. + require.Empty(resp.GetPersisted().GetSecrets().GetFields()) + }) + } +} + +type onUpdateCatalogCase struct { + name string + catalogAttrs map[string]any // used for both current and new when currentAttrs/newAttrs are nil + currentAttrs map[string]any // optional: override current catalog attrs + newAttrs map[string]any // optional: override new catalog attrs + wantErr string +} + +func testOnUpdateCatalogCases(ctx context.Context, t *testing.T, p *host.HostPlugin, cases []onUpdateCatalogCase) { + t.Helper() + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Helper() + require := require.New(t) + currentRaw := tc.catalogAttrs + if tc.currentAttrs != nil { + currentRaw = tc.currentAttrs + } + newRaw := tc.catalogAttrs + if tc.newAttrs != nil { + newRaw = tc.newAttrs + } + currentAttrs, err := structpb.NewStruct(currentRaw) + require.NoError(err) + newAttrs, err := structpb.NewStruct(newRaw) + require.NoError(err) + resp, err := p.OnUpdateCatalog(ctx, &pb.OnUpdateCatalogRequest{ + CurrentCatalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{Attributes: currentAttrs}, + }, + NewCatalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{Attributes: newAttrs}, + }, + Persisted: &pb.HostCatalogPersisted{}, + }) + if tc.wantErr != "" { + require.Error(err) + st, ok := status.FromError(err) + require.True(ok) + require.Contains(st.Message(), tc.wantErr) + return + } + require.NoError(err) + require.NotNil(resp) + // Dynamic credentials are never stored — persisted secrets should be empty. + require.Empty(resp.GetPersisted().GetSecrets().GetFields()) + }) + } +} + +type onSetCase struct { + name string + catalogAttrs map[string]any + tags []string // nil = no filter (empty set attrs) + wantErr string +} + +func testOnCreateSetCases(ctx context.Context, t *testing.T, p *host.HostPlugin, cases []onSetCase) { + t.Helper() + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Helper() + require := require.New(t) + catalogAttrs, err := structpb.NewStruct(tc.catalogAttrs) + require.NoError(err) + setRaw := map[string]any{host.ConstDescribeInstancesFilters: []any{}} + if len(tc.tags) > 0 { + setRaw = tagFilterSetAttrs(tc.tags) + } + setAttrs, err := structpb.NewStruct(setRaw) + require.NoError(err) + resp, err := p.OnCreateSet(ctx, &pb.OnCreateSetRequest{ + Catalog: &hostcatalogs.HostCatalog{Attrs: &hostcatalogs.HostCatalog_Attributes{Attributes: catalogAttrs}}, + Set: &hostsets.HostSet{Attrs: &hostsets.HostSet_Attributes{Attributes: setAttrs}}, + Persisted: &pb.HostCatalogPersisted{}, + }) + if tc.wantErr != "" { + require.Error(err) + st, ok := status.FromError(err) + require.True(ok) + require.Contains(st.Message(), tc.wantErr) + return + } + require.NoError(err) + require.NotNil(resp) + }) + } +} + +func testOnUpdateSetCases(ctx context.Context, t *testing.T, p *host.HostPlugin, cases []onSetCase) { + t.Helper() + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Helper() + require := require.New(t) + catalogAttrs, err := structpb.NewStruct(tc.catalogAttrs) + require.NoError(err) + setRaw := map[string]any{host.ConstDescribeInstancesFilters: []any{}} + if len(tc.tags) > 0 { + setRaw = tagFilterSetAttrs(tc.tags) + } + setAttrs, err := structpb.NewStruct(setRaw) + require.NoError(err) + set := &hostsets.HostSet{Attrs: &hostsets.HostSet_Attributes{Attributes: setAttrs}} + resp, err := p.OnUpdateSet(ctx, &pb.OnUpdateSetRequest{ + Catalog: &hostcatalogs.HostCatalog{Attrs: &hostcatalogs.HostCatalog_Attributes{Attributes: catalogAttrs}}, + CurrentSet: set, + NewSet: set, + Persisted: &pb.HostCatalogPersisted{}, + }) + if tc.wantErr != "" { + require.Error(err) + st, ok := status.FromError(err) + require.True(ok) + require.Contains(st.Message(), tc.wantErr) + return + } + require.NoError(err) + require.NotNil(resp) + }) + } +} + +type listHostsCase struct { + name string + catalogAttrs map[string]any + tags []string + expected map[string][]string // nil = don't assert instances (error cases) + wantErr string +} + +func testListHostsCases(ctx context.Context, t *testing.T, p *host.HostPlugin, cases []listHostsCase) { + t.Helper() + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Helper() + require := require.New(t) + catalogAttrs, err := structpb.NewStruct(tc.catalogAttrs) + require.NoError(err) + sets := make([]*hostsets.HostSet, len(tc.tags)) + for i, tag := range tc.tags { + setAttrs, err := structpb.NewStruct(map[string]any{ + host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("tag-key=%s", tag)}, + }) + require.NoError(err) + sets[i] = &hostsets.HostSet{ + Id: fmt.Sprintf("hostset-%d", i), + Attrs: &hostsets.HostSet_Attributes{Attributes: setAttrs}, + } + } + resp, err := p.ListHosts(ctx, &pb.ListHostsRequest{ + Catalog: &hostcatalogs.HostCatalog{Attrs: &hostcatalogs.HostCatalog_Attributes{Attributes: catalogAttrs}}, + Sets: sets, + Persisted: &pb.HostCatalogPersisted{}, + }) + if tc.wantErr != "" { + require.Error(err) + st, ok := status.FromError(err) + require.True(ok) + require.Contains(st.Message(), tc.wantErr) + return + } + require.NoError(err) + require.NotNil(resp) + expectedInstances := make(map[string][]string) + for i, tag := range tc.tags { + for _, instanceId := range tc.expected[tag] { + expectedInstances[instanceId] = append(expectedInstances[instanceId], fmt.Sprintf("hostset-%d", i)) + } + } + actualInstances := make(map[string][]string) + for _, h := range resp.GetHosts() { + actualInstances[h.ExternalId] = h.SetIds + } + require.Equal(expectedInstances, actualInstances) + t.Logf("success: %d hosts matched", len(actualInstances)) + }) + } +} diff --git a/testing/e2e_host_static_test.go b/testing/e2e_host_static_test.go new file mode 100644 index 0000000..386c0e1 --- /dev/null +++ b/testing/e2e_host_static_test.go @@ -0,0 +1,396 @@ +// Copyright IBM Corp. 2021, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package testing + +// This file contains helper functions for the static-credential (access key / +// secret access key) test paths exercised by TestHostPlugin in e2e_host_test.go. + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/hashicorp/boundary-plugin-aws/internal/credential" + "github.com/hashicorp/boundary-plugin-aws/plugin/service/host" + "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostcatalogs" + "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostsets" + pb "github.com/hashicorp/boundary/sdk/pbs/plugin" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" +) + +func testPluginOnCreateCatalog(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, rotate bool) (string, string) { + t.Helper() + t.Logf("testing OnCreateCatalog (region=%s, rotate=%t)", region, rotate) + require := require.New(t) + + reqAttrs, err := structpb.NewStruct(map[string]any{ + credential.ConstRegion: region, + credential.ConstDisableCredentialRotation: !rotate, + }) + require.NoError(err) + reqSecrets, err := structpb.NewStruct(map[string]any{ + credential.ConstAccessKeyId: accessKeyId, + credential.ConstSecretAccessKey: secretAccessKey, + }) + require.NoError(err) + request := &pb.OnCreateCatalogRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: reqAttrs, + }, + Secrets: reqSecrets, + }, + } + response, err := p.OnCreateCatalog(ctx, request) + require.NoError(err) + require.NotNil(response) + persisted := response.GetPersisted() + require.NotNil(persisted) + return validatePersistedSecrets(t, persisted.GetSecrets(), accessKeyId, secretAccessKey, rotate) +} + +func testPluginOnUpdateCatalog( + ctx context.Context, t *testing.T, p *host.HostPlugin, + region, currentAccessKeyId, currentSecretAccessKey, newAccessKeyId, newSecretAccessKey string, + rotated, rotate bool, +) (string, string) { + t.Helper() + t.Logf("testing OnUpdateCatalog (region=%s, newcreds=%t, rotated=%t, rotate=%t)", region, newAccessKeyId != "" && newSecretAccessKey != "", rotated, rotate) + require := require.New(t) + + // Take a timestamp of the current time to get a point in time to + // reference, ensuring that we are updating credential rotation + // timestamps. + currentCredsLastRotatedTime := time.Now() + + reqCurrentAttrs, err := structpb.NewStruct(map[string]any{ + credential.ConstRegion: region, + credential.ConstDisableCredentialRotation: !rotated, + }) + require.NoError(err) + reqNewAttrs, err := structpb.NewStruct(map[string]any{ + credential.ConstRegion: region, + credential.ConstDisableCredentialRotation: !rotate, + }) + require.NoError(err) + var reqSecrets *structpb.Struct + if newAccessKeyId != "" && newSecretAccessKey != "" { + reqSecrets, err = structpb.NewStruct(map[string]any{ + credential.ConstAccessKeyId: newAccessKeyId, + credential.ConstSecretAccessKey: newSecretAccessKey, + }) + require.NoError(err) + } + reqPersistedSecrets, err := structpb.NewStruct(map[string]any{ + credential.ConstAccessKeyId: currentAccessKeyId, + credential.ConstSecretAccessKey: currentSecretAccessKey, + credential.ConstCredsLastRotatedTime: func() string { + if rotated { + return currentCredsLastRotatedTime.Format(time.RFC3339Nano) + } + + return (time.Time{}).Format(time.RFC3339Nano) + }(), + }) + require.NoError(err) + require.NotNil(reqPersistedSecrets) + request := &pb.OnUpdateCatalogRequest{ + CurrentCatalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: reqCurrentAttrs, + }, + }, + NewCatalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: reqNewAttrs, + }, + Secrets: reqSecrets, + }, + Persisted: &pb.HostCatalogPersisted{ + Secrets: reqPersistedSecrets, + }, + } + response, err := p.OnUpdateCatalog(ctx, request) + require.NoError(err) + require.NotNil(response) + persisted := response.GetPersisted() + require.NotNil(persisted) + return validateUpdateSecrets(t, persisted.GetSecrets(), currentCredsLastRotatedTime, currentAccessKeyId, currentSecretAccessKey, newAccessKeyId, newSecretAccessKey, rotated, rotate) +} + +func testPluginOnDeleteCatalog(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, rotated bool) { + t.Helper() + t.Logf("testing OnDeleteCatalog (region=%s, rotated=%t)", region, rotated) + require := require.New(t) + + reqAttrs, err := structpb.NewStruct(map[string]any{ + credential.ConstRegion: region, + credential.ConstDisableCredentialRotation: !rotated, + }) + require.NoError(err) + reqSecrets, err := structpb.NewStruct(map[string]any{ + credential.ConstAccessKeyId: accessKeyId, + credential.ConstSecretAccessKey: secretAccessKey, + }) + require.NoError(err) + reqPersistedSecrets, err := structpb.NewStruct(map[string]any{ + credential.ConstAccessKeyId: accessKeyId, + credential.ConstSecretAccessKey: secretAccessKey, + credential.ConstCredsLastRotatedTime: func() string { + if rotated { + return time.Now().Format(time.RFC3339Nano) + } + + return (time.Time{}).Format(time.RFC3339Nano) + }(), + }) + require.NoError(err) + request := &pb.OnDeleteCatalogRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: reqAttrs, + }, + Secrets: reqSecrets, + }, + Persisted: &pb.HostCatalogPersisted{ + Secrets: reqPersistedSecrets, + }, + } + response, err := p.OnDeleteCatalog(ctx, request) + require.NoError(err) + require.NotNil(response) + + // We want to test the validity of the credentials post-deletion. + if rotated { + // The credentials should no longer be valid. + requireCredentialsInvalid(t, accessKeyId, secretAccessKey) + } else { + // The credentials should still be valid. Sleep 10s first just to + // be sure, since we're not rotating. + time.Sleep(time.Second * 10) + requireCredentialsValid(t, accessKeyId, secretAccessKey) + } +} + +func testPluginOnCreateUpdateSet(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, tags []string) { + t.Helper() + t.Logf("testing OnCreateSet (region=%s, tags=%v)", region, tags) + require := require.New(t) + catalogAttrs, err := structpb.NewStruct(map[string]any{ + credential.ConstRegion: region, + credential.ConstDisableCredentialRotation: true, // Note that this does nothing in sets, but just noting for tests + }) + require.NoError(err) + setAttrs, err := structpb.NewStruct(map[string]any{ + host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("tag-key=%s", strings.Join(tags, ","))}, + }) + require.NoError(err) + reqPersistedSecrets, err := structpb.NewStruct(map[string]any{ + credential.ConstAccessKeyId: accessKeyId, + credential.ConstSecretAccessKey: secretAccessKey, + credential.ConstCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano), + }) + require.NoError(err) + createRequest := &pb.OnCreateSetRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: catalogAttrs, + }, + }, + Set: &hostsets.HostSet{ + Attrs: &hostsets.HostSet_Attributes{ + Attributes: setAttrs, + }, + }, + Persisted: &pb.HostCatalogPersisted{ + Secrets: reqPersistedSecrets, + }, + } + createResponse, err := p.OnCreateSet(ctx, createRequest) + require.NoError(err) + require.NotNil(createResponse) + + // Do an update test in the same function, as it's pretty much the + // same function right now. + t.Logf("testing OnUpdateSet (region=%s, tags=%v)", region, tags) + updateRequest := &pb.OnUpdateSetRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: catalogAttrs, + }, + }, + CurrentSet: &hostsets.HostSet{ + Attrs: &hostsets.HostSet_Attributes{ + Attributes: setAttrs, + }, + }, + NewSet: &hostsets.HostSet{ + Attrs: &hostsets.HostSet_Attributes{ + Attributes: setAttrs, + }, + }, + Persisted: &pb.HostCatalogPersisted{ + Secrets: reqPersistedSecrets, + }, + } + updateResponse, err := p.OnUpdateSet(ctx, updateRequest) + require.NoError(err) + require.NotNil(updateResponse) +} + +func testPluginListHosts(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, tags []string, expected map[string][]string) { + t.Helper() + t.Logf("testing ListHosts (region=%s, tags=%v)", region, tags) + require := require.New(t) + catalogAttrs, err := structpb.NewStruct(map[string]any{ + credential.ConstRegion: region, + credential.ConstDisableCredentialRotation: true, // Note that this does nothing in sets, but just noting for tests + }) + require.NoError(err) + sets := make([]*hostsets.HostSet, len(tags)) + for i, tag := range tags { + setAttrs, err := structpb.NewStruct(map[string]any{ + host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("tag-key=%s", tag)}, + }) + require.NoError(err) + sets[i] = &hostsets.HostSet{ + Id: fmt.Sprintf("hostset-%d", i), + Attrs: &hostsets.HostSet_Attributes{ + Attributes: setAttrs, + }, + } + } + reqPersistedSecrets, err := structpb.NewStruct(map[string]any{ + credential.ConstAccessKeyId: accessKeyId, + credential.ConstSecretAccessKey: secretAccessKey, + credential.ConstCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano), + }) + require.NoError(err) + request := &pb.ListHostsRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: catalogAttrs, + }, + }, + Sets: sets, + Persisted: &pb.HostCatalogPersisted{ + Secrets: reqPersistedSecrets, + }, + } + response, err := p.ListHosts(ctx, request) + require.NoError(err) + require.NotNil(response) + + // Validate the returned instances by ID. Assemble the instance + // details from the expected set. + expectedInstances := make(map[string][]string) + for i, tag := range tags { + for _, instanceId := range expected[tag] { + expectedInstances[instanceId] = append(expectedInstances[instanceId], fmt.Sprintf("hostset-%d", i)) + } + } + + // Take the returned hosts by ID and create the same kind of map. + actualInstances := make(map[string][]string) + for _, host := range response.GetHosts() { + actualInstances[host.ExternalId] = host.SetIds + } + + // Compare + require.Equal(expectedInstances, actualInstances) + // Success + t.Logf("testing ListHosts: success (region=%s, tags=%v, expected/actual=(len=%d, ids=%s))", region, tags, len(actualInstances), actualInstances) +} + +func testInstanceAddressesOnlyListHosts(ctx context.Context, t *testing.T, p *host.HostPlugin, tf *TestTerraformer, region, accessKeyId, secretAccessKey string) { + t.Helper() + t.Logf("testing testInstanceAddressesOnlyListHosts") + + // Get the instance details from tf output + instanceID, err := tf.GetOutputString("multi_address_instance_id") + require.NoError(t, err) + primaryAddresses, err := tf.GetOutputMap("multi_address_primary_addresses") + require.NoError(t, err) + secondaryAddresses, err := tf.GetOutputMap("multi_address_secondary_addresses") + require.NoError(t, err) + + var primary, allAddresses []string + for _, addr := range primaryAddresses { + primary = append(primary, addr.(string)) + } + allAddresses = append(allAddresses, primary...) + for _, addr := range secondaryAddresses { + allAddresses = append(allAddresses, addr.(string)) + } + + cases := []struct { + name string + instanceAddressesOnly bool + expectedIPs []string + }{ + { + name: "all addresses", + expectedIPs: allAddresses, + }, + { + name: "instance addresses only", + instanceAddressesOnly: true, + expectedIPs: primary, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + require := require.New(t) + + catalogAttrMap := map[string]any{ + credential.ConstRegion: region, + credential.ConstDisableCredentialRotation: true, + } + if tc.instanceAddressesOnly { + catalogAttrMap[host.ConstInstanceAddressesOnly] = true + } + + catalogAttrs, err := structpb.NewStruct(catalogAttrMap) + require.NoError(err) + setAttrs, err := structpb.NewStruct(map[string]any{ + host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("instance-id=%s", instanceID)}, + }) + require.NoError(err) + + reqPersistedSecrets, err := structpb.NewStruct(map[string]any{ + credential.ConstAccessKeyId: accessKeyId, + credential.ConstSecretAccessKey: secretAccessKey, + credential.ConstCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano), + }) + require.NoError(err) + request := &pb.ListHostsRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: catalogAttrs, + }, + }, + Sets: []*hostsets.HostSet{{ + Id: "hostset-0", + Attrs: &hostsets.HostSet_Attributes{Attributes: setAttrs}, + }}, + Persisted: &pb.HostCatalogPersisted{ + Secrets: reqPersistedSecrets, + }, + } + + response, err := p.ListHosts(ctx, request) + require.NoError(err) + require.Len(response.GetHosts(), 1) + hostResp := response.GetHosts()[0] + + require.Equal(instanceID, hostResp.ExternalId) + require.ElementsMatch(hostResp.IpAddresses, tc.expectedIPs) + }) + } +} diff --git a/testing/e2e_host_test.go b/testing/e2e_host_test.go index 2856a63..5eee736 100644 --- a/testing/e2e_host_test.go +++ b/testing/e2e_host_test.go @@ -3,21 +3,24 @@ package testing +// This file contains the TestHostPlugin orchestrator. It deploys the Terraform +// workspace, collects outputs, and calls the per-credential-type helper +// functions defined in e2e_host_static_test.go and e2e_host_assumerole_test.go. + import ( "context" - "fmt" "os" - "strings" "testing" - "time" - "github.com/hashicorp/boundary-plugin-aws/internal/credential" "github.com/hashicorp/boundary-plugin-aws/plugin/service/host" - "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostcatalogs" - "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostsets" - pb "github.com/hashicorp/boundary/sdk/pbs/plugin" "github.com/stretchr/testify/require" - "google.golang.org/protobuf/types/known/structpb" +) + +const ( + envTargetAccessKeyId = "TARGET_AWS_ACCESS_KEY_ID" + envTargetSecretAccessKey = "TARGET_AWS_SECRET_ACCESS_KEY" + envTargetSessionToken = "TARGET_AWS_SESSION_TOKEN" + envTargetRegion = "TARGET_AWS_REGION" ) const expectedEc2InstanceCount = 5 @@ -35,7 +38,29 @@ func TestHostPlugin(t *testing.T) { } require := require.New(t) - tf, err := NewTestTerraformer("testdata/host") + + // Collect cross-account credentials up front so they can be passed as + // Terraform variables. The cross-account section is skipped (not failed) + // when these are absent, but we need the values available before Deploy. + targetAccessKeyId := os.Getenv(envTargetAccessKeyId) + targetSecretAccessKey := os.Getenv(envTargetSecretAccessKey) + targetSessionToken := os.Getenv(envTargetSessionToken) + targetRegion := os.Getenv(envTargetRegion) + if targetRegion == "" { + targetRegion = region + } + + tfVars := map[string]string{} + if targetAccessKeyId != "" && targetSecretAccessKey != "" { + tfVars["target_access_key_id"] = targetAccessKeyId + tfVars["target_secret_access_key"] = targetSecretAccessKey + tfVars["target_region"] = targetRegion + if targetSessionToken != "" { + tfVars["target_session_token"] = targetSessionToken + } + } + + tf, err := NewTestTerraformer("testdata/host", tfVars) require.NoError(err) require.NotNil(tf) @@ -168,377 +193,152 @@ func TestHostPlugin(t *testing.T) { // finally lets test instance_addresses_only catalog attribute correctly syncs only instance addresses testInstanceAddressesOnlyListHosts(ctx, t, p, tf, region, keyid, secret) -} -func testPluginOnCreateCatalog(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, rotate bool) (string, string) { - t.Helper() - t.Logf("testing OnCreateCatalog (region=%s, rotate=%t)", region, rotate) - require := require.New(t) + // ******************** + // * AssumeRole - Happy Path & Error Cases + // ******************** + assumeRoleArn, err := tf.GetOutputString("assume_role_arn") + require.NoError(err) - reqAttrs, err := structpb.NewStruct(map[string]any{ - credential.ConstRegion: region, - credential.ConstDisableCredentialRotation: !rotate, + noTrustRoleArn, err := tf.GetOutputString("assume_role_no_trust_arn") + require.NoError(err) + + missingPermRoleArn, err := tf.GetOutputString("assume_role_no_ec2_permission_arn") + require.NoError(err) + + testOnCreateCatalogCases(ctx, t, p, []onCreateCatalogCase{ + {name: "assume role happy path", catalogAttrs: roleArnAttrs(region, assumeRoleArn)}, + {name: "assume role no trust", catalogAttrs: roleArnAttrs(region, noTrustRoleArn), wantErr: "AccessDenied"}, + {name: "assume role missing ec2 permission", catalogAttrs: roleArnAttrs(region, missingPermRoleArn), wantErr: "UnauthorizedOperation"}, }) - require.NoError(err) - reqSecrets, err := structpb.NewStruct(map[string]any{ - credential.ConstAccessKeyId: accessKeyId, - credential.ConstSecretAccessKey: secretAccessKey, + testOnUpdateCatalogCases(ctx, t, p, []onUpdateCatalogCase{ + {name: "assume role happy path", catalogAttrs: roleArnAttrs(region, assumeRoleArn)}, + {name: "assume role no trust", catalogAttrs: roleArnAttrs(region, noTrustRoleArn), wantErr: "AccessDenied"}, + {name: "assume role missing ec2 permission", currentAttrs: roleArnAttrs(region, assumeRoleArn), newAttrs: roleArnAttrs(region, missingPermRoleArn), wantErr: "UnauthorizedOperation"}, }) - require.NoError(err) - request := &pb.OnCreateCatalogRequest{ - Catalog: &hostcatalogs.HostCatalog{ - Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: reqAttrs, - }, - Secrets: reqSecrets, - }, - } - response, err := p.OnCreateCatalog(ctx, request) - require.NoError(err) - require.NotNil(response) - persisted := response.GetPersisted() - require.NotNil(persisted) - return validatePersistedSecrets(t, persisted.GetSecrets(), accessKeyId, secretAccessKey, rotate) -} - -func testPluginOnUpdateCatalog( - ctx context.Context, t *testing.T, p *host.HostPlugin, - region, currentAccessKeyId, currentSecretAccessKey, newAccessKeyId, newSecretAccessKey string, - rotated, rotate bool, -) (string, string) { - t.Helper() - t.Logf("testing OnUpdateCatalog (region=%s, newcreds=%t, rotated=%t, rotate=%t)", region, newAccessKeyId != "" && newSecretAccessKey != "", rotated, rotate) - require := require.New(t) - - // Take a timestamp of the current time to get a point in time to - // reference, ensuring that we are updating credential rotation - // timestamps. - currentCredsLastRotatedTime := time.Now() - - reqCurrentAttrs, err := structpb.NewStruct(map[string]any{ - credential.ConstRegion: region, - credential.ConstDisableCredentialRotation: !rotated, + testOnCreateSetCases(ctx, t, p, []onSetCase{ + {name: "assume role happy path", catalogAttrs: roleArnAttrs(region, assumeRoleArn), tags: cases[0]}, + {name: "assume role no trust", catalogAttrs: roleArnAttrs(region, noTrustRoleArn), wantErr: "AccessDenied"}, + {name: "assume role missing ec2 permission", catalogAttrs: roleArnAttrs(region, missingPermRoleArn), wantErr: "UnauthorizedOperation"}, }) - require.NoError(err) - reqNewAttrs, err := structpb.NewStruct(map[string]any{ - credential.ConstRegion: region, - credential.ConstDisableCredentialRotation: !rotate, + testOnUpdateSetCases(ctx, t, p, []onSetCase{ + {name: "assume role happy path", catalogAttrs: roleArnAttrs(region, assumeRoleArn), tags: cases[0]}, + {name: "assume role no trust", catalogAttrs: roleArnAttrs(region, noTrustRoleArn), wantErr: "AccessDenied"}, + {name: "assume role missing ec2 permission", catalogAttrs: roleArnAttrs(region, missingPermRoleArn), wantErr: "UnauthorizedOperation"}, }) - require.NoError(err) - var reqSecrets *structpb.Struct - if newAccessKeyId != "" && newSecretAccessKey != "" { - reqSecrets, err = structpb.NewStruct(map[string]any{ - credential.ConstAccessKeyId: newAccessKeyId, - credential.ConstSecretAccessKey: newSecretAccessKey, - }) - require.NoError(err) - } - reqPersistedSecrets, err := structpb.NewStruct(map[string]any{ - credential.ConstAccessKeyId: currentAccessKeyId, - credential.ConstSecretAccessKey: currentSecretAccessKey, - credential.ConstCredsLastRotatedTime: func() string { - if rotated { - return currentCredsLastRotatedTime.Format(time.RFC3339Nano) - } - - return (time.Time{}).Format(time.RFC3339Nano) - }(), + testListHostsCases(ctx, t, p, []listHostsCase{ + {name: "assume role happy path", catalogAttrs: roleArnAttrs(region, assumeRoleArn), tags: cases[0], expected: expectedTagInstancesMap}, + {name: "assume role no trust", catalogAttrs: roleArnAttrs(region, noTrustRoleArn), tags: cases[0], wantErr: "AccessDenied"}, + {name: "assume role missing ec2 permission", catalogAttrs: roleArnAttrs(region, missingPermRoleArn), tags: cases[0], wantErr: "UnauthorizedOperation"}, }) - require.NoError(err) - require.NotNil(reqPersistedSecrets) - request := &pb.OnUpdateCatalogRequest{ - CurrentCatalog: &hostcatalogs.HostCatalog{ - Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: reqCurrentAttrs, - }, - }, - NewCatalog: &hostcatalogs.HostCatalog{ - Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: reqNewAttrs, - }, - Secrets: reqSecrets, - }, - Persisted: &pb.HostCatalogPersisted{ - Secrets: reqPersistedSecrets, - }, - } - response, err := p.OnUpdateCatalog(ctx, request) - require.NoError(err) - require.NotNil(response) - persisted := response.GetPersisted() - require.NotNil(persisted) - return validateUpdateSecrets(t, persisted.GetSecrets(), currentCredsLastRotatedTime, currentAccessKeyId, currentSecretAccessKey, newAccessKeyId, newSecretAccessKey, rotated, rotate) -} -func testPluginOnDeleteCatalog(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, rotated bool) { - t.Helper() - t.Logf("testing OnDeleteCatalog (region=%s, rotated=%t)", region, rotated) - require := require.New(t) + // ******************** + // * Cross-Role AssumeRole - Happy Path & Error Cases + // ******************** + // These tests exercise the two-hop role chain within a single AWS account. + // The principal role has no ec2:DescribeInstances; the target role + // (assume_role) does. The plugin must assume the principal first, then use + // those credentials to assume the target role before calling DescribeInstances. + // This validates the plugin's chained credential logic without requiring a + // second AWS account. + crossRolePrincipalArn, err := tf.GetOutputString("cross_role_principal_arn") + require.NoError(err) - reqAttrs, err := structpb.NewStruct(map[string]any{ - credential.ConstRegion: region, - credential.ConstDisableCredentialRotation: !rotated, + crossRolePrincipalNoTrustArn, err := tf.GetOutputString("cross_role_principal_no_trust_arn") + require.NoError(err) + + crossRoleTargetNoTrustArn, err := tf.GetOutputString("cross_role_target_no_trust_arn") + require.NoError(err) + + crossRoleTargetNoEc2PermissionArn, err := tf.GetOutputString("cross_role_target_no_ec2_permission_arn") + require.NoError(err) + + // assume_role_arn is reused as the cross-role target — it has ec2:DescribeInstances + // and its trust policy allows the cross-role principal to assume it. + testOnCreateCatalogCases(ctx, t, p, []onCreateCatalogCase{ + {name: "cross-role happy path", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, assumeRoleArn)}, + {name: "cross-role principal no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalNoTrustArn, assumeRoleArn), wantErr: "AccessDenied"}, + {name: "cross-role target no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoTrustArn), wantErr: "AccessDenied"}, + {name: "cross-role target missing ec2 permission", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"}, }) - require.NoError(err) - reqSecrets, err := structpb.NewStruct(map[string]any{ - credential.ConstAccessKeyId: accessKeyId, - credential.ConstSecretAccessKey: secretAccessKey, + testOnUpdateCatalogCases(ctx, t, p, []onUpdateCatalogCase{ + {name: "cross-role happy path", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, assumeRoleArn)}, + {name: "cross-role principal no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalNoTrustArn, assumeRoleArn), wantErr: "AccessDenied"}, + {name: "cross-role target no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoTrustArn), wantErr: "AccessDenied"}, + {name: "cross-role target missing ec2 permission", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"}, }) - require.NoError(err) - reqPersistedSecrets, err := structpb.NewStruct(map[string]any{ - credential.ConstAccessKeyId: accessKeyId, - credential.ConstSecretAccessKey: secretAccessKey, - credential.ConstCredsLastRotatedTime: func() string { - if rotated { - return time.Now().Format(time.RFC3339Nano) - } - - return (time.Time{}).Format(time.RFC3339Nano) - }(), + testOnCreateSetCases(ctx, t, p, []onSetCase{ + {name: "cross-role happy path", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, assumeRoleArn), tags: cases[0]}, + {name: "cross-role principal no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalNoTrustArn, assumeRoleArn), wantErr: "AccessDenied"}, + {name: "cross-role target no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoTrustArn), wantErr: "AccessDenied"}, + {name: "cross-role target missing ec2 permission", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"}, }) - require.NoError(err) - request := &pb.OnDeleteCatalogRequest{ - Catalog: &hostcatalogs.HostCatalog{ - Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: reqAttrs, - }, - Secrets: reqSecrets, - }, - Persisted: &pb.HostCatalogPersisted{ - Secrets: reqPersistedSecrets, - }, - } - response, err := p.OnDeleteCatalog(ctx, request) - require.NoError(err) - require.NotNil(response) - - // We want to test the validity of the credentials post-deletion. - if rotated { - // The credentials should no longer be valid. - requireCredentialsInvalid(t, accessKeyId, secretAccessKey) + testOnUpdateSetCases(ctx, t, p, []onSetCase{ + {name: "cross-role happy path", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, assumeRoleArn), tags: cases[0]}, + {name: "cross-role principal no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalNoTrustArn, assumeRoleArn), wantErr: "AccessDenied"}, + {name: "cross-role target no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoTrustArn), wantErr: "AccessDenied"}, + {name: "cross-role target missing ec2 permission", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"}, + }) + testListHostsCases(ctx, t, p, []listHostsCase{ + {name: "cross-role happy path", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, assumeRoleArn), tags: cases[0], expected: expectedTagInstancesMap}, + {name: "cross-role principal no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalNoTrustArn, assumeRoleArn), tags: cases[0], wantErr: "AccessDenied"}, + {name: "cross-role target no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoTrustArn), tags: cases[0], wantErr: "AccessDenied"}, + {name: "cross-role target missing ec2 permission", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoEc2PermissionArn), tags: cases[0], wantErr: "UnauthorizedOperation"}, + }) + // ******************** + // * Cross-Account AssumeRole - Happy Path & Error Cases + // ******************** + // These tests require credentials for a second AWS account supplied via + // TARGET_AWS_ACCESS_KEY_ID, TARGET_AWS_SECRET_ACCESS_KEY, and (for + // temporary credentials) TARGET_AWS_SESSION_TOKEN. They are skipped + // (not failed) when the access key ID or secret access key are absent. + if targetAccessKeyId == "" || targetSecretAccessKey == "" { + t.Log("skipping cross-account tests: set TARGET_AWS_ACCESS_KEY_ID and TARGET_AWS_SECRET_ACCESS_KEY to enable") } else { - // The credentials should still be valid. Sleep 10s first just to - // be sure, since we're not rotating. - time.Sleep(time.Second * 10) - requireCredentialsValid(t, accessKeyId, secretAccessKey) - } -} - -func testPluginOnCreateUpdateSet(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, tags []string) { - t.Helper() - t.Logf("testing OnCreateSet (region=%s, tags=%v)", region, tags) - require := require.New(t) - catalogAttrs, err := structpb.NewStruct(map[string]any{ - credential.ConstRegion: region, - credential.ConstDisableCredentialRotation: true, // Note that this does nothing in sets, but just noting for tests - }) - require.NoError(err) - setAttrs, err := structpb.NewStruct(map[string]any{ - host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("tag-key=%s", strings.Join(tags, ","))}, - }) - require.NoError(err) - reqPersistedSecrets, err := structpb.NewStruct(map[string]any{ - credential.ConstAccessKeyId: accessKeyId, - credential.ConstSecretAccessKey: secretAccessKey, - credential.ConstCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano), - }) - require.NoError(err) - createRequest := &pb.OnCreateSetRequest{ - Catalog: &hostcatalogs.HostCatalog{ - Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: catalogAttrs, - }, - }, - Set: &hostsets.HostSet{ - Attrs: &hostsets.HostSet_Attributes{ - Attributes: setAttrs, - }, - }, - Persisted: &pb.HostCatalogPersisted{ - Secrets: reqPersistedSecrets, - }, - } - createResponse, err := p.OnCreateSet(ctx, createRequest) - require.NoError(err) - require.NotNil(createResponse) - - // Do an update test in the same function, as it's pretty much the - // same function right now. - t.Logf("testing OnUpdateSet (region=%s, tags=%v)", region, tags) - updateRequest := &pb.OnUpdateSetRequest{ - Catalog: &hostcatalogs.HostCatalog{ - Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: catalogAttrs, - }, - }, - CurrentSet: &hostsets.HostSet{ - Attrs: &hostsets.HostSet_Attributes{ - Attributes: setAttrs, - }, - }, - NewSet: &hostsets.HostSet{ - Attrs: &hostsets.HostSet_Attributes{ - Attributes: setAttrs, - }, - }, - Persisted: &pb.HostCatalogPersisted{ - Secrets: reqPersistedSecrets, - }, - } - updateResponse, err := p.OnUpdateSet(ctx, updateRequest) - require.NoError(err) - require.NotNil(updateResponse) -} - -func testPluginListHosts(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, tags []string, expected map[string][]string) { - t.Helper() - t.Logf("testing ListHosts (region=%s, tags=%v)", region, tags) - require := require.New(t) - catalogAttrs, err := structpb.NewStruct(map[string]any{ - credential.ConstRegion: region, - credential.ConstDisableCredentialRotation: true, // Note that this does nothing in sets, but just noting for tests - }) - require.NoError(err) - sets := make([]*hostsets.HostSet, len(tags)) - for i, tag := range tags { - setAttrs, err := structpb.NewStruct(map[string]any{ - host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("tag-key=%s", tag)}, - }) + crossAccountPrincipalArn, err := tf.GetOutputString("cross_account_principal_arn") require.NoError(err) - sets[i] = &hostsets.HostSet{ - Id: fmt.Sprintf("hostset-%d", i), - Attrs: &hostsets.HostSet_Attributes{ - Attributes: setAttrs, - }, - } - } - reqPersistedSecrets, err := structpb.NewStruct(map[string]any{ - credential.ConstAccessKeyId: accessKeyId, - credential.ConstSecretAccessKey: secretAccessKey, - credential.ConstCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano), - }) - require.NoError(err) - request := &pb.ListHostsRequest{ - Catalog: &hostcatalogs.HostCatalog{ - Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: catalogAttrs, - }, - }, - Sets: sets, - Persisted: &pb.HostCatalogPersisted{ - Secrets: reqPersistedSecrets, - }, - } - response, err := p.ListHosts(ctx, request) - require.NoError(err) - require.NotNil(response) - // Validate the returned instances by ID. Assemble the instance - // details from the expected set. - expectedInstances := make(map[string][]string) - for i, tag := range tags { - for _, instanceId := range expected[tag] { - expectedInstances[instanceId] = append(expectedInstances[instanceId], fmt.Sprintf("hostset-%d", i)) - } - } + crossAccountPrincipalNoTrustArn, err := tf.GetOutputString("cross_account_principal_no_trust_arn") + require.NoError(err) - // Take the returned hosts by ID and create the same kind of map. - actualInstances := make(map[string][]string) - for _, host := range response.GetHosts() { - actualInstances[host.ExternalId] = host.SetIds - } + crossAccountTargetArn, err := tf.GetOutputString("cross_account_target_arn") + require.NoError(err) - // Compare - require.Equal(expectedInstances, actualInstances) - // Success - t.Logf("testing ListHosts: success (region=%s, tags=%v, expected/actual=(len=%d, ids=%s))", region, tags, len(actualInstances), actualInstances) -} + crossAccountTargetNoTrustArn, err := tf.GetOutputString("cross_account_target_no_trust_arn") + require.NoError(err) -func testInstanceAddressesOnlyListHosts(ctx context.Context, t *testing.T, p *host.HostPlugin, tf *TestTerraformer, region, accessKeyId, secretAccessKey string) { - t.Helper() - t.Logf("testing testInstanceAddressesOnlyListHosts") + crossAccountTargetNoEc2PermissionArn, err := tf.GetOutputString("cross_account_target_no_ec2_permission_arn") + require.NoError(err) - // Get the instance details from tf output - instanceID, err := tf.GetOutputString("multi_address_instance_id") - require.NoError(t, err) - primaryAddresses, err := tf.GetOutputMap("multi_address_primary_addresses") - require.NoError(t, err) - secondaryAddresses, err := tf.GetOutputMap("multi_address_secondary_addresses") - require.NoError(t, err) - - var primary, allAddresses []string - for _, addr := range primaryAddresses { - primary = append(primary, addr.(string)) - } - allAddresses = append(allAddresses, primary...) - for _, addr := range secondaryAddresses { - allAddresses = append(allAddresses, addr.(string)) - } - - cases := []struct { - name string - instanceAddressesOnly bool - expectedIPs []string - }{ - { - name: "all addresses", - expectedIPs: allAddresses, - }, - { - name: "instance addresses only", - instanceAddressesOnly: true, - expectedIPs: primary, - }, - } - - for _, tc := range cases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - require := require.New(t) - - catalogAttrMap := map[string]any{ - credential.ConstRegion: region, - credential.ConstDisableCredentialRotation: true, - } - if tc.instanceAddressesOnly { - catalogAttrMap[host.ConstInstanceAddressesOnly] = true - } - - catalogAttrs, err := structpb.NewStruct(catalogAttrMap) - require.NoError(err) - setAttrs, err := structpb.NewStruct(map[string]any{ - host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("instance-id=%s", instanceID)}, - }) - require.NoError(err) - - reqPersistedSecrets, err := structpb.NewStruct(map[string]any{ - credential.ConstAccessKeyId: accessKeyId, - credential.ConstSecretAccessKey: secretAccessKey, - credential.ConstCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano), - }) - require.NoError(err) - request := &pb.ListHostsRequest{ - Catalog: &hostcatalogs.HostCatalog{ - Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: catalogAttrs, - }, - }, - Sets: []*hostsets.HostSet{{ - Id: "hostset-0", - Attrs: &hostsets.HostSet_Attributes{Attributes: setAttrs}, - }}, - Persisted: &pb.HostCatalogPersisted{ - Secrets: reqPersistedSecrets, - }, - } - - response, err := p.ListHosts(ctx, request) - require.NoError(err) - require.Len(response.GetHosts(), 1) - hostResp := response.GetHosts()[0] - - require.Equal(instanceID, hostResp.ExternalId) - require.ElementsMatch(hostResp.IpAddresses, tc.expectedIPs) + testOnCreateCatalogCases(ctx, t, p, []onCreateCatalogCase{ + {name: "cross-account happy path", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetArn)}, + {name: "cross-account principal no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalNoTrustArn, crossAccountTargetArn), wantErr: "AccessDenied"}, + {name: "cross-account target no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoTrustArn), wantErr: "AccessDenied"}, + {name: "cross-account target missing ec2 permission", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"}, + }) + testOnUpdateCatalogCases(ctx, t, p, []onUpdateCatalogCase{ + {name: "cross-account happy path", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetArn)}, + {name: "cross-account principal no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalNoTrustArn, crossAccountTargetArn), wantErr: "AccessDenied"}, + {name: "cross-account target no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoTrustArn), wantErr: "AccessDenied"}, + {name: "cross-account target missing ec2 permission", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"}, + }) + testOnCreateSetCases(ctx, t, p, []onSetCase{ + {name: "cross-account happy path", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetArn), tags: cases[0]}, + {name: "cross-account principal no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalNoTrustArn, crossAccountTargetArn), wantErr: "AccessDenied"}, + {name: "cross-account target no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoTrustArn), wantErr: "AccessDenied"}, + {name: "cross-account target missing ec2 permission", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"}, + }) + testOnUpdateSetCases(ctx, t, p, []onSetCase{ + {name: "cross-account happy path", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetArn), tags: cases[0]}, + {name: "cross-account principal no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalNoTrustArn, crossAccountTargetArn), wantErr: "AccessDenied"}, + {name: "cross-account target no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoTrustArn), wantErr: "AccessDenied"}, + {name: "cross-account target missing ec2 permission", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"}, + }) + testListHostsCases(ctx, t, p, []listHostsCase{ + {name: "cross-account happy path", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetArn), tags: cases[0], expected: expectedTagInstancesMap}, + {name: "cross-account principal no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalNoTrustArn, crossAccountTargetArn), tags: cases[0], wantErr: "AccessDenied"}, + {name: "cross-account target no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoTrustArn), tags: cases[0], wantErr: "AccessDenied"}, + {name: "cross-account target missing ec2 permission", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoEc2PermissionArn), tags: cases[0], wantErr: "UnauthorizedOperation"}, }) } } diff --git a/testing/testdata/host/iam_assume_role.tf b/testing/testdata/host/iam_assume_role.tf new file mode 100644 index 0000000..cb0eda7 --- /dev/null +++ b/testing/testdata/host/iam_assume_role.tf @@ -0,0 +1,206 @@ +# Copyright IBM Corp. 2021, 2026 +# SPDX-License-Identifier: MPL-2.0 + +resource "random_id" "assume_role_name" { + prefix = "BoundaryPluginAssumeRole" + byte_length = 4 +} + +resource "aws_iam_role" "assume_role" { + name = random_id.assume_role_name.dec + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = ["sts:AssumeRole", "sts:SetSourceIdentity"] + Effect = "Allow" + Principal = { + AWS = data.aws_caller_identity.current.arn + } + }, + # Also trusted by the cross-role principal role so it can be used + # as the target in two-hop AssumeRole tests. + { + Action = ["sts:AssumeRole", "sts:SetSourceIdentity"] + Effect = "Allow" + Principal = { + AWS = aws_iam_role.cross_role_principal.arn + } + }, + ] + }) + + tags = { + "boundary-demo" = local.hashicorp_email + } +} + +resource "aws_iam_role_policy_attachment" "assume_role_ec2_describeinstances" { + role = aws_iam_role.assume_role.name + policy_arn = aws_iam_policy.ec2_describeinstances.arn +} + +output "assume_role_arn" { + value = aws_iam_role.assume_role.arn +} + +# Error case: role exists with ec2:DescribeInstances but trust policy +# does not allow the caller to assume it. +resource "random_id" "assume_role_no_trust_name" { + prefix = "BoundaryPluginNoTrust" + byte_length = 4 +} + +resource "aws_iam_role" "assume_role_no_trust" { + name = random_id.assume_role_no_trust_name.dec + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = ["sts:AssumeRole", "sts:SetSourceIdentity"] + Effect = "Deny" + Principal = { + AWS = data.aws_caller_identity.current.arn + } + }, + ] + }) + + tags = { + "boundary-demo" = local.hashicorp_email + } +} + +output "assume_role_no_trust_arn" { + value = aws_iam_role.assume_role_no_trust.arn +} + +# Error case: role exists and the caller can assume it, but has no +# ec2:DescribeInstances permission. +# Also trusted by cross_role_principal so this same role can be reused as the +# no-EC2-permission target in two-hop (cross-role) error case tests. +resource "random_id" "assume_role_no_ec2_permission_name" { + prefix = "BoundaryPluginNoEC2Perm" + byte_length = 4 +} + +resource "aws_iam_role" "assume_role_no_ec2_permission" { + name = random_id.assume_role_no_ec2_permission_name.dec + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = ["sts:AssumeRole", "sts:SetSourceIdentity"] + Effect = "Allow" + Principal = { + AWS = data.aws_caller_identity.current.arn + } + }, + # Also trusted by the cross-role principal so it can serve as the + # no-EC2-permission target in two-hop error case tests. + { + Action = ["sts:AssumeRole", "sts:SetSourceIdentity"] + Effect = "Allow" + Principal = { + AWS = aws_iam_role.cross_role_principal.arn + } + }, + ] + }) + + tags = { + "boundary-demo" = local.hashicorp_email + } +} + +output "assume_role_no_ec2_permission_arn" { + value = aws_iam_role.assume_role_no_ec2_permission.arn +} + +# Cross-role (two-hop) principal role. +# The test runner can assume this role. It has permission to AssumeRole into +# aws_iam_role.assume_role (the target role) but has no ec2:DescribeInstances +# of its own. This simulates the principal side of a two-hop role chain: the +# plugin must assume the principal first, then use those credentials to assume +# the target role before calling DescribeInstances. +# Note: both roles are in the same AWS account. This tests the plugin's +# two-hop credential chain logic without requiring a second account. +resource "random_id" "cross_role_principal_name" { + prefix = "BoundaryPluginCRPrincipal" + byte_length = 4 +} + +resource "aws_iam_role" "cross_role_principal" { + name = random_id.cross_role_principal_name.dec + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = ["sts:AssumeRole", "sts:SetSourceIdentity"] + Effect = "Allow" + Principal = { + AWS = data.aws_caller_identity.current.arn + } + }, + ] + }) + + tags = { + "boundary-demo" = local.hashicorp_email + } +} + +# Single policy granting cross_role_principal permission to assume all target +# roles it needs for both happy path and error case tests. +resource "aws_iam_policy" "cross_role_principal_assume_targets" { + name = "${random_id.cross_role_principal_name.dec}-assume-targets" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = ["sts:AssumeRole", "sts:SetSourceIdentity"] + Effect = "Allow" + Resource = [ + aws_iam_role.assume_role.arn, + aws_iam_role.assume_role_no_ec2_permission.arn, + ] + }, + ] + }) +} + +resource "aws_iam_role_policy_attachment" "cross_role_principal_assume_targets" { + role = aws_iam_role.cross_role_principal.name + policy_arn = aws_iam_policy.cross_role_principal_assume_targets.arn +} + +output "cross_role_principal_arn" { + value = aws_iam_role.cross_role_principal.arn +} + +# Error case: caller cannot assume the principal role (first hop denied). +# Reuses assume_role_no_trust — it already has a Deny trust policy for the +# caller, so it also serves as an unassumable principal in two-hop tests. +output "cross_role_principal_no_trust_arn" { + value = aws_iam_role.assume_role_no_trust.arn +} + +# Error case: principal can be assumed, but the target role's trust policy +# does not allow the principal to assume it (second hop denied). +# Reuses assume_role_no_trust as the target — its Deny trust policy blocks +# all callers including cross_role_principal. +output "cross_role_target_no_trust_arn" { + value = aws_iam_role.assume_role_no_trust.arn +} + +# Error case: both hops succeed but the target has no ec2:DescribeInstances. +# Reuses assume_role_no_ec2_permission — its trust policy allows +# cross_role_principal (see above), and it has no EC2 permission. +output "cross_role_target_no_ec2_permission_arn" { + value = aws_iam_role.assume_role_no_ec2_permission.arn +} diff --git a/testing/testdata/host/iam_cross_account.tf b/testing/testdata/host/iam_cross_account.tf new file mode 100644 index 0000000..30af38a --- /dev/null +++ b/testing/testdata/host/iam_cross_account.tf @@ -0,0 +1,257 @@ +# Copyright IBM Corp. 2021, 2026 +# SPDX-License-Identifier: MPL-2.0 + +# Cross-account Dynamic Host Catalog test infrastructure. +# +# This file provisions the IAM resources needed to test two-hop AssumeRole +# across two separate AWS accounts: +# +# Account A (principal account) — the default provider +# * cross_account_principal: the plugin assumes this role first. +# It has sts:AssumeRole permission on the target role in Account B. +# +# Account B (target account) — the "target" provider alias +# * cross_account_target: holds ec2:DescribeInstances; its trust policy +# allows the Account A principal role to assume it. (happy path) +# * cross_account_target_no_ec2_permission: same trust policy but no +# ec2:DescribeInstances. (error case) +# * cross_account_target_no_trust: exists in Account B but its trust +# policy does not allow the Account A principal. (error case) +# +# Prerequisites: +# TARGET_AWS_ACCESS_KEY_ID, TARGET_AWS_SECRET_ACCESS_KEY, and (for temporary +# credentials) TARGET_AWS_SESSION_TOKEN must be set to credentials that have +# sufficient IAM permissions in Account B. The test runner skips this +# section when the access key ID or secret access key are absent. + +# --------------------------------------------------------------------------- +# Provider for Account B (target account) +# --------------------------------------------------------------------------- + +provider "aws" { + alias = "target" + region = var.target_region + access_key = var.target_access_key_id + secret_key = var.target_secret_access_key + token = var.target_session_token +} + +data "aws_caller_identity" "target" { + provider = aws.target +} + +# --------------------------------------------------------------------------- +# Variables for Account B credentials and region +# --------------------------------------------------------------------------- + +variable "target_access_key_id" { + description = "AWS access key ID for the target account (Account B)." + type = string + sensitive = true + default = "" +} + +variable "target_secret_access_key" { + description = "AWS secret access key for the target account (Account B)." + type = string + sensitive = true + default = "" +} + +variable "target_session_token" { + description = "AWS session token for the target account (Account B). Required when using temporary (STS) credentials." + type = string + sensitive = true + default = "" +} + +variable "target_region" { + description = "AWS region for the target account (Account B). Defaults to the same region as Account A." + type = string + default = "" +} + +# --------------------------------------------------------------------------- +# Account A: cross_account_principal role +# --------------------------------------------------------------------------- + +resource "random_id" "cross_account_principal_name" { + prefix = "BoundaryPluginCAPrincipal" + byte_length = 4 +} + +resource "aws_iam_role" "cross_account_principal" { + name = random_id.cross_account_principal_name.dec + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = ["sts:AssumeRole", "sts:SetSourceIdentity"] + Effect = "Allow" + Principal = { AWS = data.aws_caller_identity.current.arn } + }, + ] + }) + + tags = { + "boundary-demo" = local.hashicorp_email + } +} + +# Allow the principal role to assume the happy-path and no-EC2-permission +# target roles in Account B. +resource "aws_iam_policy" "cross_account_principal_assume_targets" { + name = "${random_id.cross_account_principal_name.dec}-assume-targets" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = ["sts:AssumeRole", "sts:SetSourceIdentity"] + Effect = "Allow" + Resource = [ + aws_iam_role.cross_account_target.arn, + aws_iam_role.cross_account_target_no_ec2_permission.arn, + ] + }, + ] + }) +} + +resource "aws_iam_role_policy_attachment" "cross_account_principal_assume_targets" { + role = aws_iam_role.cross_account_principal.name + policy_arn = aws_iam_policy.cross_account_principal_assume_targets.arn +} + +output "cross_account_principal_arn" { + value = aws_iam_role.cross_account_principal.arn +} + +# Error case: caller cannot assume the principal role (first hop denied). +# Reuses assume_role_no_trust from iam_assume_role.tf — its Deny trust policy +# also blocks the test runner from assuming it as a principal. +output "cross_account_principal_no_trust_arn" { + value = aws_iam_role.assume_role_no_trust.arn +} + +# --------------------------------------------------------------------------- +# Account B: happy-path target role +# --------------------------------------------------------------------------- + +resource "random_id" "cross_account_target_name" { + prefix = "BoundaryPluginCATarget" + byte_length = 4 +} + +resource "aws_iam_role" "cross_account_target" { + provider = aws.target + name = random_id.cross_account_target_name.dec + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = ["sts:AssumeRole", "sts:SetSourceIdentity"] + Effect = "Allow" + Principal = { AWS = aws_iam_role.cross_account_principal.arn } + }, + ] + }) + + tags = { + "boundary-demo" = local.hashicorp_email + } +} + +resource "aws_iam_policy" "cross_account_target_ec2_describeinstances" { + provider = aws.target + name = "${random_id.cross_account_target_name.dec}-ec2" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = ["ec2:DescribeInstances"] + Effect = "Allow" + Resource = "*" + }, + ] + }) +} + +resource "aws_iam_role_policy_attachment" "cross_account_target_ec2_describeinstances" { + provider = aws.target + role = aws_iam_role.cross_account_target.name + policy_arn = aws_iam_policy.cross_account_target_ec2_describeinstances.arn +} + +output "cross_account_target_arn" { + value = aws_iam_role.cross_account_target.arn +} + +# --------------------------------------------------------------------------- +# Account B: error case — both hops succeed but target has no ec2:DescribeInstances +# --------------------------------------------------------------------------- + +resource "random_id" "cross_account_target_no_ec2_permission_name" { + prefix = "BoundaryPluginCATargetNoEC2" + byte_length = 4 +} + +resource "aws_iam_role" "cross_account_target_no_ec2_permission" { + provider = aws.target + name = random_id.cross_account_target_no_ec2_permission_name.dec + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = ["sts:AssumeRole", "sts:SetSourceIdentity"] + Effect = "Allow" + Principal = { AWS = aws_iam_role.cross_account_principal.arn } + }, + ] + }) + + tags = { + "boundary-demo" = local.hashicorp_email + } +} + +output "cross_account_target_no_ec2_permission_arn" { + value = aws_iam_role.cross_account_target_no_ec2_permission.arn +} + +# --------------------------------------------------------------------------- +# Account B: error case — principal cannot assume this target (second hop denied) +# --------------------------------------------------------------------------- + +resource "random_id" "cross_account_target_no_trust_name" { + prefix = "BoundaryPluginCATargetNoTrust" + byte_length = 4 +} + +resource "aws_iam_role" "cross_account_target_no_trust" { + provider = aws.target + name = random_id.cross_account_target_no_trust_name.dec + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = ["sts:AssumeRole", "sts:SetSourceIdentity"] + Effect = "Deny" + Principal = { AWS = aws_iam_role.cross_account_principal.arn } + }, + ] + }) + + tags = { + "boundary-demo" = local.hashicorp_email + } +} + +output "cross_account_target_no_trust_arn" { + value = aws_iam_role.cross_account_target_no_trust.arn +} diff --git a/testing/testing.go b/testing/testing.go index 5b18a10..8beef23 100644 --- a/testing/testing.go +++ b/testing/testing.go @@ -39,6 +39,10 @@ type TestTerraformer struct { // The working directory for Terraform commands. workDir string + // vars holds optional key/value pairs passed as -var flags to every + // Terraform command that accepts them (apply, destroy, plan). + vars map[string]string + // The Terraform state. Use the Output method to look up particular // outputs from the Terraform state. state *tfjson.State @@ -53,8 +57,9 @@ type TestTerraformer struct { // NewTestTerraformer initializes a Terraform directory. It just // returns an initialized project, it does not perform any actions on -// it. -func NewTestTerraformer(workDir string) (*TestTerraformer, error) { +// it. An optional vars map may be provided; each entry is passed as a +// -var flag to Terraform commands. +func NewTestTerraformer(workDir string, vars ...map[string]string) (*TestTerraformer, error) { tfPath, err := exec.LookPath("terraform") if err != nil { return nil, fmt.Errorf("LookPath error: %w", err) @@ -64,10 +69,24 @@ func NewTestTerraformer(workDir string) (*TestTerraformer, error) { return nil, errors.New("workDir is empty") } - return &TestTerraformer{ + tf := &TestTerraformer{ tfPath: tfPath, workDir: workDir, - }, nil + } + if len(vars) > 0 { + tf.vars = vars[0] + } + return tf, nil +} + +// varArgs converts the stored vars map into a slice of -var flags +// suitable for appending to a Terraform command's argument list. +func (t *TestTerraformer) varArgs() []string { + args := make([]string, 0, len(t.vars)*2) + for k, v := range t.vars { + args = append(args, "-var", fmt.Sprintf("%s=%s", k, v)) + } + return args } // Deploy runs the following commands, in order: init, apply, plan, @@ -214,7 +233,8 @@ func (t *TestTerraformer) init() error { // apply runs "terraform apply" in the project's working directory. func (t *TestTerraformer) apply() error { - cmd := exec.Command(t.tfPath, "apply", "-input=false", "-auto-approve") + args := append([]string{"apply", "-input=false", "-auto-approve"}, t.varArgs()...) + cmd := exec.Command(t.tfPath, args...) cmd.Dir = t.workDir stdoutStderr, err := cmd.CombinedOutput() if err != nil { @@ -226,7 +246,8 @@ func (t *TestTerraformer) apply() error { // destroy runs "terraform destroy" in the project's working directory. func (t *TestTerraformer) destroy() error { - cmd := exec.Command(t.tfPath, "destroy", "-input=false", "-auto-approve") + args := append([]string{"destroy", "-input=false", "-auto-approve"}, t.varArgs()...) + cmd := exec.Command(t.tfPath, args...) cmd.Dir = t.workDir stdoutStderr, err := cmd.CombinedOutput() if err != nil { @@ -242,7 +263,8 @@ func (t *TestTerraformer) destroy() error { // it's run to get the data for for importing the state via // terraform-json. The apply method ignores the plan generated here. func (t *TestTerraformer) plan() error { - cmd := exec.Command(t.tfPath, "plan", "-input=false", "-out=plan.tfplan") + args := append([]string{"plan", "-input=false", "-out=plan.tfplan"}, t.varArgs()...) + cmd := exec.Command(t.tfPath, args...) cmd.Dir = t.workDir stdoutStderr, err := cmd.CombinedOutput() if err != nil {