Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f81151b58c | ||
|
|
e484c09479 | ||
|
|
4c4a901d33 |
No files matched your search
@@ -25,6 +25,8 @@ Credential rotation can be turned off by setting the
|
||||
This plugin will attempt to assume a role when a `role_arn` is supplied through the
|
||||
`attributes` object. More information about assume an AWS role can be found [here](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html). This feature only works when the plugin is running on a self managed Boundary [worker](https://developer.hashicorp.com/boundary/tutorials/hcp-administration/hcp-manage-workers).
|
||||
|
||||
To discover hosts across multiple AWS accounts, supply a `target_role_arn` in addition to `role_arn`. The plugin uses the principal credentials to assume the target role via a two-hop `AssumeRole` chain, enabling cross-account host discovery from a single worker. See [Attributes and Secrets](plugin/service/host/README.md#attributes-and-secrets) for the full list of `target_*` attributes.
|
||||
|
||||
### Environment Credentials
|
||||
|
||||
This plugin will attempt to retrieve credentials from environment variables. More
|
||||
|
||||
@@ -23,6 +23,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// AwsErrorAuthFailure is returned by EC2 when credentials exist in IAM
|
||||
// but have not yet propagated to EC2. This is a transient eventual
|
||||
// consistency issue that can resolve itself by retrying the request.
|
||||
AwsErrorAuthFailure = "AuthFailure"
|
||||
|
||||
// awsErrorAccessDenied is returned when the credentials do not have
|
||||
// the required permissions to do a specific action on a resource.
|
||||
// This error will persist until the IAM Role Policy is updated by
|
||||
|
||||
@@ -109,13 +109,23 @@ The following `attributes` are valid on an AWS host catalog resource:
|
||||
- `region` (string): The region to configure the host catalog for. All host sets
|
||||
in this catalog will be configured for this region.
|
||||
- `role_arn` (string): The AWS Role ARN used for `AssumeRole` authentication. If
|
||||
provided, `disable_credential_rotation` must be also set to `true.
|
||||
- `role_external_id` (string): The external id configured for the `AssumeRole`
|
||||
provided, `disable_credential_rotation` must be also set to `true`.
|
||||
- `role_external_id` (string): The external ID configured for the principal `AssumeRole`
|
||||
provider.
|
||||
- `role_session_name` (string): The session name configured for the `AssumeRole`
|
||||
- `role_session_name` (string): The session name configured for the principal `AssumeRole`
|
||||
provider.
|
||||
- `role_tags` (object): The key-value pair tags configured for the `AssumeRole`
|
||||
- `role_tags` (object): The key-value pair tags configured for the principal `AssumeRole`
|
||||
provider.
|
||||
- `target_role_arn` (string): The AWS role ARN of the target IAM role to assume for
|
||||
cross-account discovery. Required if any other `target_*` attributes are configured.
|
||||
- `target_region` (string): The region where the target hosts reside. Defaults to the
|
||||
principal account `region` if omitted.
|
||||
- `target_role_external_id` (string): The external ID configured for the target
|
||||
`AssumeRole` provider.
|
||||
- `target_role_session_name` (string): The session name configured for the target
|
||||
`AssumeRole` provider.
|
||||
- `target_role_tags` (object): The key-value pair tags configured for the target
|
||||
`AssumeRole` provider.
|
||||
|
||||
The following `secrets` are required on an AWS host catalog resource:
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
goerrors "errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials/stscreds"
|
||||
@@ -828,10 +829,18 @@ func ec2ClientForTarget(
|
||||
return ec2.NewFromConfig(targetCfg, ec2Opts...), nil
|
||||
}
|
||||
|
||||
// dryRunValidationTimeout is the maximum time to wait for credentials to
|
||||
// propagate across AWS services after rotation before giving up.
|
||||
const dryRunValidationTimeout = 30 * time.Second
|
||||
|
||||
// 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
|
||||
// host set validation.
|
||||
//
|
||||
// After credential rotation, IAM keys may be valid for STS (GetCallerIdentity)
|
||||
// but not yet propagated to EC2. This function retries on AuthFailure to
|
||||
// tolerate that eventual consistency window.
|
||||
func dryRunValidation(ctx context.Context, state *awsCatalogPersistedState, ec2Opts []ec2Option, filters ...types.Filter) *status.Status {
|
||||
if state == nil {
|
||||
return status.New(codes.InvalidArgument, "persisted state is required")
|
||||
@@ -842,12 +851,34 @@ func dryRunValidation(ctx context.Context, state *awsCatalogPersistedState, ec2O
|
||||
return status.New(codes.InvalidArgument, fmt.Sprintf("error getting EC2 client: %s", err))
|
||||
}
|
||||
|
||||
_, err = ec2Client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{Filters: filters})
|
||||
if err != nil {
|
||||
return status.New(codes.FailedPrecondition, fmt.Sprintf("aws describe instances failed: %s", err))
|
||||
}
|
||||
deadline := time.Now().Add(dryRunValidationTimeout)
|
||||
delay := time.Second
|
||||
for {
|
||||
_, err = ec2Client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{Filters: filters})
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
// AuthFailure indicates the credentials exist but haven't propagated
|
||||
// to EC2 yet — a transient eventual consistency issue after rotation.
|
||||
// Retry until the deadline.
|
||||
if strings.Contains(err.Error(), errors.AwsErrorAuthFailure) && time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-time.After(delay):
|
||||
// Wait before retrying to give credentials time to propagate.
|
||||
case <-ctx.Done():
|
||||
// The caller cancelled — break out of the select.
|
||||
break
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
return status.New(codes.FailedPrecondition, fmt.Sprintf("aws describe instances failed: %s", err))
|
||||
}
|
||||
|
||||
// appendDistinct will append the elements to the slice
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
iamTypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
|
||||
pluginErrors "github.com/hashicorp/boundary-plugin-aws/internal/errors"
|
||||
"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"
|
||||
@@ -3121,4 +3122,66 @@ func TestDryRunValidation(t *testing.T) {
|
||||
}, []ec2Option{})
|
||||
require.Nil(t, st)
|
||||
})
|
||||
|
||||
t.Run("authFailureThenSuccess", func(t *testing.T) {
|
||||
// Simulates the IAM eventual consistency window: EC2 returns AuthFailure
|
||||
// twice before the credentials propagate and the call succeeds.
|
||||
state := &testMockEC2State{}
|
||||
st := dryRunValidation(context.Background(), &awsCatalogPersistedState{
|
||||
AwsCredentialPersistedState: &credential.AwsCredentialPersistedState{
|
||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
||||
AccessKey: "AKIAfoo",
|
||||
SecretKey: "baz",
|
||||
},
|
||||
},
|
||||
testEC2APIFunc: newTestMockEC2(state,
|
||||
testMockEC2WithDescribeInstancesErrorSequence(
|
||||
fmt.Errorf("%s: credentials not yet propagated", pluginErrors.AwsErrorAuthFailure),
|
||||
fmt.Errorf("%s: credentials not yet propagated", pluginErrors.AwsErrorAuthFailure),
|
||||
),
|
||||
testMockEC2WithDescribeInstancesOutput(&ec2.DescribeInstancesOutput{}),
|
||||
),
|
||||
}, []ec2Option{})
|
||||
require.Nil(t, st)
|
||||
require.Equal(t, 3, state.DescribeInstancesCallCount)
|
||||
})
|
||||
|
||||
t.Run("authFailureContextCancelled", func(t *testing.T) {
|
||||
// If the context is cancelled while waiting to retry, the function should
|
||||
// return a FailedPrecondition error immediately rather than hanging.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately so the first retry select hits ctx.Done()
|
||||
st := dryRunValidation(ctx, &awsCatalogPersistedState{
|
||||
AwsCredentialPersistedState: &credential.AwsCredentialPersistedState{
|
||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
||||
AccessKey: "AKIAfoo",
|
||||
SecretKey: "baz",
|
||||
},
|
||||
},
|
||||
testEC2APIFunc: newTestMockEC2(nil,
|
||||
testMockEC2WithDescribeInstancesError(fmt.Errorf("%s: credentials not yet propagated", pluginErrors.AwsErrorAuthFailure)),
|
||||
),
|
||||
}, []ec2Option{})
|
||||
require.NotNil(t, st)
|
||||
require.Equal(t, codes.FailedPrecondition.String(), st.Code().String())
|
||||
})
|
||||
|
||||
t.Run("nonAuthFailureNoRetry", func(t *testing.T) {
|
||||
// A non-AuthFailure error should fail immediately without retrying.
|
||||
state := &testMockEC2State{}
|
||||
st := dryRunValidation(context.Background(), &awsCatalogPersistedState{
|
||||
AwsCredentialPersistedState: &credential.AwsCredentialPersistedState{
|
||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
||||
AccessKey: "AKIAfoo",
|
||||
SecretKey: "baz",
|
||||
},
|
||||
},
|
||||
testEC2APIFunc: newTestMockEC2(state,
|
||||
testMockEC2WithDescribeInstancesError(fmt.Errorf("UnauthorizedOperation: access denied")),
|
||||
),
|
||||
}, []ec2Option{})
|
||||
require.NotNil(t, st)
|
||||
require.Equal(t, codes.FailedPrecondition.String(), st.Code().String())
|
||||
require.Equal(t, 1, state.DescribeInstancesCallCount)
|
||||
})
|
||||
}
|
||||
@@ -43,6 +43,10 @@ type testMockEC2 struct {
|
||||
|
||||
DescribeInstancesOutput *ec2.DescribeInstancesOutput
|
||||
DescribeInstancesError error
|
||||
|
||||
// DescribeInstancesErrorSequence, if set, returns each error in order on
|
||||
// successive calls. Once exhausted, DescribeInstancesOutput is returned.
|
||||
DescribeInstancesErrorSequence []error
|
||||
}
|
||||
|
||||
type testMockEC2Option func(m *testMockEC2) error
|
||||
@@ -61,6 +65,16 @@ func testMockEC2WithDescribeInstancesError(e error) testMockEC2Option {
|
||||
}
|
||||
}
|
||||
|
||||
// testMockEC2WithDescribeInstancesErrorSequence sets a sequence of errors to
|
||||
// return on successive DescribeInstances calls. Once the sequence is exhausted,
|
||||
// the mock returns DescribeInstancesOutput (nil error).
|
||||
func testMockEC2WithDescribeInstancesErrorSequence(errs ...error) testMockEC2Option {
|
||||
return func(m *testMockEC2) error {
|
||||
m.DescribeInstancesErrorSequence = errs
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func newTestMockEC2(state *testMockEC2State, opts ...testMockEC2Option) ec2APIFunc {
|
||||
return func(cfgs ...aws.Config) (EC2API, error) {
|
||||
m := &testMockEC2{
|
||||
@@ -96,6 +110,12 @@ func (m *testMockEC2) DescribeInstances(ctx context.Context, input *ec2.Describe
|
||||
m.State.DescribeInstancesInputs = append(m.State.DescribeInstancesInputs, input)
|
||||
}
|
||||
|
||||
if len(m.DescribeInstancesErrorSequence) > 0 {
|
||||
err := m.DescribeInstancesErrorSequence[0]
|
||||
m.DescribeInstancesErrorSequence = m.DescribeInstancesErrorSequence[1:]
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if m.DescribeInstancesError != nil {
|
||||
return nil, m.DescribeInstancesError
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user