feat(cli) Implement infra to support cross account AssumeRole for DHC on ListHosts (#86)

This commit is contained in:
Ryan Flynn authored and Matt Romanelli committed 2026-09-28 12:01:06 -05:00
1 parent 0cd593a433
commit ec00344f5a
4 files changed
+397 -15

No files matched your search

+1 -1
View File
@@ -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
+134 -13
View File
@@ -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
+251 -1
View File
@@ -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,
},
{
+11
View File
@@ -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 {