diff --git a/lib/configurators/aws/aws.go b/lib/configurators/aws/aws.go index 5bb29783e48..868ccb42b9b 100644 --- a/lib/configurators/aws/aws.go +++ b/lib/configurators/aws/aws.go @@ -288,6 +288,9 @@ type awsConfigurator struct { // actions list of the configurator actions, those are populated on the // `build` function. actions []configurators.ConfiguratorAction + // targetAccounts is a list of AWS account IDs that will be affected by + // configuration. + targetAccounts []string } type ConfiguratorConfig struct { @@ -295,17 +298,73 @@ type ConfiguratorConfig struct { Flags configurators.BootstrapFlags // ServiceConfig Teleport database service config. ServiceConfig *servicecfg.Config - // Policies instance of the `Policies` that the actions use. - Policies awslib.Policies - // Identity is the current AWS credentials chain identity. - Identity awslib.Identity + // awsConfigs is a cache of AWS configs. + awsConfigs *awsconfig.Cache + // identity is a cached AWS identity. + identity awslib.Identity + // getPolicies gets the AWS policy client for the specified assume role ARN + // and external ID. assumeRoleARN and externalID may be empty. + // Overridden in tests. + getPolicies func(ctx context.Context, assumeRoleARN, externalID string) (awslib.Policies, error) + // getIAMClient gets the AWS IAM client for the specified assume role ARN + // and external ID. assumeRoleARN and externalID may be empty. + // Overridden in tests. + getIAMClient func(ctx context.Context, assumeRoleARN, externalID string) (iamClient, error) + // getSSMClient gets the AWS SSM client for the specified assume role ARN, + // external ID, and region. assumeRoleARN and externalID may be empty. + // Overridden in tests. + getSSMClient func(ctx context.Context, region, assumeRoleARN, externalID string) (ssmClient, error) +} - // awsCfg is the configuration used for AWS service clients. - awsCfg *aws.Config - // iamClient is an AWS IAM client. - iamClient iamClient - // ssmClients is a mapping of region -> AWS SSM client - ssmClients map[string]ssmClient +// getAWSConfig gets the cached AWS config for the specified assume role ARN +// and external ID. assumeRoleARN and externalID may be empty. +func (c *ConfiguratorConfig) getAWSConfig(ctx context.Context, assumeRoleARN, externalID string) (aws.Config, error) { + if c.Flags.Manual { + return aws.Config{}, trace.BadParameter("GetAWSConfig not allowed in manual mode") + } + cfg, err := c.awsConfigs.GetConfig( + ctx, + "", /* get region from env > profile > fallback func */ + awsconfig.WithFallbackRegionResolver(func(ctx context.Context) (string, error) { + return getFallbackRegion(ctx, os.Stdout, nil), nil + }), + awsconfig.WithAmbientCredentials(), + awsconfig.WithAssumeRole(assumeRoleARN, externalID), + ) + if err != nil { + return aws.Config{}, trace.Wrap(err) + } + return cfg, nil +} + +// getIdentity gets the cached AWS identity for the specified assume role ARN +// and external ID. assumeRoleARN and externalID may be empty. +func (c *ConfiguratorConfig) getIdentity(ctx context.Context, assumeRoleARN, externalID string) (awslib.Identity, error) { + // Assumed roles can be determined from the ARN. + if assumeRoleARN != "" { + identity, err := awslib.IdentityFromArn(assumeRoleARN) + return identity, trace.Wrap(err) + } + // Return a placeholder in manual mode. + if c.Flags.Manual { + identity, err := awslib.IdentityFromArn(buildIAMARN(targetIdentityARNSectionPlaceholder, targetIdentityARNSectionPlaceholder, "user", defaultAttachUser)) + return identity, trace.Wrap(err) + } + // Check cache. + if c.identity != nil { + return c.identity, nil + } + // Fetch identity. + awsCfg, err := c.getAWSConfig(ctx, assumeRoleARN, externalID) + if err != nil { + return nil, trace.Wrap(err) + } + identity, err := awslib.GetIdentityWithClient(ctx, getSTSClient(awsCfg)) + if err != nil { + return nil, trace.Wrap(err) + } + c.identity = identity + return identity, nil } type iamClient interface { @@ -359,94 +418,114 @@ To avoid seeing this warning, please provide a region in your AWS config or thro // CheckAndSetDefaults checks and set configuration default values. func (c *ConfiguratorConfig) CheckAndSetDefaults() error { - ctx := context.Background() if c.ServiceConfig == nil { return trace.BadParameter("config file is required") } - - // When running the command in manual mode, we want to have zero dependency - // with AWS configurations (like awscli or environment variables), so that - // the user can run this command and generate the instructions without any - // pre-requisite. - if !c.Flags.Manual { - var err error - - if c.awsCfg == nil { - cfg, err := awsconfig.GetConfig( - ctx, - "", /* get region from env > profile > fallback func */ - awsconfig.WithFallbackRegionResolver(func(ctx context.Context) (string, error) { - return getFallbackRegion(ctx, os.Stdout, nil), nil - }), - awsconfig.WithAmbientCredentials(), - awsconfig.WithAssumeRole(c.Flags.AssumeRoleARN, c.Flags.ExternalID), - ) - if err != nil { - return trace.Wrap(err) - } - c.awsCfg = &cfg + if c.awsConfigs == nil { + cache, err := awsconfig.NewCache() + if err != nil { + return trace.Wrap(err) } - - if c.iamClient == nil { - c.iamClient = iamutils.NewFromConfig(*c.awsCfg, func(o *iam.Options) { + c.awsConfigs = cache + } + if c.getPolicies == nil { + c.getPolicies = func(ctx context.Context, assumeRoleARN, externalID string) (awslib.Policies, error) { + awsCfg, err := c.getAWSConfig(ctx, assumeRoleARN, externalID) + if err != nil { + return nil, trace.Wrap(err) + } + identity, err := c.getIdentity(ctx, assumeRoleARN, externalID) + if err != nil { + return nil, trace.Wrap(err) + } + iamClient := iamutils.NewFromConfig(awsCfg, func(o *iam.Options) { o.TracerProvider = smithyoteltracing.Adapt(otel.GetTracerProvider()) }) + partition := identity.GetPartition() + accountID := identity.GetAccountID() + return awslib.NewPolicies(partition, accountID, iamClient), nil } - if c.Identity == nil { - c.Identity, err = awslib.GetIdentityWithClient(ctx, getSTSClient(*c.awsCfg)) + } + if c.getIAMClient == nil { + c.getIAMClient = func(ctx context.Context, assumeRoleARN, externalID string) (iamClient, error) { + awsCfg, err := c.getAWSConfig(ctx, assumeRoleARN, externalID) if err != nil { - return trace.Wrap(err) + return nil, trace.Wrap(err) } + return iamutils.NewFromConfig(awsCfg, func(o *iam.Options) { + o.TracerProvider = smithyoteltracing.Adapt(otel.GetTracerProvider()) + }), nil } - - if c.ssmClients == nil { - c.ssmClients = make(map[string]ssmClient) - for _, matcher := range c.ServiceConfig.Discovery.AWSMatchers { - if !slices.Contains(matcher.Types, types.AWSMatcherEC2) { - continue - } - for _, region := range matcher.Regions { - if _, ok := c.ssmClients[region]; ok { - continue - } - withRegion := func(o *ssm.Options) { - o.Region = region - } - c.ssmClients[region] = ssm.NewFromConfig(*c.awsCfg, withRegion, func(o *ssm.Options) { - o.TracerProvider = smithyoteltracing.Adapt(otel.GetTracerProvider()) - }) - } + } + if c.getSSMClient == nil { + c.getSSMClient = func(ctx context.Context, region, assumeRoleARN, externalID string) (ssmClient, error) { + awsCfg, err := c.getAWSConfig(ctx, assumeRoleARN, externalID) + if err != nil { + return nil, trace.Wrap(err) } - - } - - if c.Policies == nil { - partition := c.Identity.GetPartition() - accountID := c.Identity.GetAccountID() - iamClient := iamutils.NewFromConfig(*c.awsCfg, func(o *iam.Options) { + return ssm.NewFromConfig(awsCfg, func(o *ssm.Options) { + o.Region = region o.TracerProvider = smithyoteltracing.Adapt(otel.GetTracerProvider()) - }) - c.Policies = awslib.NewPolicies(partition, accountID, iamClient) + }), nil } } - return nil } +// getDistinctAssumedRoles gets a list of the distinct roles that can be assumed +// from the AWS matchers. If there are no AWS matchers, one AssumeRole +// is returned for the current identity. +func (c *ConfiguratorConfig) getDistinctAssumedRoles() []types.AssumeRole { + defaultAssumeRole := types.AssumeRole{ + RoleARN: c.Flags.AssumeRoleARN, + ExternalID: c.Flags.ExternalID, + } + matchers := awsMatchersFromConfig(c.Flags, c.ServiceConfig) + if len(matchers) == 0 { + return []types.AssumeRole{defaultAssumeRole} + } + + assumedRoles := make([]types.AssumeRole, 0, len(matchers)) + for _, matcher := range matchers { + if ar := matcher.AssumeRole; ar == nil { + assumedRoles = append(assumedRoles, defaultAssumeRole) + } else { + assumedRoles = append(assumedRoles, *ar) + } + } + return apiutils.DeduplicateAny(assumedRoles, func(ar1, ar2 types.AssumeRole) bool { + return ar1.RoleARN == ar2.RoleARN && ar1.ExternalID == ar2.ExternalID + }) +} + // NewAWSConfigurator creates an instance of awsConfigurator and builds its // actions. -func NewAWSConfigurator(config ConfiguratorConfig) (configurators.Configurator, error) { +func NewAWSConfigurator(ctx context.Context, config ConfiguratorConfig) (configurators.Configurator, error) { err := config.CheckAndSetDefaults() if err != nil { return nil, trace.Wrap(err) } - actions, err := buildActions(config) + actions, err := buildActions(ctx, config) if err != nil { return nil, trace.Wrap(err) } - return &awsConfigurator{config, actions}, nil + assumedRoles := config.getDistinctAssumedRoles() + targetAccounts := make([]string, 0, len(assumedRoles)) + for _, ar := range assumedRoles { + identity, err := config.getIdentity(ctx, ar.RoleARN, ar.ExternalID) + if err != nil { + return nil, trace.Wrap(err) + } + targetAccounts = append(targetAccounts, identity.GetAccountID()) + } + + return &awsConfigurator{ + config: config, + actions: actions, + targetAccounts: apiutils.Deduplicate(targetAccounts), + }, nil } // IsEmpty checks if the configurator has no actions. @@ -461,7 +540,7 @@ func (a *awsConfigurator) Name() string { // Description returns a brief description of the configurator. func (a *awsConfigurator) Description() string { - return "Configure AWS for " + a.config.Flags.Service.Name() + return fmt.Sprintf("Configure AWS for %s for accounts: [%s]", a.config.Flags.Service.Name(), strings.Join(a.targetAccounts, ", ")) } // Actions list of configurator actions. @@ -478,11 +557,13 @@ type awsPolicyCreator struct { policy *awslib.Policy // formattedPolicy human-readable representation of the policy document. formattedPolicy string + // accountID is the account that the policy will be created in. + accountID string } // Description returns what the action will perform. func (a *awsPolicyCreator) Description() string { - return fmt.Sprintf("Create IAM Policy %q", a.policy.Name) + return fmt.Sprintf("[%s] Create IAM Policy %q", a.accountID, a.policy.Name) } // Details returns the policy document that will be created. @@ -515,7 +596,7 @@ type awsPoliciesAttacher struct { // Description returns what the action will perform. func (a *awsPoliciesAttacher) Description() string { - return fmt.Sprintf("Attach IAM policies to %q", a.target.GetName()) + return fmt.Sprintf("[%s] Attach IAM policies to %q", a.target.GetAccountID(), a.target.GetName()) } // Details attacher doesn't have any extra detail, this function returns an @@ -543,8 +624,8 @@ func (a *awsPoliciesAttacher) Execute(ctx context.Context, actionCtx *configurat return nil } -func buildDiscoveryActions(config ConfiguratorConfig, targetCfg targetConfig) ([]configurators.ConfiguratorAction, error) { - actions, err := buildCommonActions(config, targetCfg) +func buildDiscoveryActions(ctx context.Context, config ConfiguratorConfig, targetCfg targetConfig) ([]configurators.ConfiguratorAction, error) { + actions, err := buildCommonActions(ctx, config, targetCfg) if err != nil { return nil, trace.Wrap(err) } @@ -554,11 +635,16 @@ func buildDiscoveryActions(config ConfiguratorConfig, targetCfg targetConfig) ([ return nil, err } - actions = append(actions, buildSSMDocumentCreators(config.ssmClients, targetCfg, proxyAddr)...) + ssmActions, err := buildSSMDocumentCreators(ctx, config, targetCfg, proxyAddr) + if err != nil { + return nil, trace.Wrap(err) + } + + actions = append(actions, ssmActions...) return actions, nil } -func buildCommonActions(config ConfiguratorConfig, targetCfg targetConfig) ([]configurators.ConfiguratorAction, error) { +func buildCommonActions(ctx context.Context, config ConfiguratorConfig, targetCfg targetConfig) ([]configurators.ConfiguratorAction, error) { // Generate policies. policy, err := buildPolicyDocument(config.Flags, targetCfg) if err != nil { @@ -577,81 +663,132 @@ func buildCommonActions(config ConfiguratorConfig, targetCfg targetConfig) ([]co return nil, trace.Wrap(err) } var actions []configurators.ConfiguratorAction + var policies awslib.Policies + if !config.Flags.Manual { + policies, err = config.getPolicies(ctx, targetCfg.assumeRole.RoleARN, targetCfg.assumeRole.ExternalID) + if err != nil { + return nil, trace.Wrap(err) + } + } // Create IAM Policy. actions = append(actions, &awsPolicyCreator{ - policies: config.Policies, + policies: policies, policy: policy, formattedPolicy: formattedPolicy, + accountID: targetCfg.identity.GetAccountID(), }) // Attach the policy to the target. - actions = append(actions, &awsPoliciesAttacher{policies: config.Policies, target: targetCfg.identity}) + actions = append(actions, &awsPoliciesAttacher{policies: policies, target: targetCfg.identity}) return actions, nil } // buildActions generates the policy documents and configurator actions. -func buildActions(config ConfiguratorConfig) ([]configurators.ConfiguratorAction, error) { - // Identity is going to be empty (`nil`) when running the command on - // `Manual` mode, place a wildcard to keep the generated policies valid. - accountID := targetIdentityARNSectionPlaceholder - partitionID := targetIdentityARNSectionPlaceholder - if config.Identity != nil { - accountID = config.Identity.GetAccountID() - partitionID = config.Identity.GetPartition() +func buildActions(ctx context.Context, config ConfiguratorConfig) ([]configurators.ConfiguratorAction, error) { + var allActions []configurators.ConfiguratorAction + for _, assumeRole := range config.getDistinctAssumedRoles() { + target, err := policiesTarget(ctx, config, assumeRole) + if err != nil { + var unreachableErr unreachablePolicyTargetError + if errors.As(err, &unreachableErr) { + fmt.Printf("⚠️ Skipping matchers with identity %q: %s\n", unreachableErr.from.GetName(), unreachableErr.Error()) + continue + } + return nil, trace.Wrap(err) + } + targetCfg, err := getTargetConfig(config.Flags, config.ServiceConfig, target, assumeRole) + if err != nil { + return nil, trace.Wrap(err) + } + var actions []configurators.ConfiguratorAction + if config.Flags.Service.IsDiscovery() { + actions, err = buildDiscoveryActions(ctx, config, targetCfg) + } else { + actions, err = buildCommonActions(ctx, config, targetCfg) + } + if err != nil { + return nil, trace.Wrap(err) + } + allActions = append(allActions, actions...) } - // Define the target and target type. - target, err := policiesTarget(config.Flags, accountID, partitionID, config.Identity, config.iamClient) - if err != nil { - return nil, trace.Wrap(err) - } + return allActions, nil +} - targetCfg, err := getTargetConfig(config.Flags, config.ServiceConfig, target) - if err != nil { - return nil, trace.Wrap(err) - } +// unreachablePolicyTargetError indicates that a target identity could not be +// accessed from another identity (typically due to them being in different +// accounts). +type unreachablePolicyTargetError struct { + target awslib.Identity + from awslib.Identity +} - if config.Flags.Service.IsDiscovery() { - return buildDiscoveryActions(config, targetCfg) - } - return buildCommonActions(config, targetCfg) +func (e unreachablePolicyTargetError) Error() string { + return fmt.Sprintf( + "%q is unreachable from %q", + e.target, e.from, + ) } // policiesTarget defines which target and its type the policies will be // attached to. -func policiesTarget(flags configurators.BootstrapFlags, accountID string, partitionID string, identity awslib.Identity, iamClient iamClient) (awslib.Identity, error) { - if flags.AttachToUser != "" { - userArn := flags.AttachToUser - if !arn.IsARN(flags.AttachToUser) { - userArn = buildIAMARN(partitionID, accountID, "user", flags.AttachToUser) +func policiesTarget(ctx context.Context, config ConfiguratorConfig, targetAssumeRole types.AssumeRole) (awslib.Identity, error) { + baseIdentity, err := config.getIdentity(ctx, targetAssumeRole.RoleARN, targetAssumeRole.ExternalID) + if err != nil { + return nil, trace.Wrap(err) + } + defaultPartitionID := baseIdentity.GetPartition() + defaultAccountID := baseIdentity.GetAccountID() + + // Attach to user if provided. + attachToUser := config.Flags.AttachToUser + if attachToUser != "" { + userArn := attachToUser + if !arn.IsARN(attachToUser) { + userArn = buildIAMARN(defaultPartitionID, defaultAccountID, "user", attachToUser) } - - return awslib.IdentityFromArn(userArn) - } - - if flags.AttachToRole != "" { - roleArn := flags.AttachToRole - if !arn.IsARN(flags.AttachToRole) { - roleArn = buildIAMARN(partitionID, accountID, "role", flags.AttachToRole) + userIdentity, err := awslib.IdentityFromArn(userArn) + if err != nil { + return nil, trace.Wrap(err) } - - return awslib.IdentityFromArn(roleArn) + if defaultAccountID != userIdentity.GetAccountID() { + return nil, unreachablePolicyTargetError{target: userIdentity, from: baseIdentity} + } + return userIdentity, nil } - if identity == nil { - return awslib.IdentityFromArn(buildIAMARN(partitionID, accountID, "user", defaultAttachUser)) + // Attach to role if provided. + attachToRole := config.Flags.AttachToRole + if attachToRole != "" { + roleArn := attachToRole + if !arn.IsARN(attachToRole) { + roleArn = buildIAMARN(defaultPartitionID, defaultAccountID, "role", attachToRole) + } + roleIdentity, err := awslib.IdentityFromArn(roleArn) + if err != nil { + return nil, trace.Wrap(err) + } + if defaultAccountID != roleIdentity.GetAccountID() { + return nil, unreachablePolicyTargetError{target: roleIdentity, from: baseIdentity} + } + return roleIdentity, nil } - if identity.GetType() == awslib.ResourceTypeAssumedRole { - roleIdentity, err := getRoleARNForAssumedRole(iamClient, identity) + // Attach to current identity. + if baseIdentity.GetType() == awslib.ResourceTypeAssumedRole { + baseIAMClient, err := config.getIAMClient(ctx, targetAssumeRole.RoleARN, targetAssumeRole.ExternalID) + if err != nil { + return nil, trace.Wrap(err) + } + roleIdentity, err := getRoleARNForAssumedRole(baseIAMClient, baseIdentity) if err != nil { return nil, trace.Wrap(err) } return roleIdentity, nil } - return identity, nil + return baseIdentity, nil } // buildIAMARN constructs an AWS IAM ARN string from the given partition, @@ -686,6 +823,9 @@ func failedToResolveAssumeRoleARN(roleIdentity string) string { // This is necessary since the assumed-role ARN does not include the role path, // so we cannot reliably reconstruct the role ARN from the assumed-role ARN. func getRoleARNForAssumedRole(iamClient iamClient, identity awslib.Identity) (awslib.Identity, error) { + if iamClient == nil { + return nil, trace.BadParameter("missing iamClient") + } failedToResolveAssumeRoleARN := failedToResolveAssumeRoleARN(identity.GetName()) out, err := iamClient.GetRole(context.Background(), &iam.GetRoleInput{ @@ -810,22 +950,31 @@ func getProxyAddrFromConfig(cfg *servicecfg.Config, flags configurators.Bootstra return "", trace.NotFound("proxy address not found, please provide --proxy, or set either teleport.proxy_server or proxy_service.public_addr in the teleport config") } -func buildSSMDocumentCreators(ssm map[string]ssmClient, targetCfg targetConfig, proxyAddr string) []configurators.ConfiguratorAction { +func buildSSMDocumentCreators(ctx context.Context, config ConfiguratorConfig, targetCfg targetConfig, proxyAddr string) ([]configurators.ConfiguratorAction, error) { var creators []configurators.ConfiguratorAction for _, matcher := range targetCfg.awsMatchers { if !slices.Contains(matcher.Types, types.AWSMatcherEC2) { continue } for _, region := range matcher.Regions { + var ssmClient ssmClient + if !config.Flags.Manual { + var err error + ssmClient, err = config.getSSMClient(ctx, region, targetCfg.assumeRole.RoleARN, targetCfg.assumeRole.ExternalID) + if err != nil { + return nil, trace.Wrap(err) + } + } ssmCreator := awsSSMDocumentCreator{ - ssm: ssm[region], - Name: matcher.SSM.DocumentName, - Contents: awslib.EC2DiscoverySSMDocument(proxyAddr), + ssm: ssmClient, + Name: matcher.SSM.DocumentName, + Contents: awslib.EC2DiscoverySSMDocument(proxyAddr), + accountID: targetCfg.identity.GetAccountID(), } creators = append(creators, &ssmCreator) } } - return creators + return creators, nil } func isEC2AutoDiscoveryEnabled(flags configurators.BootstrapFlags, matchers []types.AWSMatcher) bool { @@ -1104,14 +1253,15 @@ func buildARN(target awslib.Identity, service, resource string) string { } type awsSSMDocumentCreator struct { - Contents string - ssm ssmClient - Name string + Contents string + ssm ssmClient + Name string + accountID string } // Description returns what the action will perform. func (a *awsSSMDocumentCreator) Description() string { - return fmt.Sprintf("Create SSM Document %q", a.Name) + return fmt.Sprintf("[%s] Create SSM Document %q", a.accountID, a.Name) } // Details returns the policy document that will be created. @@ -1121,6 +1271,9 @@ func (a *awsSSMDocumentCreator) Details() string { // Execute upserts the policy and store its ARN in the action context. func (a *awsSSMDocumentCreator) Execute(ctx context.Context, actionCtx *configurators.ConfiguratorActionContext) error { + if a.ssm == nil { + return trace.BadParameter("ssm client not initialized") + } _, err := a.ssm.CreateDocument(ctx, &ssm.CreateDocumentInput{ Content: aws.String(a.Contents), Name: aws.String(a.Name), @@ -1146,6 +1299,8 @@ func (a *awsSSMDocumentCreator) Execute(ctx context.Context, actionCtx *configur type targetConfig struct { // identity is the target identity. identity awslib.Identity + // assumeRole is the role that should be assumed while configuring the target, if any. + assumeRole types.AssumeRole // awsMatchers are the AWS matchers associated with the target identity. awsMatchers []types.AWSMatcher // databases are the databases associated with the target identity. @@ -1157,7 +1312,7 @@ type targetConfig struct { // getTargetConfig gets the resources that are relevant to the target identity // from cli flags and file configuration. -func getTargetConfig(flags configurators.BootstrapFlags, cfg *servicecfg.Config, target awslib.Identity) (targetConfig, error) { +func getTargetConfig(flags configurators.BootstrapFlags, cfg *servicecfg.Config, target awslib.Identity, assumeRole types.AssumeRole) (targetConfig, error) { forcedRoles, err := parseForcedAWSRoles(flags, target) if err != nil { return targetConfig{}, trace.Wrap(err) @@ -1173,6 +1328,7 @@ func getTargetConfig(flags configurators.BootstrapFlags, cfg *servicecfg.Config, } return targetConfig{ identity: target, + assumeRole: assumeRole, awsMatchers: matchersForTarget(awsMatchers, target, targetIsAssumeRole), databases: databasesForTarget(databases, target, targetIsAssumeRole), assumesAWSRoles: targetAssumesRoles, diff --git a/lib/configurators/aws/aws_test.go b/lib/configurators/aws/aws_test.go index fece3446838..6e54bfc3e5f 100644 --- a/lib/configurators/aws/aws_test.go +++ b/lib/configurators/aws/aws_test.go @@ -34,6 +34,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/gravitational/trace" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/gravitational/teleport/api/types" @@ -52,7 +53,67 @@ var sortStringsTrans = cmp.Transformer("SortStrings", func(in []string) []string return out }) +func TestGetIdentity(t *testing.T) { + t.Parallel() + tests := []struct { + name string + config ConfiguratorConfig + roleARN string + externalID string + assert assert.ErrorAssertionFunc + expectIdentity awslib.Identity + }{ + { + name: "identity from assume role", + config: ConfiguratorConfig{ + Flags: configurators.BootstrapFlags{ + Manual: true, + }, + identity: identityFromArn(t, "arn:aws:iam::123456789012:role/not-this-one"), + }, + roleARN: "arn:aws:iam::123456789012:role/example-role", + externalID: "foobar", + assert: assert.NoError, + expectIdentity: identityFromArn(t, "arn:aws:iam::123456789012:role/example-role"), + }, + { + name: "placeholder identity in manual mode", + config: ConfiguratorConfig{ + Flags: configurators.BootstrapFlags{ + Manual: true, + }, + identity: identityFromArn(t, "arn:aws:iam::123456789012:role/not-this-one"), + }, + assert: assert.NoError, + expectIdentity: identityFromArn(t, buildIAMARN(targetIdentityARNSectionPlaceholder, targetIdentityARNSectionPlaceholder, "user", defaultAttachUser)), + }, + { + name: "cached identity", + config: ConfiguratorConfig{ + identity: identityFromArn(t, "arn:aws:iam::123456789012:role/example-role"), + }, + assert: assert.NoError, + expectIdentity: identityFromArn(t, "arn:aws:iam::123456789012:role/example-role"), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + identity, err := tc.config.getIdentity(t.Context(), tc.roleARN, tc.externalID) + tc.assert(t, err) + if tc.expectIdentity == nil { + assert.Nil(t, identity) + return + } + if assert.NotNil(t, identity) { + assert.Equal(t, tc.expectIdentity.String(), identity.String()) + assert.Equal(t, tc.expectIdentity.GetType(), identity.GetType()) + } + }) + } +} + func TestAWSIAMDocuments(t *testing.T) { + t.Parallel() userTarget, err := awslib.IdentityFromArn("arn:aws:iam::123456789012:user/example-user") require.NoError(t, err) @@ -1085,7 +1146,7 @@ func mustGetTargetConfig(t *testing.T, flags configurators.BootstrapFlags, roleT require.NoError(t, fileConfig.CheckAndSetDefaults()) serviceCfg := servicecfg.MakeDefaultConfig() require.NoError(t, config.ApplyFileConfig(fileConfig, serviceCfg)) - targetCfg, err := getTargetConfig(flags, serviceCfg, roleTarget) + targetCfg, err := getTargetConfig(flags, serviceCfg, roleTarget, types.AssumeRole{}) require.NoError(t, err) return targetCfg } @@ -1099,7 +1160,8 @@ func mustBuildPolicyDocument(t *testing.T, flags configurators.BootstrapFlags, t } func TestAWSPolicyCreator(t *testing.T) { - ctx := context.Background() + t.Parallel() + ctx := t.Context() tests := map[string]struct { returnError bool @@ -1137,7 +1199,8 @@ func TestAWSPolicyCreator(t *testing.T) { } func TestAWSPoliciesAttacher(t *testing.T) { - ctx := context.Background() + t.Parallel() + ctx := t.Context() userTarget, err := awslib.IdentityFromArn("arn:aws:iam::1234567:user/example-user") require.NoError(t, err) @@ -1196,33 +1259,56 @@ func TestAWSPoliciesAttacher(t *testing.T) { } } +func makeIAMClientGetter(expectedRoleARN, expectedExternalID string, clt iamClient) func(ctx context.Context, assumeRoleARN, externalID string) (iamClient, error) { + return func(ctx context.Context, assumeRoleARN, externalID string) (iamClient, error) { + if assumeRoleARN != expectedRoleARN || externalID != expectedExternalID { + return nil, trace.NotFound("no IAM client for assume role %q with external ID %q", expectedRoleARN, expectedExternalID) + } + return clt, nil + } +} + +func makePoliciesGetter(expectedRoleARN, expectedExternalID string, policies awslib.Policies) func(ctx context.Context, assumeRoleARN, externalID string) (awslib.Policies, error) { + return func(ctx context.Context, assumeRoleARN, externalID string) (awslib.Policies, error) { + if assumeRoleARN != expectedRoleARN || externalID != expectedExternalID { + return nil, trace.NotFound("no policies client for assume role %q with external ID %q", expectedRoleARN, expectedExternalID) + } + return policies, nil + } +} + +func makeSSMClientGetter(expectedRegion, expectedRoleARN, expectedExternalID string, ssm ssmClient) func(ctx context.Context, region, assumeRoleARN, externalID string) (ssmClient, error) { + return func(ctx context.Context, region, assumeRoleARN, externalID string) (ssmClient, error) { + if region != expectedRegion || assumeRoleARN != expectedRoleARN || externalID != expectedExternalID { + return nil, trace.NotFound("no IAM client for assume role %q with external ID %q", expectedRoleARN, expectedExternalID) + } + return ssm, nil + } +} + func TestAWSPoliciesTarget(t *testing.T) { - userIdentity, err := awslib.IdentityFromArn("arn:aws:iam::123456789012:user/example-user") - require.NoError(t, err) - - roleIdentity, err := awslib.IdentityFromArn("arn:aws:iam::123456789012:role/example-role") - require.NoError(t, err) - - assumedRoleIdentity, err := awslib.IdentityFromArn("arn:aws:sts::123456789012:assumed-role/example-role/i-12345") - require.NoError(t, err) + t.Parallel() + userIdentity := identityFromArn(t, "arn:aws:iam::123456789012:user/example-user") + roleIdentity := identityFromArn(t, "arn:aws:iam::123456789012:role/example-role") + assumedRoleIdentity := identityFromArn(t, "arn:aws:sts::123456789012:assumed-role/example-role/i-12345") + altAssumedRoleIdentity := identityFromArn(t, "arn:aws:sts::123456789012:assumed-role/alternate-role/i-12345") + defaultIdentity := identityFromArn(t, "arn:aws:iam::123456789012:user/me") tests := map[string]struct { - flags configurators.BootstrapFlags - identity awslib.Identity - accountID string - partitionID string + config ConfiguratorConfig + assumeRole types.AssumeRole targetType awslib.Identity targetName string targetAccountID string targetPartitionID string targetString string - iamClient iamClient wantErrContains string }{ "UserNameFromFlags": { - flags: configurators.BootstrapFlags{AttachToUser: "example-user"}, - accountID: "123456", - partitionID: "aws", + config: ConfiguratorConfig{ + Flags: configurators.BootstrapFlags{AttachToUser: "example-user"}, + identity: identityFromArn(t, buildIAMARN("aws", "123456", "user", defaultAttachUser)), + }, targetType: awslib.User{}, targetName: "example-user", targetAccountID: "123456", @@ -1230,9 +1316,10 @@ func TestAWSPoliciesTarget(t *testing.T) { targetString: "arn:aws:iam::123456:user/example-user", }, "UserNameWithPathFromFlags": { - flags: configurators.BootstrapFlags{AttachToUser: "/some/path/example-user"}, - accountID: "123456", - partitionID: "aws", + config: ConfiguratorConfig{ + Flags: configurators.BootstrapFlags{AttachToUser: "/some/path/example-user"}, + identity: identityFromArn(t, buildIAMARN("aws", "123456", "user", defaultAttachUser)), + }, targetType: awslib.User{}, targetName: "example-user", targetAccountID: "123456", @@ -1240,17 +1327,31 @@ func TestAWSPoliciesTarget(t *testing.T) { targetString: "arn:aws:iam::123456:user/some/path/example-user", }, "UserARNFromFlags": { - flags: configurators.BootstrapFlags{AttachToUser: "arn:aws:iam::123456789012:user/example-user"}, + config: ConfiguratorConfig{ + Flags: configurators.BootstrapFlags{AttachToUser: "arn:aws:iam::123456789012:user/example-user"}, + identity: defaultIdentity, + }, targetType: awslib.User{}, targetName: "example-user", targetAccountID: "123456789012", targetPartitionID: "aws", targetString: "arn:aws:iam::123456789012:user/example-user", }, + "UserARNFromFlagsWrongAccount": { + config: ConfiguratorConfig{ + Flags: configurators.BootstrapFlags{AttachToUser: "arn:aws:iam::987654321098:user/alt-user"}, + identity: defaultIdentity, + }, + wantErrContains: unreachablePolicyTargetError{ + target: identityFromArn(t, "arn:aws:iam::987654321098:user/alt-user"), + from: defaultIdentity, + }.Error(), + }, "RoleNameFromFlags": { - flags: configurators.BootstrapFlags{AttachToRole: "example-role"}, - accountID: "123456789012", - partitionID: "aws", + config: ConfiguratorConfig{ + Flags: configurators.BootstrapFlags{AttachToRole: "example-role"}, + identity: identityFromArn(t, buildIAMARN("aws", "123456789012", "user", defaultAttachUser)), + }, targetType: awslib.Role{}, targetName: "example-role", targetAccountID: "123456789012", @@ -1258,9 +1359,10 @@ func TestAWSPoliciesTarget(t *testing.T) { targetString: "arn:aws:iam::123456789012:role/example-role", }, "RoleNameWithPathFromFlags": { - flags: configurators.BootstrapFlags{AttachToRole: "/some/path/example-role"}, - accountID: "123456789012", - partitionID: "aws", + config: ConfiguratorConfig{ + Flags: configurators.BootstrapFlags{AttachToRole: "/some/path/example-role"}, + identity: identityFromArn(t, buildIAMARN("aws", "123456789012", "user", defaultAttachUser)), + }, targetType: awslib.Role{}, targetName: "example-role", targetAccountID: "123456789012", @@ -1268,16 +1370,31 @@ func TestAWSPoliciesTarget(t *testing.T) { targetString: "arn:aws:iam::123456789012:role/some/path/example-role", }, "RoleARNFromFlags": { - flags: configurators.BootstrapFlags{AttachToRole: "arn:aws:iam::123456789012:role/example-role"}, + config: ConfiguratorConfig{ + Flags: configurators.BootstrapFlags{AttachToRole: "arn:aws:iam::123456789012:role/example-role"}, + identity: defaultIdentity, + }, targetType: awslib.Role{}, targetName: "example-role", targetAccountID: "123456789012", targetPartitionID: "aws", targetString: "arn:aws:iam::123456789012:role/example-role", }, + "RoleARNFromFlagsWrongAccount": { + config: ConfiguratorConfig{ + Flags: configurators.BootstrapFlags{AttachToRole: "arn:aws:iam::987654321098:role/alt-role"}, + identity: defaultIdentity, + }, + wantErrContains: unreachablePolicyTargetError{ + target: identityFromArn(t, "arn:aws:iam::987654321098:role/alt-role"), + from: defaultIdentity, + }.Error(), + }, "UserFromIdentity": { - flags: configurators.BootstrapFlags{}, - identity: userIdentity, + config: ConfiguratorConfig{ + + identity: userIdentity, + }, targetType: awslib.User{}, targetName: userIdentity.GetName(), targetAccountID: userIdentity.GetAccountID(), @@ -1285,8 +1402,9 @@ func TestAWSPoliciesTarget(t *testing.T) { targetString: "arn:aws:iam::123456789012:user/example-user", }, "RoleFromIdentity": { - flags: configurators.BootstrapFlags{}, - identity: roleIdentity, + config: ConfiguratorConfig{ + identity: roleIdentity, + }, targetType: awslib.Role{}, targetName: roleIdentity.GetName(), targetAccountID: roleIdentity.GetAccountID(), @@ -1294,9 +1412,6 @@ func TestAWSPoliciesTarget(t *testing.T) { targetString: "arn:aws:iam::123456789012:role/example-role", }, "DefaultTarget": { - flags: configurators.BootstrapFlags{}, - accountID: "*", - partitionID: "*", targetType: awslib.User{}, targetName: defaultAttachUser, targetAccountID: "*", @@ -1304,51 +1419,157 @@ func TestAWSPoliciesTarget(t *testing.T) { targetString: "arn:*:iam::*:user/username", }, "AssumedRoleIdentity": { - flags: configurators.BootstrapFlags{}, - identity: assumedRoleIdentity, + config: ConfiguratorConfig{ + identity: assumedRoleIdentity, + getIAMClient: makeIAMClientGetter("", "", &iamMock{ + partition: "aws", + account: "123456789012", + }), + }, targetType: awslib.Role{}, targetName: assumedRoleIdentity.GetName(), targetAccountID: assumedRoleIdentity.GetAccountID(), targetPartitionID: assumedRoleIdentity.GetPartition(), targetString: "arn:aws:iam::123456789012:role/example-role", - iamClient: &iamMock{partition: "aws", account: "123456789012"}, }, "AssumedRoleIdentityForRoleWithPath": { - flags: configurators.BootstrapFlags{}, - identity: assumedRoleIdentity, + config: ConfiguratorConfig{ + identity: assumedRoleIdentity, + getIAMClient: makeIAMClientGetter("", "", &iamMock{ + partition: "aws", + account: "123456789012", + addPath: "/some/path/", + }), + }, targetType: awslib.Role{}, targetName: assumedRoleIdentity.GetName(), targetAccountID: assumedRoleIdentity.GetAccountID(), targetPartitionID: assumedRoleIdentity.GetPartition(), targetString: "arn:aws:iam::123456789012:role/some/path/example-role", - iamClient: &iamMock{partition: "aws", account: "123456789012", addPath: "/some/path/"}, }, "AssumedRoleIdentityWithoutIAMPermissions": { - flags: configurators.BootstrapFlags{}, - identity: assumedRoleIdentity, - targetType: awslib.Role{}, - targetName: assumedRoleIdentity.GetName(), - targetAccountID: assumedRoleIdentity.GetAccountID(), - targetPartitionID: assumedRoleIdentity.GetPartition(), - targetString: "arn:aws:iam::123456789012:role/example-role", - iamClient: &iamMock{unauthorized: true}, - wantErrContains: "policies cannot be attached to an assumed-role", + config: ConfiguratorConfig{ + identity: assumedRoleIdentity, + getIAMClient: makeIAMClientGetter("", "", &iamMock{unauthorized: true}), + }, + wantErrContains: "policies cannot be attached to an assumed-role", }, "AssumedRoleIdentityWithRoleFromFlags": { - flags: configurators.BootstrapFlags{AttachToRole: "arn:aws:iam::123456789012:role/some/path/example-role"}, - identity: assumedRoleIdentity, + config: ConfiguratorConfig{ + Flags: configurators.BootstrapFlags{AttachToRole: "arn:aws:iam::123456789012:role/some/path/example-role"}, + identity: assumedRoleIdentity, + getIAMClient: makeIAMClientGetter("", "", &iamMock{unauthorized: true}), + }, targetType: awslib.Role{}, targetName: "example-role", targetAccountID: "123456789012", targetPartitionID: "aws", targetString: "arn:aws:iam::123456789012:role/some/path/example-role", - iamClient: &iamMock{unauthorized: true}, + }, + "MatcherAssumeRole": { + config: ConfiguratorConfig{ + getIAMClient: makeIAMClientGetter(assumedRoleIdentity.String(), "", &iamMock{ + account: assumedRoleIdentity.GetAccountID(), + partition: assumedRoleIdentity.GetPartition(), + addPath: "/some/path/", + }), + identity: defaultIdentity, + }, + assumeRole: types.AssumeRole{RoleARN: assumedRoleIdentity.String()}, + targetType: awslib.Role{}, + targetName: roleIdentity.GetName(), + targetAccountID: roleIdentity.GetAccountID(), + targetPartitionID: roleIdentity.GetPartition(), + targetString: "arn:aws:iam::123456789012:role/some/path/example-role", + }, + "MatcherAssumeRoleWithUserFlag": { + config: ConfiguratorConfig{ + Flags: configurators.BootstrapFlags{AttachToUser: userIdentity.String()}, + identity: defaultIdentity, + }, + assumeRole: types.AssumeRole{RoleARN: assumedRoleIdentity.String()}, + targetType: awslib.User{}, + targetName: userIdentity.GetName(), + targetAccountID: userIdentity.GetAccountID(), + targetPartitionID: userIdentity.GetPartition(), + targetString: userIdentity.String(), + }, + "MatcherAssumeRoleWithUserFlagPath": { + config: ConfiguratorConfig{ + Flags: configurators.BootstrapFlags{AttachToUser: "/some/path/example-user"}, + identity: defaultIdentity, + }, + assumeRole: types.AssumeRole{RoleARN: assumedRoleIdentity.String()}, + targetType: awslib.User{}, + targetName: userIdentity.GetName(), + targetAccountID: userIdentity.GetAccountID(), + targetPartitionID: userIdentity.GetPartition(), + targetString: "arn:aws:iam::123456789012:user/some/path/example-user", + }, + "MatcherAssumeRoleWithUserFlagWrongAccount": { + config: ConfiguratorConfig{ + Flags: configurators.BootstrapFlags{AttachToUser: "arn:aws:iam::5678567856782:user/example-user"}, + identity: defaultIdentity, + }, + assumeRole: types.AssumeRole{RoleARN: assumedRoleIdentity.String()}, + wantErrContains: unreachablePolicyTargetError{ + target: identityFromArn(t, "arn:aws:iam::5678567856782:user/example-user"), + from: assumedRoleIdentity, + }.Error(), + }, + "MatcherAssumeRoleWithRoleFlag": { + config: ConfiguratorConfig{ + Flags: configurators.BootstrapFlags{AttachToRole: roleIdentity.String()}, + identity: defaultIdentity, + }, + assumeRole: types.AssumeRole{RoleARN: altAssumedRoleIdentity.String()}, + targetType: awslib.Role{}, + targetName: roleIdentity.GetName(), + targetAccountID: roleIdentity.GetAccountID(), + targetPartitionID: roleIdentity.GetPartition(), + targetString: roleIdentity.String(), + }, + "MatcherAssumeRoleWithRoleFlagPath": { + config: ConfiguratorConfig{ + Flags: configurators.BootstrapFlags{AttachToRole: "/some/path/example-role"}, + identity: defaultIdentity, + }, + assumeRole: types.AssumeRole{RoleARN: altAssumedRoleIdentity.String()}, + targetType: awslib.Role{}, + targetName: roleIdentity.GetName(), + targetAccountID: roleIdentity.GetAccountID(), + targetPartitionID: roleIdentity.GetPartition(), + targetString: "arn:aws:iam::123456789012:role/some/path/example-role", + }, + "MatcherAssumeRoleWithRoleFlagWrongAccount": { + config: ConfiguratorConfig{ + Flags: configurators.BootstrapFlags{AttachToRole: "arn:aws:iam::567856785678:role/example-role"}, + identity: defaultIdentity, + }, + assumeRole: types.AssumeRole{RoleARN: altAssumedRoleIdentity.String()}, + wantErrContains: unreachablePolicyTargetError{ + target: identityFromArn(t, "arn:aws:iam::567856785678:role/example-role"), + from: altAssumedRoleIdentity, + }.Error(), + }, + "MatcherAssumeRoleWithoutIAMPermission": { + config: ConfiguratorConfig{ + getIAMClient: makeIAMClientGetter(assumedRoleIdentity.String(), "", &iamMock{unauthorized: true}), + identity: defaultIdentity, + }, + assumeRole: types.AssumeRole{RoleARN: assumedRoleIdentity.String()}, + wantErrContains: failedToResolveAssumeRoleARN(assumedRoleIdentity.GetName()), }, } for name, test := range tests { t.Run(name, func(t *testing.T) { - target, err := policiesTarget(test.flags, test.accountID, test.partitionID, test.identity, test.iamClient) + test.config.ServiceConfig = &servicecfg.Config{} + require.NoError(t, test.config.CheckAndSetDefaults()) + if test.config.identity == nil { + test.config.identity = identityFromArn(t, buildIAMARN(targetIdentityARNSectionPlaceholder, targetIdentityARNSectionPlaceholder, "user", defaultAttachUser)) + } + target, err := policiesTarget(t.Context(), test.config, test.assumeRole) if test.wantErrContains != "" { require.ErrorContains(t, err, test.wantErrContains) return @@ -1371,13 +1592,17 @@ func identityFromArn(t *testing.T, arn string) awslib.Identity { } func TestAWSDocumentConfigurator(t *testing.T) { + t.Parallel() var err error - ctx := context.Background() + ctx := t.Context() fileConfig := &config.FileConfig{ Proxy: config.Proxy{ PublicAddr: []string{"proxy.example.org:443"}, }, Discovery: config.Discovery{ + Service: config.Service{ + EnabledFlag: "yes", + }, AWSMatchers: []config.AWSMatcher{ { Types: []string{"ec2"}, @@ -1392,29 +1617,26 @@ func TestAWSDocumentConfigurator(t *testing.T) { require.NoError(t, config.ApplyFileConfig(fileConfig, serviceConfig)) config := ConfiguratorConfig{ - awsCfg: &aws.Config{}, - iamClient: &iamMock{}, - Identity: identityFromArn(t, "arn:aws:iam::1234567:role/example-role"), - ssmClients: map[string]ssmClient{ - "eu-central-1": &ssmMock{ - t: t, - expectedInput: &ssm.CreateDocumentInput{ - Content: aws.String(awslib.EC2DiscoverySSMDocument("https://proxy.example.org:443")), - DocumentType: ssmtypes.DocumentTypeCommand, - DocumentFormat: ssmtypes.DocumentFormatYaml, - Name: aws.String("document"), - }}, - }, + getIAMClient: makeIAMClientGetter("", "", &iamMock{}), + identity: identityFromArn(t, "arn:aws:iam::1234567:role/example-role"), + getSSMClient: makeSSMClientGetter("eu-central-1", "", "", &ssmMock{ + t: t, + expectedInput: &ssm.CreateDocumentInput{ + Content: aws.String(awslib.EC2DiscoverySSMDocument("https://proxy.example.org:443")), + DocumentType: ssmtypes.DocumentTypeCommand, + DocumentFormat: ssmtypes.DocumentFormatYaml, + Name: aws.String("document"), + }}), ServiceConfig: serviceConfig, Flags: configurators.BootstrapFlags{ Service: configurators.DiscoveryService, ForceEC2Permissions: true, }, - Policies: &policiesMock{ + getPolicies: makePoliciesGetter("", "", &policiesMock{ upsertArn: "policies-arn", - }, + }), } - configurator, err := NewAWSConfigurator(config) + configurator, err := NewAWSConfigurator(t.Context(), config) require.NoError(t, err) require.False(t, configurator.IsEmpty()) @@ -1429,25 +1651,25 @@ func TestAWSDocumentConfigurator(t *testing.T) { // TestAWSConfigurator tests all actions together. func TestAWSConfigurator(t *testing.T) { + t.Parallel() var err error - ctx := context.Background() + ctx := t.Context() config := ConfiguratorConfig{ - awsCfg: &aws.Config{}, - iamClient: &iamMock{}, - Identity: identityFromArn(t, "arn:aws:iam::1234567:role/example-role"), - ssmClients: map[string]ssmClient{"eu-central-1": &ssmMock{}}, + getIAMClient: makeIAMClientGetter("", "", &iamMock{}), + identity: identityFromArn(t, "arn:aws:iam::1234567:role/example-role"), + getSSMClient: makeSSMClientGetter("eu-central-1", "", "", &ssmMock{}), ServiceConfig: &servicecfg.Config{}, Flags: configurators.BootstrapFlags{ AttachToUser: "some-user", ForceRDSPermissions: true, }, - Policies: &policiesMock{ + getPolicies: makePoliciesGetter("", "", &policiesMock{ upsertArn: "policies-arn", - }, + }), } - configurator, err := NewAWSConfigurator(config) + configurator, err := NewAWSConfigurator(t.Context(), config) require.NoError(t, err) require.False(t, configurator.IsEmpty()) @@ -1462,7 +1684,7 @@ func TestAWSConfigurator(t *testing.T) { config.Flags.ForceEC2Permissions = true config.Flags.Proxy = "proxy.xyz" - configurator, err = NewAWSConfigurator(config) + configurator, err = NewAWSConfigurator(t.Context(), config) require.NoError(t, err) require.False(t, configurator.IsEmpty()) @@ -1751,7 +1973,7 @@ func TestExtractTargetConfig(t *testing.T) { } for name, tt := range tests { t.Run(name, func(t *testing.T) { - got, err := getTargetConfig(tt.flags, tt.cfg, tt.target) + got, err := getTargetConfig(tt.flags, tt.cfg, tt.target, types.AssumeRole{}) if tt.wantErr != "" { require.ErrorContains(t, err, tt.wantErr) return @@ -1888,7 +2110,7 @@ func (m *ssmMock) CreateDocument(ctx context.Context, input *ssm.CreateDocumentI cmp.Diff(m.expectedInput.Content, input.Content), "Document content diff (-want +got)") require.Empty(m.t, - cmp.Diff(m.expectedInput, input, cmpopts.IgnoreFields(ssm.CreateDocumentInput{}, "Content")), + cmp.Diff(m.expectedInput, input, cmpopts.IgnoreUnexported(ssm.CreateDocumentInput{})), "Document diff (-want +got)") return nil, nil @@ -1925,6 +2147,7 @@ func (m mockLocalRegionGetter) GetRegion(context.Context) (string, error) { } func Test_getFallbackRegion(t *testing.T) { + t.Parallel() tests := []struct { name string localRegionGetter localRegionGetter @@ -1948,8 +2171,83 @@ func Test_getFallbackRegion(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - region := getFallbackRegion(context.Background(), io.Discard, test.localRegionGetter) + region := getFallbackRegion(t.Context(), io.Discard, test.localRegionGetter) require.Equal(t, test.wantRegion, region) }) } } + +func TestGetDistinctAssumedRoles(t *testing.T) { + t.Parallel() + defaultAssumeRole := types.AssumeRole{ + RoleARN: "arn:aws:iam::123456789012:role/example-role", + ExternalID: "foobar", + } + tests := []struct { + name string + matchers []types.AWSMatcher + expectedAssumeRoles []types.AssumeRole + }{ + { + name: "empty", + expectedAssumeRoles: []types.AssumeRole{defaultAssumeRole}, + }, + { + name: "multiple", + matchers: []types.AWSMatcher{ + {}, + {AssumeRole: &types.AssumeRole{RoleARN: "12345678", ExternalID: "foo"}}, + {AssumeRole: &types.AssumeRole{RoleARN: "87654321"}}, + }, + expectedAssumeRoles: []types.AssumeRole{ + defaultAssumeRole, + {RoleARN: "12345678", ExternalID: "foo"}, + {RoleARN: "87654321"}, + }, + }, + { + name: "filter out duplicates", + matchers: []types.AWSMatcher{ + {}, + {AssumeRole: &types.AssumeRole{RoleARN: "12345678"}}, + {AssumeRole: &types.AssumeRole{RoleARN: "87654321", ExternalID: "foo"}}, + {AssumeRole: &types.AssumeRole{RoleARN: "12345678"}}, + {AssumeRole: &types.AssumeRole{RoleARN: "87654321", ExternalID: "foo"}}, + {}, + }, + expectedAssumeRoles: []types.AssumeRole{ + defaultAssumeRole, + {RoleARN: "12345678"}, + {RoleARN: "87654321", ExternalID: "foo"}, + }, + }, + { + name: "preserve duplicate arn when external id differs", + matchers: []types.AWSMatcher{ + {AssumeRole: &types.AssumeRole{RoleARN: "12345678", ExternalID: "foo"}}, + {AssumeRole: &types.AssumeRole{RoleARN: "12345678", ExternalID: "bar"}}, + }, + expectedAssumeRoles: []types.AssumeRole{ + {RoleARN: "12345678", ExternalID: "foo"}, + {RoleARN: "12345678", ExternalID: "bar"}, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + config := ConfiguratorConfig{ + Flags: configurators.BootstrapFlags{ + Service: configurators.DiscoveryService, + AssumeRoleARN: defaultAssumeRole.RoleARN, + ExternalID: defaultAssumeRole.ExternalID, + }, + ServiceConfig: &servicecfg.Config{ + Discovery: servicecfg.DiscoveryConfig{ + AWSMatchers: tc.matchers, + }, + }, + } + require.ElementsMatch(t, tc.expectedAssumeRoles, config.getDistinctAssumedRoles()) + }) + } +} diff --git a/lib/configurators/configuratorbuilder/builder.go b/lib/configurators/configuratorbuilder/builder.go index e3cdd4f412e..254d4e0b05c 100644 --- a/lib/configurators/configuratorbuilder/builder.go +++ b/lib/configurators/configuratorbuilder/builder.go @@ -19,6 +19,8 @@ package configuratorbuilder import ( + "context" + "github.com/gravitational/trace" "github.com/gravitational/teleport/lib/config" @@ -29,7 +31,7 @@ import ( // BuildConfigurators reads the configuration and returns a list of // configurators. Configurators that are "empty" are not returned. -func BuildConfigurators(flags configurators.BootstrapFlags) ([]configurators.Configurator, error) { +func BuildConfigurators(ctx context.Context, flags configurators.BootstrapFlags) ([]configurators.Configurator, error) { fileConfig, err := config.ReadFromFile(flags.ConfigPath) if err != nil { return nil, trace.Wrap(err) @@ -39,7 +41,7 @@ func BuildConfigurators(flags configurators.BootstrapFlags) ([]configurators.Con return nil, trace.Wrap(err) } - awsConfigurator, err := aws.NewAWSConfigurator(aws.ConfiguratorConfig{ + awsConfigurator, err := aws.NewAWSConfigurator(ctx, aws.ConfiguratorConfig{ Flags: flags, ServiceConfig: serviceCfg, }) diff --git a/tool/teleport/common/configurator.go b/tool/teleport/common/configurator.go index d342fe3c528..32493f03e39 100644 --- a/tool/teleport/common/configurator.go +++ b/tool/teleport/common/configurator.go @@ -138,11 +138,10 @@ func makeDatabaseServiceBootstrapFlagsWithDiscoveryServiceConfig(flags configure // onConfigureDiscoveryBootstrap subcommand that bootstraps configuration for // discovery agents. -func onConfigureDiscoveryBootstrap(flags configureDiscoveryBootstrapFlags) error { +func onConfigureDiscoveryBootstrap(ctx context.Context, flags configureDiscoveryBootstrapFlags) error { fmt.Printf("Reading configuration at %q...\n", flags.config.ConfigPath) - ctx := context.TODO() - configurators, err := configuratorbuilder.BuildConfigurators(flags.config) + configurators, err := configuratorbuilder.BuildConfigurators(ctx, flags.config) if err != nil { return trace.Wrap(err) } @@ -152,7 +151,7 @@ func onConfigureDiscoveryBootstrap(flags configureDiscoveryBootstrapFlags) error // service config. if flags.config.Service.IsDiscovery() && flags.databaseServiceRole != "" { config := makeDatabaseServiceBootstrapFlagsWithDiscoveryServiceConfig(flags) - dbConfigurators, err := configuratorbuilder.BuildConfigurators(config) + dbConfigurators, err := configuratorbuilder.BuildConfigurators(ctx, config) if err != nil { return trace.Wrap(err) } @@ -245,7 +244,7 @@ type configureDatabaseAWSPrintFlags struct { // buildAWSConfigurator builds the database configurator used on AWS-specific // commands. -func buildAWSConfigurator(manual bool, flags configureDatabaseAWSFlags) (configurators.Configurator, error) { +func buildAWSConfigurator(ctx context.Context, manual bool, flags configureDatabaseAWSFlags) (configurators.Configurator, error) { err := flags.CheckAndSetDefaults() if err != nil { return nil, trace.Wrap(err) @@ -284,7 +283,7 @@ func buildAWSConfigurator(manual bool, flags configureDatabaseAWSFlags) (configu } } - configurator, err := awsconfigurators.NewAWSConfigurator(awsconfigurators.ConfiguratorConfig{ + configurator, err := awsconfigurators.NewAWSConfigurator(ctx, awsconfigurators.ConfiguratorConfig{ Flags: configuratorFlags, ServiceConfig: &servicecfg.Config{}, }) @@ -297,8 +296,8 @@ func buildAWSConfigurator(manual bool, flags configureDatabaseAWSFlags) (configu // onConfigureDatabasesAWSPrint is a subcommand used to print AWS IAM access // Teleport requires to run databases discovery on AWS. -func onConfigureDatabasesAWSPrint(flags configureDatabaseAWSPrintFlags) error { - configurator, err := buildAWSConfigurator(true, flags.configureDatabaseAWSFlags) +func onConfigureDatabasesAWSPrint(ctx context.Context, flags configureDatabaseAWSPrintFlags) error { + configurator, err := buildAWSConfigurator(ctx, true, flags.configureDatabaseAWSFlags) if err != nil { return trace.Wrap(err) } @@ -335,9 +334,8 @@ type configureDatabaseAWSCreateFlags struct { // onConfigureDatabasesAWSCreates is a subcommand used to create AWS IAM access // for Teleport to run databases discovery on AWS. -func onConfigureDatabasesAWSCreate(flags configureDatabaseAWSCreateFlags) error { - ctx := context.TODO() - configurator, err := buildAWSConfigurator(false, flags.configureDatabaseAWSFlags) +func onConfigureDatabasesAWSCreate(ctx context.Context, flags configureDatabaseAWSCreateFlags) error { + configurator, err := buildAWSConfigurator(ctx, false, flags.configureDatabaseAWSFlags) if err != nil { return trace.Wrap(err) } diff --git a/tool/teleport/common/teleport.go b/tool/teleport/common/teleport.go index 40d9278fc7e..53d4140c989 100644 --- a/tool/teleport/common/teleport.go +++ b/tool/teleport/common/teleport.go @@ -737,19 +737,19 @@ Examples: case dbConfigureCreate.FullCommand(): err = onDumpDatabaseConfig(dbConfigCreateFlags) case dbConfigureAWSPrintIAM.FullCommand(): - err = onConfigureDatabasesAWSPrint(configureDatabaseAWSPrintFlags) + err = onConfigureDatabasesAWSPrint(ctx, configureDatabaseAWSPrintFlags) case dbConfigureAWSCreateIAM.FullCommand(): - err = onConfigureDatabasesAWSCreate(configureDatabaseAWSCreateFlags) + err = onConfigureDatabasesAWSCreate(ctx, configureDatabaseAWSCreateFlags) case dbConfigureBootstrap.FullCommand(): configureDiscoveryBootstrapFlags.config.Service = configurators.DatabaseService - err = onConfigureDiscoveryBootstrap(configureDiscoveryBootstrapFlags) + err = onConfigureDiscoveryBootstrap(ctx, configureDiscoveryBootstrapFlags) case systemdInstall.FullCommand(): err = onDumpSystemdUnitFile(systemdInstallFlags) case installAutoDiscoverNode.FullCommand(): err = onInstallAutoDiscoverNode(installAutoDiscoverNodeFlags) case discoveryBootstrapCmd.FullCommand(): configureDiscoveryBootstrapFlags.config.Service = configurators.DiscoveryService - err = onConfigureDiscoveryBootstrap(configureDiscoveryBootstrapFlags) + err = onConfigureDiscoveryBootstrap(ctx, configureDiscoveryBootstrapFlags) case joinOpenSSH.FullCommand(): err = onJoinOpenSSH(ccf, conf) case integrationConfDeployServiceCmd.FullCommand():