From ec00344f5ae471d0de62668836f5374c02dabdd2 Mon Sep 17 00:00:00 2001 From: Ryan Flynn <35703007+ryanflynn9299@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:33:31 -0400 Subject: [PATCH] feat(cli) Implement infra to support cross account AssumeRole for DHC on ListHosts (#86) --- go.mod | 2 +- plugin/service/host/plugin.go | 147 +++++++++++++++-- plugin/service/host/plugin_test.go | 252 ++++++++++++++++++++++++++++- plugin/service/host/testing.go | 11 ++ 4 files changed, 397 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index 4d7e026..9ae7546 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.7 require ( github.com/aws/aws-sdk-go-v2 v1.41.7 + github.com/aws/aws-sdk-go-v2/credentials v1.17.46 github.com/aws/aws-sdk-go-v2/service/ec2 v1.254.0 github.com/aws/aws-sdk-go-v2/service/iam v1.47.6 github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 @@ -26,7 +27,6 @@ require ( github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect github.com/aws/aws-sdk-go-v2/config v1.28.5 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.17.46 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect diff --git a/plugin/service/host/plugin.go b/plugin/service/host/plugin.go index 5f26414..51b3f2e 100644 --- a/plugin/service/host/plugin.go +++ b/plugin/service/host/plugin.go @@ -5,12 +5,17 @@ package host import ( "context" + goerrors "errors" "fmt" "strings" "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials/stscreds" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" + "github.com/aws/aws-sdk-go-v2/service/sts" + stsTypes "github.com/aws/aws-sdk-go-v2/service/sts/types" + "github.com/aws/smithy-go" "github.com/hashicorp/boundary-plugin-aws/internal/credential" "github.com/hashicorp/boundary-plugin-aws/internal/errors" pb "github.com/hashicorp/boundary/sdk/pbs/plugin" @@ -32,6 +37,16 @@ type HostPlugin struct { testCatalogStateOpts []awsCatalogPersistedStateOption } +type hostSetQuery struct { + Id string + Input *ec2.DescribeInstancesInput + Output *ec2.DescribeInstancesOutput + OutputHosts []*pb.ListHostsResponseHost +} + +const defaultSessionName = "boundary-default" +const dryRunOperationErrorCode = "DryRunOperation" + // Ensure that we are implementing HostPluginServiceServer var _ pb.HostPluginServiceServer = (*HostPlugin)(nil) @@ -495,13 +510,6 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (* } // Build all the queries in advance. - type hostSetQuery struct { - Id string - Input *ec2.DescribeInstancesInput - Output *ec2.DescribeInstancesOutput - OutputHosts []*pb.ListHostsResponseHost - } - queries := make([]hostSetQuery, len(sets)) for i, set := range sets { // Validate Id since we use it in output @@ -531,20 +539,22 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (* if catalogAttributes.DualStack { opts = append(opts, WithDualStack(catalogAttributes.DualStack)) } - ec2Client, err := catalogState.EC2Client(ctx, opts...) - if err != nil { - return nil, errors.BadRequestStatusf("error getting EC2 client: %s", err) - } // Run all queries now and assemble output. var maxLen int for i, query := range queries { - output, err := ec2Client.DescribeInstances(ctx, query.Input) + output, err := checkHosts(ctx, catalogState, query.Input, opts, catalogAttributes.TargetAccount) if err != nil { - return nil, errors.BadRequestStatusf("error running DescribeInstances for host set id %q: %s", query.Id, err) + if catalogAttributes.TargetAccount != nil { + return nil, errors.BadRequestStatusf("unable to list hosts for host set %q: %s", query.Id, err) + } + return nil, errors.BadRequestStatusf("error retrieving host results for host set id %q: %s", query.Id, err) } queries[i].Output = output + if output == nil { + continue + } // Process the output here, we will normalize this into a single // set of hosts afterwards (possibly removing duplicates). @@ -711,6 +721,117 @@ func awsInstanceToHost(instance types.Instance, catalogAttributes *CatalogAttrib return result, nil } +func checkHosts(ctx context.Context, + principalCatalogState *awsCatalogPersistedState, + input *ec2.DescribeInstancesInput, + opts []ec2Option, + target *credential.CredentialAttributes, +) (*ec2.DescribeInstancesOutput, error) { + if principalCatalogState == nil { + return nil, fmt.Errorf("unable to create EC2 client from catalog state") + } + + var ec2Client EC2API + var err error + if target == nil { + // use the principal account to execute the DescribeInstances command + ec2Client, err = principalCatalogState.EC2Client(ctx, opts...) + if err != nil { + return nil, err + } + } else { + // use the target account to execute the DescribeInstances command + ec2Client, err = ec2ClientForTarget(ctx, principalCatalogState, target, opts) + if err != nil { + return nil, fmt.Errorf("EC2 client setup failed for target account: %w", err) + } + } + + output, err := ec2Client.DescribeInstances(ctx, input) + if err != nil { + var awsErr smithy.APIError + ok := goerrors.As(err, &awsErr) + if ok && awsErr.ErrorCode() == dryRunOperationErrorCode { + // successful DryRun validation, no response necessary, no error thrown + return nil, nil + } + + if target != nil { + return nil, fmt.Errorf("EC2 DescribeInstances failed for target account: %w", err) + } + return nil, err + } + + return output, nil +} + +// ec2ClientForTarget builds an EC2 client to query the target account. +// The hop is a continuation of the principal session: STS AssumeRole is called +// as the principal to get to the target, then EC2 uses the resulting temporary credentials. +// This is not a second worker-level credential chain on the target attributes. +func ec2ClientForTarget( + ctx context.Context, + principalCatalogState *awsCatalogPersistedState, + target *credential.CredentialAttributes, + opts []ec2Option) (EC2API, error) { + // Reuse the principal SDK config (HTTP client, retries, logger, and the + // principal identity). + principalCfg, err := principalCatalogState.GenerateCredentialChain(ctx) + if err != nil { + return nil, fmt.Errorf("principal AWS configuration could not be loaded: %w", err) + } + if principalCfg == nil { + return nil, fmt.Errorf("nil principal AWS configuration while configuring EC2 client for target account") + } + + // STS must run as the principal so the target role's trust policy sees that + // identity, not the worker's. Session name, external ID, and tags are + // forwarded from attrs when set so the AssumeRole matches the target role's configuration. + stsClient := sts.NewFromConfig(*principalCfg) + provider := stscreds.NewAssumeRoleProvider(stsClient, target.RoleArn, func(o *stscreds.AssumeRoleOptions) { + if target.RoleSessionName != "" { + o.RoleSessionName = target.RoleSessionName + } else { + // STS may reject the AssumeRole request if session name is not provided. + o.RoleSessionName = defaultSessionName + } + if target.RoleExternalId != "" { + o.ExternalID = &target.RoleExternalId + } + for k, v := range target.RoleTags { + o.Tags = append(o.Tags, stsTypes.Tag{ + Key: aws.String(k), + Value: aws.String(v), + }) + } + }) + + // Same worker SDK environment as the principal; only identity and region change. + targetCfg := principalCfg.Copy() + targetCfg.Credentials = aws.NewCredentialsCache(provider) + if target.Region != "" { + targetCfg.Region = target.Region + } + + parsed, err := getOpts(opts...) + if err != nil { + return nil, fmt.Errorf("EC2 client options are invalid: %w", err) + } + // Dual-stack is the worker's path to AWS endpoints, not per-account. Apply + // it on the hopped client the same way EC2Client does for the principal. + var ec2Opts []func(*ec2.Options) + if parsed.withDualStack { + ec2Opts = append(ec2Opts, ec2.WithEndpointResolverV2(&endpointResolver{ + dualStack: parsed.withDualStack, + })) + } + + if principalCatalogState.testEC2APIFunc != nil { + return principalCatalogState.testEC2APIFunc(targetCfg) + } + return ec2.NewFromConfig(targetCfg, ec2Opts...), nil +} + // dryRunValidation performs an AWS DescribeInstances call to verify the state's // credentials, the host listing functionality as well as the filters, if any // are passed in. This function can therefore be used for both host catalog and diff --git a/plugin/service/host/plugin_test.go b/plugin/service/host/plugin_test.go index a10093b..80d342f 100644 --- a/plugin/service/host/plugin_test.go +++ b/plugin/service/host/plugin_test.go @@ -1849,6 +1849,162 @@ func TestPluginOnUpdateSetErr(t *testing.T) { } } +func testListHostsSet(id, filter string) *hostsets.HostSet { + return &hostsets.HostSet{ + Id: id, + Attrs: &hostsets.HostSet_Attributes{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + ConstDescribeInstancesFilters: structpb.NewListValue( + &structpb.ListValue{ + Values: []*structpb.Value{ + structpb.NewStringValue(filter), + }, + }, + ), + }, + }, + }, + } +} + +func testListHostsPersisted() *pb.HostCatalogPersisted { + return &pb.HostCatalogPersisted{ + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + }, + }, + } +} + +func testExpectedDescribeInput(filterKey, filterValue string) *ec2.DescribeInstancesInput { + return &ec2.DescribeInstancesInput{ + DryRun: aws.Bool(false), + Filters: []types.Filter{ + { + Name: aws.String(filterKey), + Values: []string{filterValue}, + }, + { + Name: aws.String("instance-state-name"), + Values: []string{string(types.InstanceStateNameRunning)}, + }, + }, + } +} + +func TestPluginListHosts(t *testing.T) { + describeOutput := &ec2.DescribeInstancesOutput{ + Reservations: []types.Reservation{ + { + Instances: []types.Instance{ + { + InstanceId: aws.String("i-shared"), + PrivateIpAddress: aws.String("10.0.0.1"), + }, + }, + }, + }, + } + + cases := []struct { + name string + catalogFields map[string]*structpb.Value + sets []*hostsets.HostSet + expectedRegion string + expectedHosts []*pb.ListHostsResponseHost + expectedInputs []*ec2.DescribeInstancesInput + expectedDescribe int + }{ + { + name: "principal account lists hosts", + catalogFields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + sets: []*hostsets.HostSet{ + testListHostsSet("set-1", "tag-key=foo"), + }, + expectedRegion: "us-west-2", + expectedHosts: []*pb.ListHostsResponseHost{ + { + ExternalId: "i-shared", + SetIds: []string{"set-1"}, + }, + }, + expectedInputs: []*ec2.DescribeInstancesInput{ + testExpectedDescribeInput("tag-key", "foo"), + }, + expectedDescribe: 1, + }, + { + name: "target account lists hosts without querying principal", + catalogFields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstTargetRoleArn: structpb.NewStringValue("arn:aws:iam::222222222222:role/Target"), + credential.ConstTargetRegion: structpb.NewStringValue("eu-west-1"), + }, + sets: []*hostsets.HostSet{ + testListHostsSet("set-1", "tag-key=foo"), + testListHostsSet("set-2", "tag-key=bar"), + }, + expectedRegion: "eu-west-1", + expectedHosts: []*pb.ListHostsResponseHost{ + { + ExternalId: "i-shared", + SetIds: []string{"set-1", "set-2"}, + }, + }, + expectedInputs: []*ec2.DescribeInstancesInput{ + testExpectedDescribeInput("tag-key", "foo"), + testExpectedDescribeInput("tag-key", "bar"), + }, + expectedDescribe: 2, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + require := require.New(t) + state := &testMockEC2State{} + p := &HostPlugin{ + testCatalogStateOpts: []awsCatalogPersistedStateOption{ + withTestEC2APIFunc(newTestMockEC2( + state, + testMockEC2WithDescribeInstancesOutput(describeOutput), + )), + }, + } + + resp, err := p.ListHosts(context.Background(), &pb.ListHostsRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: &structpb.Struct{Fields: tc.catalogFields}, + }, + }, + Persisted: testListHostsPersisted(), + Sets: tc.sets, + }) + require.NoError(err) + require.NotNil(resp) + require.Len(resp.GetHosts(), len(tc.expectedHosts)) + for i, expected := range tc.expectedHosts { + require.Equal(expected.ExternalId, resp.Hosts[i].ExternalId) + require.Equal(expected.SetIds, resp.Hosts[i].SetIds) + } + + require.Equal(tc.expectedDescribe, state.DescribeInstancesCallCount) + require.Len(state.ClientRegions, tc.expectedDescribe) + for _, region := range state.ClientRegions { + require.Equal(tc.expectedRegion, region) + } + require.Equal(tc.expectedInputs, state.DescribeInstancesInputs) + }) + } +} + func TestPluginListHostsErr(t *testing.T) { cases := []struct { name string @@ -2130,7 +2286,101 @@ func TestPluginListHostsErr(t *testing.T) { testMockEC2WithDescribeInstancesError(errors.New(testDescribeInstancesError)), )), }, - expectedErrContains: fmt.Sprintf("error running DescribeInstances for host set id \"foobar\": %s", testDescribeInstancesError), + expectedErrContains: fmt.Sprintf("error retrieving host results for host set id \"foobar\": %s", testDescribeInstancesError), + expectedErrCode: codes.InvalidArgument, + }, + { + name: "target attributes missing target_role_arn", + req: &pb.ListHostsRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstTargetRegion: structpb.NewStringValue("eu-west-1"), + }, + }, + }, + }, + }, + expectedErrContains: "attributes.target_role_arn: missing required value \"target_role_arn\"", + expectedErrCode: codes.InvalidArgument, + }, + { + name: "target EC2 client error", + req: &pb.ListHostsRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstTargetRoleArn: structpb.NewStringValue("arn:aws:iam::222222222222:role/Target"), + }, + }, + }, + }, + Persisted: &pb.HostCatalogPersisted{ + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, + }, + Sets: []*hostsets.HostSet{ + { + Id: "foobar", + Attrs: &hostsets.HostSet_Attributes{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + ConstDescribeInstancesFilters: structpb.NewListValue( + &structpb.ListValue{ + Values: []*structpb.Value{ + structpb.NewStringValue("tag-key=foo"), + }, + }, + ), + }, + }, + }, + }, + }, + }, + catalogOpts: []awsCatalogPersistedStateOption{ + withTestEC2APIFunc(func(...aws.Config) (EC2API, error) { + return nil, errors.New("test EC2 client error") + }), + }, + expectedErrContains: "unable to list hosts for host set \"foobar\": EC2 client setup failed for target account: test EC2 client error", + expectedErrCode: codes.InvalidArgument, + }, + { + name: "target DescribeInstances error", + req: &pb.ListHostsRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstTargetRoleArn: structpb.NewStringValue("arn:aws:iam::222222222222:role/Target"), + credential.ConstTargetRegion: structpb.NewStringValue("eu-west-1"), + }, + }, + }, + }, + Persisted: testListHostsPersisted(), + Sets: []*hostsets.HostSet{ + testListHostsSet("foobar", "tag-key=foo"), + }, + }, + catalogOpts: []awsCatalogPersistedStateOption{ + withTestEC2APIFunc(newTestMockEC2( + nil, + testMockEC2WithDescribeInstancesError(errors.New(testDescribeInstancesError)), + )), + }, + expectedErrContains: "unable to list hosts for host set \"foobar\": EC2 DescribeInstances failed for target account: DescribeInstances error", expectedErrCode: codes.InvalidArgument, }, { diff --git a/plugin/service/host/testing.go b/plugin/service/host/testing.go index b45dc18..92f19ac 100644 --- a/plugin/service/host/testing.go +++ b/plugin/service/host/testing.go @@ -22,11 +22,17 @@ const ( type testMockEC2State struct { DescribeInstancesCalled bool DescribeInstancesInputParams *ec2.DescribeInstancesInput + DescribeInstancesCallCount int + DescribeInstancesInputs []*ec2.DescribeInstancesInput + ClientRegions []string } func (s *testMockEC2State) Reset() { s.DescribeInstancesCalled = false s.DescribeInstancesInputParams = nil + s.DescribeInstancesCallCount = 0 + s.DescribeInstancesInputs = nil + s.ClientRegions = nil } type testMockEC2 struct { @@ -72,7 +78,10 @@ func newTestMockEC2(state *testMockEC2State, opts ...testMockEC2Option) ec2APIFu if cfg.Region != "" { m.Region = cfg.Region } + } + if state != nil { + state.ClientRegions = append(state.ClientRegions, m.Region) } return m, nil @@ -83,6 +92,8 @@ func (m *testMockEC2) DescribeInstances(ctx context.Context, input *ec2.Describe if m.State != nil { m.State.DescribeInstancesCalled = true m.State.DescribeInstancesInputParams = input + m.State.DescribeInstancesCallCount++ + m.State.DescribeInstancesInputs = append(m.State.DescribeInstancesInputs, input) } if m.DescribeInstancesError != nil {