diff --git a/lib/integrations/awsra/profile_syncer.go b/lib/integrations/awsra/profile_syncer.go index 6908b96f0fb..449e7023cf5 100644 --- a/lib/integrations/awsra/profile_syncer.go +++ b/lib/integrations/awsra/profile_syncer.go @@ -518,6 +518,22 @@ func processProfile(ctx context.Context, req processProfileRequest) error { return nil } +// awsConsoleURLForARN returns the AWS management console URL appropriate for +// the partition and region encoded in the given ARN. For GovCloud, it returns +// a region-scoped URL (e.g. https://us-gov-west-1.console.amazonaws-us-gov.com) +// so that downstream code can derive the correct region-scoped federation +// endpoint required by GovCloud. +func awsConsoleURLForARN(parsedARN arn.ARN) string { + switch parsedARN.Partition { + case "aws-us-gov": + return constants.AWSUSGovConsoleURL + case "aws-cn": + return constants.AWSCNConsoleURL + default: + return constants.AWSConsoleURL + } +} + func convertProfile(params AWSRolesAnywhereProfileSyncerParams, profile *integrationv1.RolesAnywhereProfile, integrationName string, proxyPublicAddr string) (types.AppServer, error) { parsedProfileARN, err := arn.Parse(profile.Arn) if err != nil { @@ -560,7 +576,7 @@ func convertProfile(params AWSRolesAnywhereProfileSyncerParams, profile *integra Labels: labels, }, Spec: types.AppSpecV3{ - URI: constants.AWSConsoleURL, + URI: awsConsoleURLForARN(parsedProfileARN), Integration: integrationName, PublicAddr: appURL, AWS: &types.AppAWS{ diff --git a/lib/integrations/awsra/profile_syncer_test.go b/lib/integrations/awsra/profile_syncer_test.go index 4b7a7cb476a..95ff1cda32a 100644 --- a/lib/integrations/awsra/profile_syncer_test.go +++ b/lib/integrations/awsra/profile_syncer_test.go @@ -25,11 +25,13 @@ import ( "time" "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/aws/arn" "github.com/aws/aws-sdk-go-v2/service/rolesanywhere" ratypes "github.com/aws/aws-sdk-go-v2/service/rolesanywhere/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/gravitational/teleport/api/constants" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/auth/keystore" "github.com/gravitational/teleport/lib/backend/memory" @@ -366,6 +368,187 @@ func TestRunAWSRolesAnywherProfileSyncer(t *testing.T) { require.NotEmpty(t, lastSyncSummary.ErrorMessage) }) }) + + t.Run("app server console URL is partition-specific", func(t *testing.T) { + for _, tt := range []struct { + name string + integration string + appName string + syncProfile string + appProfile string + trustAnchor string + roleARN string + expectedURI string + expectedAppID string + }{ + { + name: "govcloud", + integration: "govcloud-integration", + appName: "GovProfile", + syncProfile: "arn:aws-us-gov:rolesanywhere:us-gov-west-1:123456789012:profile/sync-profile", + appProfile: "arn:aws-us-gov:rolesanywhere:us-gov-west-1:123456789012:profile/uuid-gov", + trustAnchor: "arn:aws-us-gov:rolesanywhere:us-gov-west-1:123456789012:trust-anchor/ExampleTrustAnchor", + roleARN: "arn:aws-us-gov:iam::123456789012:role/SyncRole", + expectedURI: constants.AWSUSGovConsoleURL, + expectedAppID: "GovProfile-govcloud-integration", + }, + { + name: "china", + integration: "china-integration", + appName: "ChinaProfile", + syncProfile: "arn:aws-cn:rolesanywhere:cn-north-1:123456789012:profile/sync-profile", + appProfile: "arn:aws-cn:rolesanywhere:cn-north-1:123456789012:profile/uuid-cn", + trustAnchor: "arn:aws-cn:rolesanywhere:cn-north-1:123456789012:trust-anchor/ExampleTrustAnchor", + roleARN: "arn:aws-cn:iam::123456789012:role/SyncRole", + expectedURI: constants.AWSCNConsoleURL, + expectedAppID: "ChinaProfile-china-integration", + }, + } { + t.Run(tt.name, func(t *testing.T) { + integration, err := types.NewIntegrationAWSRA(types.Metadata{Name: tt.integration}, &types.AWSRAIntegrationSpecV1{ + TrustAnchorARN: tt.trustAnchor, + ProfileSyncConfig: &types.AWSRolesAnywhereProfileSyncConfig{ + Enabled: true, + ProfileARN: tt.syncProfile, + ProfileAcceptsRoleSessionName: true, + RoleARN: tt.roleARN, + }, + }) + require.NoError(t, err) + + serverClient := &mockCache{ + integrations: map[string]types.Integration{ + integration.GetName(): integration, + }, + ca: newCertAuthority(t, types.AWSRACA, "cluster-name"), + } + + syncProfile := ratypes.ProfileDetail{ + Name: aws.String("SyncProfile"), + ProfileArn: aws.String(tt.syncProfile), + Enabled: aws.Bool(true), + AcceptRoleSessionName: aws.Bool(true), + } + + appProfile := ratypes.ProfileDetail{ + Name: aws.String(tt.appName), + ProfileArn: aws.String(tt.appProfile), + Enabled: aws.Bool(true), + AcceptRoleSessionName: aws.Bool(true), + } + + params := baseParams(serverClient) + params.rolesAnywhereClient = &mockRolesAnywhereClient{ + profiles: []ratypes.ProfileDetail{ + syncProfile, + appProfile, + }, + tags: map[string][]ratypes.Tag{}, + } + + synctest.Test(t, func(t *testing.T) { + go func() { + err := RunAWSRolesAnywhereProfileSyncerWhileLocked(t.Context(), params) + assert.NoError(t, err) + }() + + synctest.Wait() + + require.Len(t, serverClient.appServers, 1) + appServer := serverClient.appServers[0] + require.Equal(t, tt.expectedAppID, appServer.GetName()) + require.Equal(t, tt.expectedURI, appServer.GetApp().GetURI()) + require.Equal(t, tt.appProfile, appServer.GetApp().GetAWSRolesAnywhereProfileARN()) + + status := serverClient.integrations[integration.GetName()].GetStatus() + require.NotNil(t, status) + lastSyncSummary := status.AWSRolesAnywhere.LastProfileSync + require.Equal(t, types.IntegrationAWSRolesAnywhereProfileSyncStatusSuccess, lastSyncSummary.Status) + require.NotEmpty(t, lastSyncSummary.StartTime) + require.NotEmpty(t, lastSyncSummary.EndTime) + require.Equal(t, int32(1), lastSyncSummary.SyncedProfiles) + require.Empty(t, lastSyncSummary.ErrorMessage) + }) + }) + } + }) + + t.Run("invalid profile ARN reports error status", func(t *testing.T) { + serverClient := baseServerClient(t) + + invalidProfile := ratypes.ProfileDetail{ + Name: aws.String("InvalidProfile"), + ProfileArn: aws.String("not-an-arn"), + Enabled: aws.Bool(true), + AcceptRoleSessionName: aws.Bool(true), + } + + params := baseParams(serverClient) + params.rolesAnywhereClient = &mockRolesAnywhereClient{ + profiles: []ratypes.ProfileDetail{ + syncProfile, + invalidProfile, + }, + tags: map[string][]ratypes.Tag{}, + } + + synctest.Test(t, func(t *testing.T) { + go func() { + err := RunAWSRolesAnywhereProfileSyncerWhileLocked(t.Context(), params) + assert.NoError(t, err) + }() + + synctest.Wait() + + require.Empty(t, serverClient.appServers) + + status := serverClient.integrations[integrationWithProfileSync.GetName()].GetStatus() + require.NotNil(t, status) + lastSyncSummary := status.AWSRolesAnywhere.LastProfileSync + require.Equal(t, types.IntegrationAWSRolesAnywhereProfileSyncStatusError, lastSyncSummary.Status) + require.NotEmpty(t, lastSyncSummary.StartTime) + require.NotEmpty(t, lastSyncSummary.EndTime) + require.Equal(t, int32(0), lastSyncSummary.SyncedProfiles) + require.NotEmpty(t, lastSyncSummary.ErrorMessage) + }) + }) +} + +func TestAWSConsoleURLForARN(t *testing.T) { + tests := []struct { + name string + inputARN string + expectedURL string + }{ + { + name: "GovCloud us-gov-west-1", + inputARN: "arn:aws-us-gov:rolesanywhere:us-gov-west-1:123456789012:profile/uuid1", + expectedURL: constants.AWSUSGovConsoleURL, + }, + { + name: "GovCloud us-gov-east-1", + inputARN: "arn:aws-us-gov:rolesanywhere:us-gov-east-1:123456789012:profile/uuid1", + expectedURL: constants.AWSUSGovConsoleURL, + }, + { + name: "AWS China", + inputARN: "arn:aws-cn:rolesanywhere:cn-north-1:123456789012:profile/uuid1", + expectedURL: "https://console.amazonaws.cn", + }, + { + name: "AWS Standard", + inputARN: "arn:aws:rolesanywhere:eu-west-2:123456789012:profile/uuid1", + expectedURL: "https://console.aws.amazon.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parsed, err := arn.Parse(tt.inputARN) + require.NoError(t, err) + require.Equal(t, tt.expectedURL, awsConsoleURLForARN(parsed)) + }) + } } type mockRolesAnywhereClient struct {