From 8d473178eaddd8357e720df7be65d1b43304624c Mon Sep 17 00:00:00 2001 From: Alex Vulaj Date: Thu, 26 Oct 2023 14:35:33 -0400 Subject: [PATCH] Move all functionality for assume command into credentials/console --- cmd/ocm-backplane/cloud/assume.go | 188 --------------- cmd/ocm-backplane/cloud/cloud.go | 2 - cmd/ocm-backplane/cloud/common.go | 129 ++++++++++ .../cloud/{assume_test.go => common_test.go} | 222 ++++++++++++++---- cmd/ocm-backplane/cloud/console.go | 40 +++- cmd/ocm-backplane/cloud/console_test.go | 2 + cmd/ocm-backplane/cloud/credentials.go | 167 ++++--------- cmd/ocm-backplane/cloud/credentials_test.go | 34 +-- cmd/ocm-backplane/cloud/token.go | 72 ------ go.mod | 2 +- go.sum | 4 +- pkg/awsutil/credentials.go | 45 ---- pkg/awsutil/credentials_test.go | 149 ------------ pkg/credentials/aws.go | 33 +++ pkg/credentials/credentials.go | 9 + pkg/credentials/gcp.go | 22 ++ pkg/utils/mocks/ocmWrapperMock.go | 15 ++ pkg/utils/ocmWrapper.go | 13 + 18 files changed, 502 insertions(+), 646 deletions(-) delete mode 100644 cmd/ocm-backplane/cloud/assume.go create mode 100644 cmd/ocm-backplane/cloud/common.go rename cmd/ocm-backplane/cloud/{assume_test.go => common_test.go} (73%) delete mode 100644 cmd/ocm-backplane/cloud/token.go delete mode 100644 pkg/awsutil/credentials.go delete mode 100644 pkg/awsutil/credentials_test.go create mode 100644 pkg/credentials/aws.go create mode 100644 pkg/credentials/credentials.go create mode 100644 pkg/credentials/gcp.go diff --git a/cmd/ocm-backplane/cloud/assume.go b/cmd/ocm-backplane/cloud/assume.go deleted file mode 100644 index d727c4f8..00000000 --- a/cmd/ocm-backplane/cloud/assume.go +++ /dev/null @@ -1,188 +0,0 @@ -package cloud - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "strings" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/credentials" - "github.com/aws/aws-sdk-go-v2/service/sts" - "github.com/openshift/backplane-cli/pkg/awsutil" - "github.com/openshift/backplane-cli/pkg/utils" - "github.com/spf13/cobra" -) - -var assumeArgs struct { - output string - debugFile string - console bool -} - -var StsClientWithProxy = awsutil.StsClientWithProxy -var AssumeRoleWithJWT = awsutil.AssumeRoleWithJWT -var NewStaticCredentialsProvider = credentials.NewStaticCredentialsProvider -var AssumeRoleSequence = awsutil.AssumeRoleSequence - -var AssumeCmd = &cobra.Command{ - Use: "assume [CLUSTERID|EXTERNAL_ID|CLUSTER_NAME|CLUSTER_NAME_SEARCH]", - Short: "Performs the assume role chaining necessary to generate temporary access to the customer's AWS account", - Long: `Performs the assume role chaining necessary to generate temporary access to the customer's AWS account - -This command is the equivalent of running "aws sts assume-role-with-web-identity --role-arn [role-arn] --web-identity-token [ocm token] --role-session-name [email from OCM token]" -behind the scenes, where the ocm token used is the result of running "ocm token" and the role-arn is the value of "assume-initial-arn" from the backplane configuration. - -Then, the command makes a call to the backplane API to get the necessary jump roles for the cluster's account. It then calls the -equivalent of "aws sts assume-role --role-arn [role-arn] --role-session-name [email from OCM token]" repeatedly for each -role arn in the chain, using the previous role's credentials to assume the next role in the chain. - -By default this command will output sts credentials for the support in the given cluster account formatted as terminal envars. -If the "--console" flag is provided, it will output a link to the web console for the target cluster's account. -`, - Example: `With -o flag specified: -backplane cloud assume e3b2fdc5-d9a7-435e-8870-312689cfb29c -oenv - -With a debug file: -backplane cloud assume e3b2fdc5-d9a7-435e-8870-312689cfb29c --debug-file test_arns - -As console url: -backplane cloud assume e3b2fdc5-d9a7-435e-8870-312689cfb29c --console`, - Args: cobra.MaximumNArgs(1), - RunE: runAssume, -} - -func init() { - flags := AssumeCmd.Flags() - flags.StringVarP(&assumeArgs.output, "output", "o", "env", "Format the output of the console response. Valid values are `env`, `json`, and `yaml`.") - flags.StringVar(&assumeArgs.debugFile, "debug-file", "", "A file containing the list of ARNs to assume in order, not including the initial role ARN. Providing this flag will bypass calls to the backplane API to retrieve the assume role chain. The file should be a plain text file with each ARN on a new line.") - flags.BoolVar(&assumeArgs.console, "console", false, "Outputs a console url to access the targeted cluster instead of the STS credentials.") -} - -type assumeChainResponse struct { - AssumptionSequence []namedRoleArn `json:"assumptionSequence"` -} - -type namedRoleArn struct { - Name string `json:"name"` - Arn string `json:"arn"` -} - -func runAssume(_ *cobra.Command, args []string) error { - if len(args) == 0 && assumeArgs.debugFile == "" { - return fmt.Errorf("must provide either cluster ID as an argument, or --debug-file as a flag") - } - - ocmToken, err := utils.DefaultOCMInterface.GetOCMAccessToken() - if err != nil { - return fmt.Errorf("failed to retrieve OCM token: %w", err) - } - - email, err := utils.GetStringFieldFromJWT(*ocmToken, "email") - if err != nil { - return fmt.Errorf("unable to extract email from given token: %w", err) - } - - bpConfig, err := GetBackplaneConfiguration() - if err != nil { - return fmt.Errorf("error retrieving backplane configuration: %w", err) - } - - if bpConfig.AssumeInitialArn == "" { - return errors.New("backplane config is missing required `assume-initial-arn` property") - } - - initialClient, err := StsClientWithProxy(bpConfig.ProxyURL) - if err != nil { - return fmt.Errorf("failed to create sts client: %w", err) - } - - seedCredentials, err := AssumeRoleWithJWT(*ocmToken, bpConfig.AssumeInitialArn, initialClient) - if err != nil { - return fmt.Errorf("failed to assume role using JWT: %w", err) - } - - var roleAssumeSequence []string - if assumeArgs.debugFile == "" { - clusterID, _, err := utils.DefaultOCMInterface.GetTargetCluster(args[0]) - if err != nil { - return fmt.Errorf("failed to get target cluster: %w", err) - } - - backplaneClient, err := utils.DefaultClientUtils.MakeRawBackplaneAPIClientWithAccessToken(bpConfig.URL, *ocmToken) - if err != nil { - return fmt.Errorf("failed to create backplane client with access token: %w", err) - } - - response, err := backplaneClient.GetAssumeRoleSequence(context.TODO(), clusterID) - if err != nil { - return fmt.Errorf("failed to fetch arn sequence: %w", err) - } - if response.StatusCode != 200 { - return fmt.Errorf("failed to fetch arn sequence: %v", response.Status) - } - - bytes, err := io.ReadAll(response.Body) - if err != nil { - return fmt.Errorf("failed to read backplane API response body: %w", err) - } - - roleChainResponse := &assumeChainResponse{} - err = json.Unmarshal(bytes, roleChainResponse) - if err != nil { - return fmt.Errorf("failed to unmarshal response: %w", err) - } - - roleAssumeSequence = make([]string, 0, len(roleChainResponse.AssumptionSequence)) - for _, namedRoleArn := range roleChainResponse.AssumptionSequence { - roleAssumeSequence = append(roleAssumeSequence, namedRoleArn.Arn) - } - } else { - arnBytes, err := os.ReadFile(assumeArgs.debugFile) - if err != nil { - return fmt.Errorf("failed to read file %v: %w", assumeArgs.debugFile, err) - } - - roleAssumeSequence = append(roleAssumeSequence, strings.Split(string(arnBytes), "\n")...) - } - - seedClient := sts.NewFromConfig(aws.Config{ - Region: "us-east-1", - Credentials: NewStaticCredentialsProvider(seedCredentials.AccessKeyID, seedCredentials.SecretAccessKey, seedCredentials.SessionToken), - }) - - targetCredentials, err := AssumeRoleSequence(email, seedClient, roleAssumeSequence, bpConfig.ProxyURL, awsutil.DefaultSTSClientProviderFunc) - if err != nil { - return fmt.Errorf("failed to assume role sequence: %w", err) - } - - if assumeArgs.console { - resp, err := awsutil.GetSigninToken(targetCredentials) - if err != nil { - return fmt.Errorf("failed to get signin token from AWS: %w", err) - } - - signInFederationURL, err := awsutil.GetConsoleURL(resp.SigninToken) - if err != nil { - return fmt.Errorf("failed to generate console url: %w", err) - } - - fmt.Printf("The AWS Console URL is:\n%s\n", signInFederationURL.String()) - } else { - credsResponse := awsutil.AWSCredentialsResponse{ - AccessKeyID: targetCredentials.AccessKeyID, - SecretAccessKey: targetCredentials.SecretAccessKey, - SessionToken: targetCredentials.SessionToken, - Expiration: targetCredentials.Expires.String(), - } - formattedResult, err := credsResponse.RenderOutput(assumeArgs.output) - if err != nil { - return fmt.Errorf("failed to format output correctly: %w", err) - } - fmt.Println(formattedResult) - } - return nil -} diff --git a/cmd/ocm-backplane/cloud/cloud.go b/cmd/ocm-backplane/cloud/cloud.go index be14c0d4..b0e581b7 100644 --- a/cmd/ocm-backplane/cloud/cloud.go +++ b/cmd/ocm-backplane/cloud/cloud.go @@ -18,8 +18,6 @@ var CloudCmd = &cobra.Command{ func init() { CloudCmd.AddCommand(CredentialsCmd) CloudCmd.AddCommand(ConsoleCmd) - CloudCmd.AddCommand(TokenCmd) - CloudCmd.AddCommand(AssumeCmd) } func help(cmd *cobra.Command, _ []string) { diff --git a/cmd/ocm-backplane/cloud/common.go b/cmd/ocm-backplane/cloud/common.go new file mode 100644 index 00000000..8d694258 --- /dev/null +++ b/cmd/ocm-backplane/cloud/common.go @@ -0,0 +1,129 @@ +package cloud + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/aws/arn" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/sts" + v1 "github.com/openshift-online/ocm-sdk-go/clustersmgmt/v1" + "github.com/openshift/backplane-cli/pkg/awsutil" + "github.com/openshift/backplane-cli/pkg/utils" + "io" + "net/http" +) + +const OldFlowSupportRole = "role/RH-Technical-Support-Access" + +var StsClientWithProxy = awsutil.StsClientWithProxy +var AssumeRoleWithJWT = awsutil.AssumeRoleWithJWT +var NewStaticCredentialsProvider = credentials.NewStaticCredentialsProvider +var AssumeRoleSequence = awsutil.AssumeRoleSequence + +type assumeChainResponse struct { + AssumptionSequence []namedRoleArn `json:"assumptionSequence"` +} + +type namedRoleArn struct { + Name string `json:"name"` + Arn string `json:"arn"` +} + +func getIsolatedCredentials(clusterID string) (aws.Credentials, error) { + if clusterID == "" { + return aws.Credentials{}, errors.New("must provide non-empty cluster ID") + } + + ocmToken, err := utils.DefaultOCMInterface.GetOCMAccessToken() + if err != nil { + return aws.Credentials{}, fmt.Errorf("failed to retrieve OCM token: %w", err) + } + + email, err := utils.GetStringFieldFromJWT(*ocmToken, "email") + if err != nil { + return aws.Credentials{}, fmt.Errorf("unable to extract email from given token: %w", err) + } + + bpConfig, err := GetBackplaneConfiguration() + if err != nil { + return aws.Credentials{}, fmt.Errorf("error retrieving backplane configuration: %w", err) + } + + if bpConfig.AssumeInitialArn == "" { + return aws.Credentials{}, errors.New("backplane config is missing required `assume-initial-arn` property") + } + + initialClient, err := StsClientWithProxy(bpConfig.ProxyURL) + if err != nil { + return aws.Credentials{}, fmt.Errorf("failed to create sts client: %w", err) + } + + seedCredentials, err := AssumeRoleWithJWT(*ocmToken, bpConfig.AssumeInitialArn, initialClient) + if err != nil { + return aws.Credentials{}, fmt.Errorf("failed to assume role using JWT: %w", err) + } + + backplaneClient, err := utils.DefaultClientUtils.MakeRawBackplaneAPIClientWithAccessToken(bpConfig.URL, *ocmToken) + if err != nil { + return aws.Credentials{}, fmt.Errorf("failed to create backplane client with access token: %w", err) + } + + response, err := backplaneClient.GetAssumeRoleSequence(context.TODO(), clusterID) + if err != nil { + return aws.Credentials{}, fmt.Errorf("failed to fetch arn sequence: %w", err) + } + if response.StatusCode != http.StatusOK { + return aws.Credentials{}, fmt.Errorf("failed to fetch arn sequence: %v", response.Status) + } + + bytes, err := io.ReadAll(response.Body) + if err != nil { + return aws.Credentials{}, fmt.Errorf("failed to read backplane API response body: %w", err) + } + + roleChainResponse := &assumeChainResponse{} + err = json.Unmarshal(bytes, roleChainResponse) + if err != nil { + return aws.Credentials{}, fmt.Errorf("failed to unmarshal response: %w", err) + } + + roleAssumeSequence := make([]string, 0, len(roleChainResponse.AssumptionSequence)) + for _, namedRoleArn := range roleChainResponse.AssumptionSequence { + roleAssumeSequence = append(roleAssumeSequence, namedRoleArn.Arn) + } + + seedClient := sts.NewFromConfig(aws.Config{ + Region: "us-east-1", + Credentials: NewStaticCredentialsProvider(seedCredentials.AccessKeyID, seedCredentials.SecretAccessKey, seedCredentials.SessionToken), + }) + + targetCredentials, err := AssumeRoleSequence(email, seedClient, roleAssumeSequence, bpConfig.ProxyURL, awsutil.DefaultSTSClientProviderFunc) + if err != nil { + return aws.Credentials{}, fmt.Errorf("failed to assume role sequence: %w", err) + } + return targetCredentials, nil +} + +func isIsolatedBackplaneAccess(cluster *v1.Cluster) (bool, error) { + if clusterAws, ok := cluster.GetAWS(); ok { + if clusterAwsSts, ok := clusterAws.GetSTS(); ok { + if clusterAwsSts.Enabled() { + stsSupportJumpRole, err := utils.DefaultOCMInterface.GetStsSupportJumpRoleARN(cluster.ID()) + if err != nil { + return false, fmt.Errorf("failed to get sts support jump role ARN for cluster %v: %w", cluster.ID(), err) + } + supportRoleArn, err := arn.Parse(stsSupportJumpRole) + if err != nil { + return false, fmt.Errorf("failed to parse ARN for jump role %v: %w", stsSupportJumpRole, err) + } + if supportRoleArn.Resource != OldFlowSupportRole { + return true, nil + } + } + } + } + return false, nil +} diff --git a/cmd/ocm-backplane/cloud/assume_test.go b/cmd/ocm-backplane/cloud/common_test.go similarity index 73% rename from cmd/ocm-backplane/cloud/assume_test.go rename to cmd/ocm-backplane/cloud/common_test.go index 4dc6ef25..d694ad46 100644 --- a/cmd/ocm-backplane/cloud/assume_test.go +++ b/cmd/ocm-backplane/cloud/common_test.go @@ -11,6 +11,7 @@ import ( "github.com/golang/mock/gomock" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" + v1 "github.com/openshift-online/ocm-sdk-go/clustersmgmt/v1" "github.com/openshift/backplane-cli/pkg/awsutil" "github.com/openshift/backplane-cli/pkg/cli/config" "github.com/openshift/backplane-cli/pkg/client/mocks" @@ -23,8 +24,7 @@ import ( ) //nolint:gosec -var _ = Describe("Cloud assume command", func() { - +var _ = Describe("getIsolatedCredentials", func() { var ( mockCtrl *gomock.Controller mockOcmInterface *mocks2.MockOCMInterface @@ -36,7 +36,6 @@ var _ = Describe("Cloud assume command", func() { testAccessKeyID string testSecretAccessKey string testSessionToken string - testClusterName string testExpiration time.Time ) @@ -56,11 +55,14 @@ var _ = Describe("Cloud assume command", func() { testAccessKeyID = "test-access-key-id" testSecretAccessKey = "test-secret-access-key" testSessionToken = "test-session-token" - testClusterName = "test-cluster" testExpiration = time.UnixMilli(1691606228384) }) - Context("Execute cloud assume command", func() { + AfterEach(func() { + mockCtrl.Finish() + }) + + Context("Execute getIsolatedCredentials", func() { It("should return AWS STS credentials", func() { mockOcmInterface.EXPECT().GetOCMAccessToken().Return(&testOcmToken, nil).Times(1) GetBackplaneConfiguration = func() (bpConfig config.BackplaneConfiguration, err error) { @@ -83,7 +85,6 @@ var _ = Describe("Cloud assume command", func() { NewStaticCredentialsProvider = func(key, secret, session string) credentials.StaticCredentialsProvider { return credentials.StaticCredentialsProvider{} } - mockOcmInterface.EXPECT().GetTargetCluster(testClusterID).Return(testClusterID, testClusterName, nil).Times(1) mockClientUtil.EXPECT().MakeRawBackplaneAPIClientWithAccessToken("testUrl.com", testOcmToken).Return(mockClient, nil).Times(1) mockClient.EXPECT().GetAssumeRoleSequence(context.TODO(), testClusterID).Return(&http.Response{ StatusCode: 200, @@ -98,17 +99,25 @@ var _ = Describe("Cloud assume command", func() { }, nil } - err := runAssume(nil, []string{testClusterID}) + credentials, err := getIsolatedCredentials(testClusterID) Expect(err).To(BeNil()) + Expect(credentials).To(Equal(aws.Credentials{ + AccessKeyID: testAccessKeyID, + SecretAccessKey: testSecretAccessKey, + SessionToken: testSessionToken, + Source: "", + CanExpire: false, + Expires: testExpiration, + })) }) - It("should fail if no argument or debug file is provided", func() { - err := runAssume(nil, []string{}) - Expect(err).To(Equal(fmt.Errorf("must provide either cluster ID as an argument, or --debug-file as a flag"))) + It("should fail if no argument is provided", func() { + _, err := getIsolatedCredentials("") + Expect(err).To(Equal(fmt.Errorf("must provide non-empty cluster ID"))) }) It("should fail if cannot retrieve OCM token", func() { mockOcmInterface.EXPECT().GetOCMAccessToken().Return(nil, errors.New("foo")).Times(1) - err := runAssume(nil, []string{testClusterID}) + _, err := getIsolatedCredentials(testClusterID) Expect(err.Error()).To(Equal("failed to retrieve OCM token: foo")) }) It("should fail if cannot retrieve backplane configuration", func() { @@ -117,26 +126,23 @@ var _ = Describe("Cloud assume command", func() { return config.BackplaneConfiguration{}, errors.New("oops") } - err := runAssume(nil, []string{testClusterID}) + _, err := getIsolatedCredentials(testClusterID) Expect(err.Error()).To(Equal("error retrieving backplane configuration: oops")) }) - It("should fail if cannot create sts client with proxy", func() { + It("should fail if backplane configuration does not contain value for AssumeInitialArn", func() { mockOcmInterface.EXPECT().GetOCMAccessToken().Return(&testOcmToken, nil).Times(1) GetBackplaneConfiguration = func() (bpConfig config.BackplaneConfiguration, err error) { return config.BackplaneConfiguration{ URL: "testUrl.com", ProxyURL: "testProxyUrl.com", - AssumeInitialArn: "arn:aws:iam::123456789:role/ManagedOpenShift-Support-Role", + AssumeInitialArn: "", }, nil } - StsClientWithProxy = func(proxyURL string) (*sts.Client, error) { - return nil, errors.New(":(") - } - err := runAssume(nil, []string{testClusterID}) - Expect(err.Error()).To(Equal("failed to create sts client: :(")) + _, err := getIsolatedCredentials(testClusterID) + Expect(err.Error()).To(Equal("backplane config is missing required `assume-initial-arn` property")) }) - It("should fail if initial role cannot be assumed with JWT", func() { + It("should fail if cannot create sts client with proxy", func() { mockOcmInterface.EXPECT().GetOCMAccessToken().Return(&testOcmToken, nil).Times(1) GetBackplaneConfiguration = func() (bpConfig config.BackplaneConfiguration, err error) { return config.BackplaneConfiguration{ @@ -146,17 +152,13 @@ var _ = Describe("Cloud assume command", func() { }, nil } StsClientWithProxy = func(proxyURL string) (*sts.Client, error) { - return &sts.Client{}, nil - } - AssumeRoleWithJWT = func(jwt string, roleArn string, stsClient stscreds.AssumeRoleWithWebIdentityAPIClient) (aws.Credentials, error) { - return aws.Credentials{}, errors.New("failure") + return nil, errors.New(":(") } - err := runAssume(nil, []string{testClusterID}) - Expect(err.Error()).To(Equal("failed to assume role using JWT: failure")) + _, err := getIsolatedCredentials(testClusterID) + Expect(err.Error()).To(Equal("failed to create sts client: :(")) }) - It("should fail if email cannot be pulled off JWT", func() { - testOcmToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + It("should fail if initial role cannot be assumed with JWT", func() { mockOcmInterface.EXPECT().GetOCMAccessToken().Return(&testOcmToken, nil).Times(1) GetBackplaneConfiguration = func() (bpConfig config.BackplaneConfiguration, err error) { return config.BackplaneConfiguration{ @@ -169,17 +171,14 @@ var _ = Describe("Cloud assume command", func() { return &sts.Client{}, nil } AssumeRoleWithJWT = func(jwt string, roleArn string, stsClient stscreds.AssumeRoleWithWebIdentityAPIClient) (aws.Credentials, error) { - return aws.Credentials{ - AccessKeyID: testAccessKeyID, - SecretAccessKey: testSecretAccessKey, - SessionToken: testSessionToken, - }, nil + return aws.Credentials{}, errors.New("failure") } - err := runAssume(nil, []string{testClusterID}) - Expect(err.Error()).To(Equal("unable to extract email from given token: no field email on given token")) + _, err := getIsolatedCredentials(testClusterID) + Expect(err.Error()).To(Equal("failed to assume role using JWT: failure")) }) - It("should fail if cluster cannot be retrieved from OCM", func() { + It("should fail if email cannot be pulled off JWT", func() { + testOcmToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" mockOcmInterface.EXPECT().GetOCMAccessToken().Return(&testOcmToken, nil).Times(1) GetBackplaneConfiguration = func() (bpConfig config.BackplaneConfiguration, err error) { return config.BackplaneConfiguration{ @@ -198,10 +197,9 @@ var _ = Describe("Cloud assume command", func() { SessionToken: testSessionToken, }, nil } - mockOcmInterface.EXPECT().GetTargetCluster(testClusterID).Return("", "", errors.New("oh no")).Times(1) - err := runAssume(nil, []string{testClusterID}) - Expect(err.Error()).To(Equal("failed to get target cluster: oh no")) + _, err := getIsolatedCredentials(testClusterID) + Expect(err.Error()).To(Equal("unable to extract email from given token: no field email on given token")) }) It("should fail if error creating backplane api client", func() { mockOcmInterface.EXPECT().GetOCMAccessToken().Return(&testOcmToken, nil).Times(1) @@ -225,10 +223,9 @@ var _ = Describe("Cloud assume command", func() { NewStaticCredentialsProvider = func(key, secret, session string) credentials.StaticCredentialsProvider { return credentials.StaticCredentialsProvider{} } - mockOcmInterface.EXPECT().GetTargetCluster(testClusterID).Return(testClusterID, testClusterName, nil).Times(1) mockClientUtil.EXPECT().MakeRawBackplaneAPIClientWithAccessToken("testUrl.com", testOcmToken).Return(nil, errors.New("foo")).Times(1) - err := runAssume(nil, []string{testClusterID}) + _, err := getIsolatedCredentials(testClusterID) Expect(err.Error()).To(Equal("failed to create backplane client with access token: foo")) }) It("should fail if cannot retrieve role sequence", func() { @@ -253,11 +250,10 @@ var _ = Describe("Cloud assume command", func() { NewStaticCredentialsProvider = func(key, secret, session string) credentials.StaticCredentialsProvider { return credentials.StaticCredentialsProvider{} } - mockOcmInterface.EXPECT().GetTargetCluster(testClusterID).Return(testClusterID, testClusterName, nil).Times(1) mockClientUtil.EXPECT().MakeRawBackplaneAPIClientWithAccessToken("testUrl.com", testOcmToken).Return(mockClient, nil).Times(1) mockClient.EXPECT().GetAssumeRoleSequence(context.TODO(), testClusterID).Return(nil, errors.New("error")).Times(1) - err := runAssume(nil, []string{testClusterID}) + _, err := getIsolatedCredentials(testClusterID) Expect(err.Error()).To(Equal("failed to fetch arn sequence: error")) }) It("should fail if fetching assume role sequence doesn't return a 200 status code", func() { @@ -282,7 +278,6 @@ var _ = Describe("Cloud assume command", func() { NewStaticCredentialsProvider = func(key, secret, session string) credentials.StaticCredentialsProvider { return credentials.StaticCredentialsProvider{} } - mockOcmInterface.EXPECT().GetTargetCluster(testClusterID).Return(testClusterID, testClusterName, nil).Times(1) mockClientUtil.EXPECT().MakeRawBackplaneAPIClientWithAccessToken("testUrl.com", testOcmToken).Return(mockClient, nil).Times(1) mockClient.EXPECT().GetAssumeRoleSequence(context.TODO(), testClusterID).Return(&http.Response{ StatusCode: 401, @@ -290,7 +285,7 @@ var _ = Describe("Cloud assume command", func() { Body: io.NopCloser(strings.NewReader(`{"assumption_sequence":[{"name": "name_one", "arn": "arn_one"},{"name": "name_two", "arn": "arn_two"}]}`)), }, nil).Times(1) - err := runAssume(nil, []string{testClusterID}) + _, err := getIsolatedCredentials(testClusterID) Expect(err.Error()).To(Equal("failed to fetch arn sequence: Unauthorized")) }) It("should fail if it cannot unmarshal backplane API response", func() { @@ -315,14 +310,13 @@ var _ = Describe("Cloud assume command", func() { NewStaticCredentialsProvider = func(key, secret, session string) credentials.StaticCredentialsProvider { return credentials.StaticCredentialsProvider{} } - mockOcmInterface.EXPECT().GetTargetCluster(testClusterID).Return(testClusterID, testClusterName, nil).Times(1) mockClientUtil.EXPECT().MakeRawBackplaneAPIClientWithAccessToken("testUrl.com", testOcmToken).Return(mockClient, nil).Times(1) mockClient.EXPECT().GetAssumeRoleSequence(context.TODO(), testClusterID).Return(&http.Response{ StatusCode: 200, Body: io.NopCloser(strings.NewReader("")), }, nil).Times(1) - err := runAssume(nil, []string{testClusterID}) + _, err := getIsolatedCredentials(testClusterID) Expect(err.Error()).To(Equal("failed to unmarshal response: unexpected end of JSON input")) }) It("should fail if it cannot assume the role sequence", func() { @@ -347,7 +341,6 @@ var _ = Describe("Cloud assume command", func() { NewStaticCredentialsProvider = func(key, secret, session string) credentials.StaticCredentialsProvider { return credentials.StaticCredentialsProvider{} } - mockOcmInterface.EXPECT().GetTargetCluster(testClusterID).Return(testClusterID, testClusterName, nil).Times(1) mockClientUtil.EXPECT().MakeRawBackplaneAPIClientWithAccessToken("testUrl.com", testOcmToken).Return(mockClient, nil).Times(1) mockClient.EXPECT().GetAssumeRoleSequence(context.TODO(), testClusterID).Return(&http.Response{ StatusCode: 200, @@ -357,8 +350,139 @@ var _ = Describe("Cloud assume command", func() { return aws.Credentials{}, errors.New("oops") } - err := runAssume(nil, []string{testClusterID}) + _, err := getIsolatedCredentials(testClusterID) Expect(err.Error()).To(Equal("failed to assume role sequence: oops")) }) }) + +}) + +var _ = Describe("isIsolatedBackplaneAccess", func() { + var ( + mockCtrl *gomock.Controller + mockOcmInterface *mocks2.MockOCMInterface + + testClusterID string + ) + + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + + mockOcmInterface = mocks2.NewMockOCMInterface(mockCtrl) + utils.DefaultOCMInterface = mockOcmInterface + + testClusterID = "test123" + }) + + AfterEach(func() { + mockCtrl.Finish() + }) + + Context("Execute isIsolatedBackplaneAccess", func() { + It("returns false with no error if cluster has no AWS field", func() { + result, err := isIsolatedBackplaneAccess(&v1.Cluster{}) + + Expect(result).To(Equal(false)) + Expect(err).To(BeNil()) + }) + It("returns false with no error if cluster AWS field has no STS field", func() { + clusterBuilder := v1.ClusterBuilder{} + clusterBuilder.AWS(&v1.AWSBuilder{}) + cluster, _ := clusterBuilder.Build() + result, err := isIsolatedBackplaneAccess(cluster) + + Expect(result).To(Equal(false)) + Expect(err).To(BeNil()) + }) + It("returns false with no error if cluster is non-STS enabled", func() { + stsBuilder := &v1.STSBuilder{} + stsBuilder.Enabled(false) + + awsBuilder := &v1.AWSBuilder{} + awsBuilder.STS(stsBuilder) + + clusterBuilder := v1.ClusterBuilder{} + clusterBuilder.AWS(awsBuilder) + + cluster, _ := clusterBuilder.Build() + result, err := isIsolatedBackplaneAccess(cluster) + + Expect(result).To(Equal(false)) + Expect(err).To(BeNil()) + }) + It("returns an error if fails to get STS Support Jump Role from OCM for STS enabled cluster", func() { + mockOcmInterface.EXPECT().GetStsSupportJumpRole(testClusterID).Return("", errors.New("oops")) + + stsBuilder := &v1.STSBuilder{} + stsBuilder.Enabled(true) + + awsBuilder := &v1.AWSBuilder{} + awsBuilder.STS(stsBuilder) + + clusterBuilder := v1.ClusterBuilder{} + clusterBuilder.AWS(awsBuilder) + clusterBuilder.ID(testClusterID) + + cluster, _ := clusterBuilder.Build() + _, err := isIsolatedBackplaneAccess(cluster) + + Expect(err).NotTo(BeNil()) + }) + It("returns an error if fails to parse STS Support Jump Role from OCM for STS enabled cluster", func() { + mockOcmInterface.EXPECT().GetStsSupportJumpRole(testClusterID).Return("not-an-arn", nil) + + stsBuilder := &v1.STSBuilder{} + stsBuilder.Enabled(true) + + awsBuilder := &v1.AWSBuilder{} + awsBuilder.STS(stsBuilder) + + clusterBuilder := v1.ClusterBuilder{} + clusterBuilder.AWS(awsBuilder) + clusterBuilder.ID(testClusterID) + + cluster, _ := clusterBuilder.Build() + _, err := isIsolatedBackplaneAccess(cluster) + + Expect(err).NotTo(BeNil()) + }) + It("returns false with no error for STS enabled cluster with ARN that matches old support flow ARN", func() { + mockOcmInterface.EXPECT().GetStsSupportJumpRole(testClusterID).Return("arn:aws:iam::123456789:role/RH-Technical-Support-Access", nil) + + stsBuilder := &v1.STSBuilder{} + stsBuilder.Enabled(true) + + awsBuilder := &v1.AWSBuilder{} + awsBuilder.STS(stsBuilder) + + clusterBuilder := v1.ClusterBuilder{} + clusterBuilder.AWS(awsBuilder) + clusterBuilder.ID(testClusterID) + + cluster, _ := clusterBuilder.Build() + result, err := isIsolatedBackplaneAccess(cluster) + + Expect(result).To(Equal(false)) + Expect(err).To(BeNil()) + }) + It("returns true with no error for STS enabled cluster with ARN that doesn't match old support flow ARN", func() { + mockOcmInterface.EXPECT().GetStsSupportJumpRole(testClusterID).Return("arn:aws:iam::123456789:role/RH-Technical-Support-12345", nil) + + stsBuilder := &v1.STSBuilder{} + stsBuilder.Enabled(true) + + awsBuilder := &v1.AWSBuilder{} + awsBuilder.STS(stsBuilder) + + clusterBuilder := v1.ClusterBuilder{} + clusterBuilder.AWS(awsBuilder) + clusterBuilder.ID(testClusterID) + + cluster, _ := clusterBuilder.Build() + result, err := isIsolatedBackplaneAccess(cluster) + + Expect(result).To(Equal(true)) + Expect(err).To(BeNil()) + }) + }) }) diff --git a/cmd/ocm-backplane/cloud/console.go b/cmd/ocm-backplane/cloud/console.go index f84a6f76..74f558be 100644 --- a/cmd/ocm-backplane/cloud/console.go +++ b/cmd/ocm-backplane/cloud/console.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/openshift/backplane-cli/pkg/awsutil" "net/http" "os" "strconv" @@ -30,14 +31,14 @@ type ConsoleResponse struct { ConsoleLink string `json:"ConsoleLink" yaml:"ConsoleLink"` } -var consoleStrFmt string = `Console Link: +var consoleStrFmt = `Console Link: Link: %s` func (r *ConsoleResponse) String() string { return fmt.Sprintf(consoleStrFmt, r.ConsoleLink) } -// Environment variable that indicates if open by browser is set as default +// EnvBrowserDefault environment variable that indicates if open by browser is set as default const EnvBrowserDefault = "BACKPLANE_DEFAULT_OPEN_BROWSER" // ConsoleCmd represents the cloud credentials command @@ -109,6 +110,11 @@ func runConsole(cmd *cobra.Command, argv []string) (err error) { return err } + cluster, err := utils.DefaultOCMInterface.GetClusterInfoByID(clusterID) + if err != nil { + return fmt.Errorf("failed to get cluster info for %s: %w", clusterID, err) + } + logger.WithFields(logger.Fields{ "ID": clusterID, "Name": clusterName}).Infoln("Target cluster") @@ -132,13 +138,35 @@ func runConsole(cmd *cobra.Command, argv []string) (err error) { } // ======== Get cloud console from backplane API ============ - response, err := getCloudConsole(bpURL, clusterID) + + var consoleResponse *ConsoleResponse + isolatedBackplane, err := isIsolatedBackplaneAccess(cluster) if err != nil { - return err + return fmt.Errorf("failed to determine if cluster is using isolated backplane access: %w", err) + } + if isolatedBackplane { + targetCredentials, err := getIsolatedCredentials(clusterID) + if err != nil { + return fmt.Errorf("failed to get cloud credentials for cluster %v: %w", clusterID, err) + } + resp, err := awsutil.GetSigninToken(targetCredentials) + if err != nil { + return fmt.Errorf("failed to get signin token: %w", err) + } + + signinFederationURL, err := awsutil.GetConsoleURL(resp.SigninToken) + if err != nil { + return fmt.Errorf("failed to generate console url: %w", err) + } + consoleResponse = &ConsoleResponse{ConsoleLink: signinFederationURL.String()} + } else { + consoleResponse, err = getCloudConsole(bpURL, clusterID) + if err != nil { + return err + } } - // ====== Render cloud console response based on output format - err = renderCloudConsole(response) + err = renderCloudConsole(consoleResponse) if err != nil { return err } diff --git a/cmd/ocm-backplane/cloud/console_test.go b/cmd/ocm-backplane/cloud/console_test.go index 9c08c7ca..e409d8c4 100644 --- a/cmd/ocm-backplane/cloud/console_test.go +++ b/cmd/ocm-backplane/cloud/console_test.go @@ -94,6 +94,7 @@ var _ = Describe("Cloud console command", func() { Context("Execute cloud console command", func() { It("should return AWS cloud console", func() { mockOcmInterface.EXPECT().GetTargetCluster(testClusterID).Return(trueClusterID, testClusterID, nil) + mockOcmInterface.EXPECT().GetClusterInfoByID(trueClusterID).Return(nil, nil) mockClientUtil.EXPECT().GetBackplaneClient(proxyURI).Return(mockClient, nil).AnyTimes() mockClient.EXPECT().GetCloudConsole(gomock.Any(), trueClusterID).Return(fakeAWSResp, nil) @@ -104,6 +105,7 @@ var _ = Describe("Cloud console command", func() { It("should return GCP cloud console", func() { mockOcmInterface.EXPECT().GetTargetCluster(testClusterID).Return(trueClusterID, testClusterID, nil) + mockOcmInterface.EXPECT().GetClusterInfoByID(trueClusterID).Return(nil, nil) mockClientUtil.EXPECT().GetBackplaneClient(proxyURI).Return(mockClient, nil).AnyTimes() mockClient.EXPECT().GetCloudConsole(gomock.Any(), trueClusterID).Return(fakeGCloudResp, nil) err := runConsole(nil, []string{testClusterID}) diff --git a/cmd/ocm-backplane/cloud/credentials.go b/cmd/ocm-backplane/cloud/credentials.go index 36aee9ba..b49b7fb5 100644 --- a/cmd/ocm-backplane/cloud/credentials.go +++ b/cmd/ocm-backplane/cloud/credentials.go @@ -5,15 +5,13 @@ import ( "encoding/json" "errors" "fmt" + bpCredentials "github.com/openshift/backplane-cli/pkg/credentials" "net/http" logger "github.com/sirupsen/logrus" "github.com/spf13/cobra" "sigs.k8s.io/yaml" - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/credentials" BackplaneApi "github.com/openshift/backplane-api/pkg/client" "github.com/openshift/backplane-cli/pkg/utils" @@ -26,69 +24,6 @@ var credentialArgs struct { output string } -type CredentialsResponse interface { - // String returns a friendly message outlining how users can setup cloud environment access - String() string - - // fmtExport sets environment variables for users to export to setup cloud environment access - fmtExport() string -} - -type AWSCredentialsResponse struct { - AccessKeyID string `json:"AccessKeyID" yaml:"AccessKeyID"` - SecretAccessKey string `json:"SecretAccessKey" yaml:"SecretAccessKey"` - SessionToken string `json:"SessionToken" yaml:"SessionToken"` - Region string `json:"Region" yaml:"Region"` - Expiration string `json:"Expiration" yaml:"Expiration"` -} - -type GCPCredentialsResponse struct { - ProjectID string `json:"project_id" yaml:"project_id"` -} - -const ( - // format strings for printing AWS credentials as a string or as environment variables - awsCredentialsStringFormat = `Temporary Credentials: - AccessKeyID: %s - SecretAccessKey: %s - SessionToken: %s - Region: %s - Expires: %s` - awsExportFormat = `export AWS_ACCESS_KEY_ID=%s -export AWS_SECRET_ACCESS_KEY=%s -export AWS_SESSION_TOKEN=%s -export AWS_DEFAULT_REGION=%s` - - // format strings for printing GCP credentials as a string or as environment variables - gcpCredentialsStringFormat = `If this is your first time, run "gcloud auth login" and then -gcloud config set project %s` - gcpExportFormat = `export CLOUDSDK_CORE_PROJECT=%s` -) - -func (r *AWSCredentialsResponse) String() string { - return fmt.Sprintf(awsCredentialsStringFormat, r.AccessKeyID, r.SecretAccessKey, r.SessionToken, r.Region, r.Expiration) -} - -func (r *AWSCredentialsResponse) fmtExport() string { - return fmt.Sprintf(awsExportFormat, r.AccessKeyID, r.SecretAccessKey, r.SessionToken, r.Region) -} - -// AWSV2Config returns an aws-sdk-go-v2 config that can be used to programmatically access the AWS API -func (r *AWSCredentialsResponse) AWSV2Config() (aws.Config, error) { - return config.LoadDefaultConfig(context.TODO(), - config.WithRegion(r.Region), - config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(r.AccessKeyID, r.SecretAccessKey, r.SessionToken)), - ) -} - -func (r *GCPCredentialsResponse) String() string { - return fmt.Sprintf(gcpCredentialsStringFormat, r.ProjectID) -} - -func (r *GCPCredentialsResponse) fmtExport() string { - return fmt.Sprintf(gcpExportFormat, r.ProjectID) -} - // CredentialsCmd represents the cloud credentials command var CredentialsCmd = &cobra.Command{ Use: "credentials [CLUSTERID|EXTERNAL_ID|CLUSTER_NAME|CLUSTER_NAME_SEARCH]", @@ -175,65 +110,38 @@ func runCredentials(cmd *cobra.Command, argv []string) error { // ======== Call Endpoint ================================== logger.Debugln("Getting Cloud Credentials") - credsResp, err := getCloudCredential(bpURL, clusterID) + var output string + isolatedBackplane, err := isIsolatedBackplaneAccess(cluster) if err != nil { - return err + return fmt.Errorf("failed to determine if cluster is using isolated backplane access: %w", err) } - - // ======== Render cloud credentials ======================= - switch cloudProvider { - case "aws": - cliResp := &AWSCredentialsResponse{} - if err := json.Unmarshal([]byte(*credsResp.JSON200.Credentials), cliResp); err != nil { - return fmt.Errorf("unable to unmarshal AWS credentials response from backplane %s: %w", *credsResp.JSON200.Credentials, err) + if isolatedBackplane { + targetCredentials, err := getIsolatedCredentials(clusterID) + if err != nil { + return fmt.Errorf("failed to get cloud credentials for cluster %v: %w", clusterID, err) } - cliResp.Region = *credsResp.JSON200.Region - creds, err := renderCloudCredentials(credentialArgs.output, cliResp) + output, err = renderCloudCredentials(credentialArgs.output, &bpCredentials.AWSCredentialsResponse{ + AccessKeyID: targetCredentials.AccessKeyID, + SecretAccessKey: targetCredentials.SecretAccessKey, + SessionToken: targetCredentials.SessionToken, + Expiration: targetCredentials.Expires.String(), + }) if err != nil { - return err + return fmt.Errorf("failed to render credentials: %w", err) } - fmt.Println(creds) - return nil - case "gcp": - cliResp := &GCPCredentialsResponse{} - if err := json.Unmarshal([]byte(*credsResp.JSON200.Credentials), cliResp); err != nil { - return fmt.Errorf("unable to unmarshal GCP credentials response from backplane %s: %w", *credsResp.JSON200.Credentials, err) + } else { + credsResp, err := getCloudCredential(bpURL, clusterID) + if err != nil { + return fmt.Errorf("failed to get cloud credentials for cluster %v: %w", clusterID, err) } - creds, err := renderCloudCredentials(credentialArgs.output, cliResp) + output, err = renderCredentials(credsResp.JSON200.Credentials, cloudProvider) if err != nil { return err } - fmt.Println(creds) - return nil - default: - return fmt.Errorf("unsupported cloud provider: %s", cloudProvider) - } -} - -// GetAWSV2Config allows consumers to get an aws-sdk-go-v2 Config to programmatically access the AWS API -func GetAWSV2Config(backplaneURL string, clusterID string) (aws.Config, error) { - cluster, err := utils.DefaultOCMInterface.GetClusterInfoByID(clusterID) - if err != nil { - return aws.Config{}, err - } - - cloudProvider := utils.DefaultClusterUtils.GetCloudProvider(cluster) - if cloudProvider != "aws" { - return aws.Config{}, fmt.Errorf("only supported for the aws cloud provider, this cluster has: %s", cloudProvider) - } - - resp, err := getCloudCredential(backplaneURL, clusterID) - if err != nil { - return aws.Config{}, err } - credsResp := &AWSCredentialsResponse{} - if err := json.Unmarshal([]byte(*resp.JSON200.Credentials), credsResp); err != nil { - return aws.Config{}, fmt.Errorf("unable to unmarshal AWS credentials response from backplane %s: %w", *resp.JSON200.Credentials, err) - } - credsResp.Region = *resp.JSON200.Region - - return credsResp.AWSV2Config() + fmt.Println(output) + return nil } // getCloudCredential returns Cloud Credentials Response @@ -266,11 +174,38 @@ func getCloudCredential(backplaneURL string, clusterID string) (*BackplaneApi.Ge return credsResp, nil } +func renderCredentials(credentials *string, cloudProvider string) (string, error) { + switch cloudProvider { + case "aws": + cliResp := &bpCredentials.AWSCredentialsResponse{} + if err := json.Unmarshal([]byte(*credentials), cliResp); err != nil { + return "", fmt.Errorf("unable to unmarshal AWS credentials response from backplane %s: %w", *credentials, err) + } + creds, err := renderCloudCredentials(credentialArgs.output, cliResp) + if err != nil { + return "", err + } + return creds, nil + case "gcp": + cliResp := &bpCredentials.GCPCredentialsResponse{} + if err := json.Unmarshal([]byte(*credentials), cliResp); err != nil { + return "", fmt.Errorf("unable to unmarshal GCP credentials response from backplane %s: %w", *credentials, err) + } + creds, err := renderCloudCredentials(credentialArgs.output, cliResp) + if err != nil { + return "", err + } + return creds, nil + default: + return "", fmt.Errorf("unsupported cloud provider: %s", cloudProvider) + } +} + // renderCloudCredentials displays the results of `ocm backplane cloud credentials` for AWS clusters -func renderCloudCredentials(outputFormat string, creds CredentialsResponse) (string, error) { +func renderCloudCredentials(outputFormat string, creds bpCredentials.Response) (string, error) { switch outputFormat { case "env": - return creds.fmtExport(), nil + return creds.FmtExport(), nil case "yaml": yamlBytes, err := yaml.Marshal(creds) if err != nil { diff --git a/cmd/ocm-backplane/cloud/credentials_test.go b/cmd/ocm-backplane/cloud/credentials_test.go index 2769ace0..a2090c60 100644 --- a/cmd/ocm-backplane/cloud/credentials_test.go +++ b/cmd/ocm-backplane/cloud/credentials_test.go @@ -22,6 +22,7 @@ import ( "github.com/openshift/backplane-cli/pkg/cli/config" "github.com/openshift/backplane-cli/pkg/client/mocks" + bpCredentials "github.com/openshift/backplane-cli/pkg/credentials" "github.com/openshift/backplane-cli/pkg/info" "github.com/openshift/backplane-cli/pkg/utils" mocks2 "github.com/openshift/backplane-cli/pkg/utils/mocks" @@ -75,7 +76,7 @@ var _ = Describe("Cloud console command", func() { // Define fake AWS response fakeAWSResp = &http.Response{ Body: MakeIoReader( - fmt.Sprintf(`{"credentials":"proxy", "message":"msg", "JSON200":"%s"}`, credentialAWS), + fmt.Sprintf(`{"bpCredentials":"proxy", "message":"msg", "JSON200":"%s"}`, credentialAWS), ), Header: map[string][]string{}, StatusCode: http.StatusOK, @@ -103,7 +104,7 @@ var _ = Describe("Cloud console command", func() { // Define broken AWS response // https://stackoverflow.com/questions/32708717/go-when-will-json-unmarshal-to-struct-return-error - resp, _ := json.Marshal(map[string]string{"credentials": "foo", "clusterID": "bar"}) + resp, _ := json.Marshal(map[string]string{"bpCredentials": "foo", "clusterID": "bar"}) fakeBrokenAWSResp = &http.Response{ Body: io.NopCloser(bytes.NewReader(resp)), Header: map[string][]string{}, @@ -114,7 +115,7 @@ var _ = Describe("Cloud console command", func() { // Define broken gcp response // https://stackoverflow.com/questions/32708717/go-when-will-json-unmarshal-to-struct-return-error // Define fake AWS response - resp, _ = json.Marshal(map[string]string{"credentials": "foo", "clusterID": "bar"}) + resp, _ = json.Marshal(map[string]string{"bpCredentials": "foo", "clusterID": "bar"}) fakeBrokenGCPResp = &http.Response{ Body: io.NopCloser(bytes.NewReader(resp)), Header: map[string][]string{}, @@ -122,7 +123,7 @@ var _ = Describe("Cloud console command", func() { } fakeBrokenGCPResp.Header.Add("Content-Type", "json") - resp, _ = json.Marshal(map[string]string{"credentials": "foo", "clusterID": "bar"}) + resp, _ = json.Marshal(map[string]string{"bpCredentials": "foo", "clusterID": "bar"}) fakeBrokenGCPResp = &http.Response{ Body: io.NopCloser(bytes.NewReader(resp)), Header: map[string][]string{}, @@ -235,7 +236,7 @@ var _ = Describe("Cloud console command", func() { )) }) - It("returns an error if we can't umarshal the cloud credentials response on aws", func() { + It("returns an error if we can't umarshal the cloud bpCredentials response on aws", func() { GetBackplaneConfiguration = func() (bpConfig config.BackplaneConfiguration, err error) { return config.BackplaneConfiguration{ URL: "https://foo.bar", @@ -248,10 +249,10 @@ var _ = Describe("Cloud console command", func() { mockClient.EXPECT().GetCloudCredentials(gomock.Any(), "foo").Return(fakeBrokenAWSResp, nil).Times(1) mockOcmInterface.EXPECT().GetClusterInfoByID(gomock.Any()).Return(&cmv1.Cluster{}, nil).AnyTimes() - Expect(runCredentials(&cobra.Command{}, []string{"foo"}).Error()).To(ContainSubstring("unable to unmarshal AWS credentials response from backplane")) + Expect(runCredentials(&cobra.Command{}, []string{"foo"}).Error()).To(ContainSubstring("unable to unmarshal AWS bpCredentials response from backplane")) }) - It("returns an error if we can't umarshal the cloud credentials response on gcp", func() { + It("returns an error if we can't umarshal the cloud bpCredentials response on gcp", func() { GetBackplaneConfiguration = func() (bpConfig config.BackplaneConfiguration, err error) { return config.BackplaneConfiguration{ URL: "https://foo.bar", @@ -264,7 +265,7 @@ var _ = Describe("Cloud console command", func() { mockClient.EXPECT().GetCloudCredentials(gomock.Any(), "foo").Return(fakeBrokenGCPResp, nil).Times(1) mockOcmInterface.EXPECT().GetClusterInfoByID(gomock.Any()).Return(&cmv1.Cluster{}, nil).AnyTimes() - Expect(runCredentials(&cobra.Command{}, []string{"foo"}).Error()).To(ContainSubstring("unable to unmarshal GCP credentials response from backplane")) + Expect(runCredentials(&cobra.Command{}, []string{"foo"}).Error()).To(ContainSubstring("unable to unmarshal GCP bpCredentials response from backplane")) }) It("returns an error if there is an unknown cloud provider", func() { @@ -278,6 +279,7 @@ var _ = Describe("Cloud console command", func() { mockOcmInterface.EXPECT().GetTargetCluster(gomock.Any()).Return("foo", "bar", nil).AnyTimes() mockOcmInterface.EXPECT().GetClusterInfoByID(gomock.Any()).Return(&cmv1.Cluster{}, nil).AnyTimes() mockClusterUtils.EXPECT().GetCloudProvider(gomock.Any()).Return("azure").AnyTimes() + Expect(runCredentials(&cobra.Command{}, []string{"cluster-key"})).To(Equal( fmt.Errorf("unsupported cloud provider: %s", "azure"), )) @@ -314,7 +316,7 @@ var _ = Describe("Cloud console command", func() { }) Context("test renderCloudCredentials", func() { - creds := AWSCredentialsResponse{ + creds := bpCredentials.AWSCredentialsResponse{ AccessKeyID: "foo", SecretAccessKey: "bar", SessionToken: "baz", @@ -322,7 +324,7 @@ var _ = Describe("Cloud console command", func() { } It("prints the format export if the env output flag is supplied", func() { - export := creds.fmtExport() + export := creds.FmtExport() Expect(renderCloudCredentials("env", &creds)).To(Equal(export)) }) @@ -348,7 +350,7 @@ var _ = Describe("Cloud console command", func() { }) Context("TestAWSCredentialsResponseString(", func() { It("It formats the output correctly", func() { - r := &AWSCredentialsResponse{ + r := &bpCredentials.AWSCredentialsResponse{ AccessKeyID: "12345", SecretAccessKey: "56789", SessionToken: "sessiontoken", @@ -367,7 +369,7 @@ var _ = Describe("Cloud console command", func() { }) Context("TestGCPCredentialsResponseString", func() { It("It formats the output correctly", func() { - r := &GCPCredentialsResponse{ + r := &bpCredentials.GCPCredentialsResponse{ ProjectID: "foo", } expect := `If this is your first time, run "gcloud auth login" and then @@ -378,7 +380,7 @@ gcloud config set project foo` }) Context("TestAWSCredentialsResponseFmtEformattedcredsxport", func() { It("It formats the output correctly", func() { - r := &AWSCredentialsResponse{ + r := &bpCredentials.AWSCredentialsResponse{ AccessKeyID: "foo", SecretAccessKey: "bar", SessionToken: "baz", @@ -390,18 +392,18 @@ gcloud config set project foo` export AWS_SECRET_ACCESS_KEY=bar export AWS_SESSION_TOKEN=baz export AWS_DEFAULT_REGION=quux` - Expect(awsExportOut).To(Equal(r.fmtExport())) + Expect(awsExportOut).To(Equal(r.FmtExport())) }) }) Context("TestGCPCredentialsResponseFmtExport", func() { It("It formats the output correctly", func() { - r := &GCPCredentialsResponse{ + r := &bpCredentials.GCPCredentialsResponse{ ProjectID: "foo", } gcpExportFormatOut := `export CLOUDSDK_CORE_PROJECT=foo` - Expect(gcpExportFormatOut).To(Equal(r.fmtExport())) + Expect(gcpExportFormatOut).To(Equal(r.FmtExport())) }) }) }) diff --git a/cmd/ocm-backplane/cloud/token.go b/cmd/ocm-backplane/cloud/token.go deleted file mode 100644 index c23b3245..00000000 --- a/cmd/ocm-backplane/cloud/token.go +++ /dev/null @@ -1,72 +0,0 @@ -package cloud - -import ( - "fmt" - - "github.com/spf13/cobra" - - "github.com/openshift/backplane-cli/pkg/awsutil" - "github.com/openshift/backplane-cli/pkg/cli/config" - "github.com/openshift/backplane-cli/pkg/utils" -) - -var tokenArgs struct { - roleArn string - output string -} - -var TokenCmd = &cobra.Command{ - Use: "token", - Short: "Generates a session token for the given role ARN", - Long: `Generates a session token for the given role ARN. - -This command is the equivalent of running "aws sts assume-role-with-web-identity --role-arn [role-arn] --web-identity-token [ocm token] --role-session-name [email from OCM token]" behind the scenes, -where the ocm token used is the result of running "ocm token". - -This command will output the "Credentials" property of that call in formatted JSON.`, - Example: "backplane cloud token --role-arn arn:aws:iam::1234567890:role/read-only -oenv", - Args: cobra.NoArgs, - RunE: runToken, -} - -func init() { - flags := TokenCmd.Flags() - flags.StringVar(&tokenArgs.roleArn, "role-arn", "", "The arn of the role for which to get credentials.") - flags.StringVarP(&tokenArgs.output, "output", "o", "env", "Format the output of the console response.") -} - -func runToken(*cobra.Command, []string) error { - ocmToken, err := utils.DefaultOCMInterface.GetOCMAccessToken() - if err != nil { - return fmt.Errorf("failed to retrieve OCM token: %w", err) - } - - bpConfig, err := config.GetBackplaneConfiguration() - if err != nil { - return fmt.Errorf("error retrieving backplane configuration: %w", err) - } - svc, err := awsutil.StsClientWithProxy(bpConfig.ProxyURL) - if err != nil { - return fmt.Errorf("error creating STS client: %w", err) - } - - result, err := awsutil.AssumeRoleWithJWT(*ocmToken, tokenArgs.roleArn, svc) - if err != nil { - return fmt.Errorf("failed to assume role with JWT: %w", err) - } - - credsResponse := awsutil.AWSCredentialsResponse{ - AccessKeyID: result.AccessKeyID, - SecretAccessKey: result.SecretAccessKey, - SessionToken: result.SessionToken, - Expiration: result.Expires.String(), - } - - formattedResult, err := credsResponse.RenderOutput(tokenArgs.output) - if err != nil { - return fmt.Errorf("failed to format output correctly: %w", err) - } - - fmt.Println(formattedResult) - return nil -} diff --git a/go.mod b/go.mod index 7e2c40cf..3085fa33 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/onsi/ginkgo v1.16.5 github.com/onsi/gomega v1.28.0 github.com/openshift-online/ocm-cli v0.1.66 - github.com/openshift-online/ocm-sdk-go v0.1.376 + github.com/openshift-online/ocm-sdk-go v0.1.377 github.com/openshift/backplane-api v0.0.0-20230919035427-a52e4ae498fb github.com/openshift/client-go v0.0.0-20221019143426-16aed247da5c github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 diff --git a/go.sum b/go.sum index 7d2d61d2..0b35d2d3 100644 --- a/go.sum +++ b/go.sum @@ -441,8 +441,8 @@ github.com/onsi/gomega v1.28.0 h1:i2rg/p9n/UqIDAMFUJ6qIUUMcsqOuUHgbpbu235Vr1c= github.com/onsi/gomega v1.28.0/go.mod h1:A1H2JE76sI14WIP57LMKj7FVfCHx3g3BcZVjJG8bjX8= github.com/openshift-online/ocm-cli v0.1.66 h1:+zBcNLVF4zv5ru0tSelIlzWBb35/dxwXRTKwO9MYNgE= github.com/openshift-online/ocm-cli v0.1.66/go.mod h1:lTq3/GbsStzceXeIijnMHbe80w1kDWqNnTvOQV43V+w= -github.com/openshift-online/ocm-sdk-go v0.1.376 h1:KK4Nb+D0ncTl8aytnJVDDr56JnGMJLKYW6lsrefCBAA= -github.com/openshift-online/ocm-sdk-go v0.1.376/go.mod h1:KYOw8kAKAHyPrJcQoVR82CneQ4ofC02Na4cXXaTq4Nw= +github.com/openshift-online/ocm-sdk-go v0.1.377 h1:ZFJ2t6hxIyvufo/V3egInp2V564IUZK8ZOjjribZKq4= +github.com/openshift-online/ocm-sdk-go v0.1.377/go.mod h1:KYOw8kAKAHyPrJcQoVR82CneQ4ofC02Na4cXXaTq4Nw= github.com/openshift/api v0.0.0-20221018124113-7edcfe3c76cb h1:QsBjYe5UfHIZi/3SMzQBIjYDKnWqZxq50eQkBp9eUew= github.com/openshift/api v0.0.0-20221018124113-7edcfe3c76cb/go.mod h1:JRz+ZvTqu9u7t6suhhPTacbFl5K65Y6rJbNM7HjWA3g= github.com/openshift/backplane-api v0.0.0-20230919035427-a52e4ae498fb h1:hs/QQB+1gHpFozwc0lXIy+V7iMkzkkJSKdCaCumjw8Q= diff --git a/pkg/awsutil/credentials.go b/pkg/awsutil/credentials.go deleted file mode 100644 index f0b0024e..00000000 --- a/pkg/awsutil/credentials.go +++ /dev/null @@ -1,45 +0,0 @@ -package awsutil - -import ( - "encoding/json" - "fmt" - - "sigs.k8s.io/yaml" -) - -const awsExportFormat = `export AWS_ACCESS_KEY_ID=%s -export AWS_SECRET_ACCESS_KEY=%s -export AWS_SESSION_TOKEN=%s` - -type AWSCredentialsResponse struct { - AccessKeyID string `json:"AccessKeyID" yaml:"AccessKeyID"` - SecretAccessKey string `json:"SecretAccessKey" yaml:"SecretAccessKey"` - SessionToken string `json:"SessionToken" yaml:"SessionToken"` - Region string `json:"Region,omitempty" yaml:"Region,omitempty"` - Expiration string `json:"Expiration,omitempty" yaml:"Expiration,omitempty"` -} - -func (r AWSCredentialsResponse) EnvFormat() string { - return fmt.Sprintf(awsExportFormat, r.AccessKeyID, r.SecretAccessKey, r.SessionToken) -} - -func (r AWSCredentialsResponse) RenderOutput(outputFormat string) (string, error) { - switch outputFormat { - case "env": - return r.EnvFormat(), nil - case "json": - jsonBytes, err := json.Marshal(r) - if err != nil { - return "", fmt.Errorf("failed to render output as %v: %w", outputFormat, err) - } - return string(jsonBytes), nil - case "yaml": - yamlBytes, err := yaml.Marshal(r) - if err != nil { - return "", fmt.Errorf("failed to render output as %v: %w", outputFormat, err) - } - return string(yamlBytes), nil - default: - return "", fmt.Errorf("unsupported format %v", outputFormat) - } -} diff --git a/pkg/awsutil/credentials_test.go b/pkg/awsutil/credentials_test.go deleted file mode 100644 index 4d1926ca..00000000 --- a/pkg/awsutil/credentials_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package awsutil - -import ( - "encoding/json" - "fmt" - "testing" - - "sigs.k8s.io/yaml" -) - -func TestAWSCredentialsResponse_EnvFormat(t *testing.T) { - type fields struct { - AccessKeyID string - SecretAccessKey string - SessionToken string - } - tests := []struct { - name string - fields fields - want string - }{ - { - name: "Contains no values", - want: fmt.Sprintf(awsExportFormat, "", "", ""), - }, - { - name: "Contains Access Key Id only", - fields: fields{AccessKeyID: "test-key"}, - want: fmt.Sprintf(awsExportFormat, "test-key", "", ""), - }, - { - name: "Contains Secret Access Key only", - fields: fields{SecretAccessKey: "test-secret-key"}, - want: fmt.Sprintf(awsExportFormat, "", "test-secret-key", ""), - }, - { - name: "Contains Session Token only", - fields: fields{SessionToken: "test-session-token"}, - want: fmt.Sprintf(awsExportFormat, "", "", "test-session-token"), - }, - { - name: "Contains Access Key Id and Secret Access Key", - fields: fields{AccessKeyID: "test-key", SecretAccessKey: "test-secret-key"}, - want: fmt.Sprintf(awsExportFormat, "test-key", "test-secret-key", ""), - }, - { - name: "Contains Access Key Id and Session Token", - fields: fields{AccessKeyID: "test-key", SessionToken: "test-session-token"}, - want: fmt.Sprintf(awsExportFormat, "test-key", "", "test-session-token"), - }, - { - name: "Contains Secret Access Key and Session Token", - fields: fields{SecretAccessKey: "test-secret-key", SessionToken: "test-session-token"}, - want: fmt.Sprintf(awsExportFormat, "", "test-secret-key", "test-session-token"), - }, - { - name: "Contains Access Key Id, Secret Access Key, and Session Token", - fields: fields{AccessKeyID: "test-key", SecretAccessKey: "test-secret-key", SessionToken: "test-session-token"}, - want: fmt.Sprintf(awsExportFormat, "test-key", "test-secret-key", "test-session-token"), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := AWSCredentialsResponse{ - AccessKeyID: tt.fields.AccessKeyID, - SecretAccessKey: tt.fields.SecretAccessKey, - SessionToken: tt.fields.SessionToken, - } - if got := r.EnvFormat(); got != tt.want { - t.Errorf("EnvFormat() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestAWSCredentialsResponse_RenderOutput(t *testing.T) { - type fields struct { - AccessKeyID string - SecretAccessKey string - SessionToken string - Region string - Expiration string - } - type args struct { - outputFormat string - } - credentials := fields{ - AccessKeyID: "1", - SecretAccessKey: "2", - SessionToken: "3", - Region: "4", - Expiration: "5", - } - jsonOutput, _ := json.Marshal(credentials) - yamlOutput, _ := yaml.Marshal(credentials) - - tests := []struct { - name string - fields fields - args args - want string - wantErr bool - }{ - { - "Render JSON", credentials, args{ - outputFormat: "json", - }, - string(jsonOutput), - false, - }, - { - "Render YAML", - credentials, - args{ - outputFormat: "yaml", - }, - string(yamlOutput), - false, - }, - { - "Render Invalid", - credentials, - args{ - outputFormat: "invalid", - }, - "", - true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := AWSCredentialsResponse{ - AccessKeyID: tt.fields.AccessKeyID, - SecretAccessKey: tt.fields.SecretAccessKey, - SessionToken: tt.fields.SessionToken, - Region: tt.fields.Region, - Expiration: tt.fields.Expiration, - } - got, err := r.RenderOutput(tt.args.outputFormat) - if (err != nil) != tt.wantErr { - t.Errorf("AWSCredentialsResponse.RenderOutput() error = %v, wantErr %v", err, tt.wantErr) - return - } - if got != tt.want { - t.Errorf("AWSCredentialsResponse.RenderOutput() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/pkg/credentials/aws.go b/pkg/credentials/aws.go new file mode 100644 index 00000000..f770cda0 --- /dev/null +++ b/pkg/credentials/aws.go @@ -0,0 +1,33 @@ +package credentials + +import "fmt" + +const ( + // AwsCredentialsStringFormat format strings for printing AWS credentials as a string or as environment variables + AwsCredentialsStringFormat = `Temporary Credentials: + AccessKeyID: %s + SecretAccessKey: %s + SessionToken: %s + Region: %s + Expires: %s` + AwsExportFormat = `export AWS_ACCESS_KEY_ID=%s +export AWS_SECRET_ACCESS_KEY=%s +export AWS_SESSION_TOKEN=%s +export AWS_DEFAULT_REGION=%s` +) + +type AWSCredentialsResponse struct { + AccessKeyID string `json:"AccessKeyID" yaml:"AccessKeyID"` + SecretAccessKey string `json:"SecretAccessKey" yaml:"SecretAccessKey"` + SessionToken string `json:"SessionToken" yaml:"SessionToken"` + Region string `json:"Region" yaml:"Region"` + Expiration string `json:"Expiration" yaml:"Expiration"` +} + +func (r *AWSCredentialsResponse) String() string { + return fmt.Sprintf(AwsCredentialsStringFormat, r.AccessKeyID, r.SecretAccessKey, r.SessionToken, r.Region, r.Expiration) +} + +func (r *AWSCredentialsResponse) FmtExport() string { + return fmt.Sprintf(AwsExportFormat, r.AccessKeyID, r.SecretAccessKey, r.SessionToken, r.Region) +} diff --git a/pkg/credentials/credentials.go b/pkg/credentials/credentials.go new file mode 100644 index 00000000..31cf399c --- /dev/null +++ b/pkg/credentials/credentials.go @@ -0,0 +1,9 @@ +package credentials + +type Response interface { + // String returns a friendly message outlining how users can setup cloud environment access + String() string + + // FmtExport sets environment variables for users to export to setup cloud environment access + FmtExport() string +} diff --git a/pkg/credentials/gcp.go b/pkg/credentials/gcp.go new file mode 100644 index 00000000..866ce54c --- /dev/null +++ b/pkg/credentials/gcp.go @@ -0,0 +1,22 @@ +package credentials + +import "fmt" + +const ( + // format strings for printing GCP credentials as a string or as environment variables + gcpCredentialsStringFormat = `If this is your first time, run "gcloud auth login" and then +gcloud config set project %s` + gcpExportFormat = `export CLOUDSDK_CORE_PROJECT=%s` +) + +type GCPCredentialsResponse struct { + ProjectID string `json:"project_id" yaml:"project_id"` +} + +func (r *GCPCredentialsResponse) String() string { + return fmt.Sprintf(gcpCredentialsStringFormat, r.ProjectID) +} + +func (r *GCPCredentialsResponse) FmtExport() string { + return fmt.Sprintf(gcpExportFormat, r.ProjectID) +} diff --git a/pkg/utils/mocks/ocmWrapperMock.go b/pkg/utils/mocks/ocmWrapperMock.go index 7558bac0..02b28667 100644 --- a/pkg/utils/mocks/ocmWrapperMock.go +++ b/pkg/utils/mocks/ocmWrapperMock.go @@ -111,6 +111,21 @@ func (mr *MockOCMInterfaceMockRecorder) GetServiceCluster(arg0 interface{}) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceCluster", reflect.TypeOf((*MockOCMInterface)(nil).GetServiceCluster), arg0) } +// GetStsSupportJumpRole mocks base method. +func (m *MockOCMInterface) GetStsSupportJumpRoleARN(arg0 string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetStsSupportJumpRoleARN", arg0) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetStsSupportJumpRole indicates an expected call of GetStsSupportJumpRole. +func (mr *MockOCMInterfaceMockRecorder) GetStsSupportJumpRole(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetStsSupportJumpRoleARN", reflect.TypeOf((*MockOCMInterface)(nil).GetStsSupportJumpRoleARN), arg0) +} + // GetTargetCluster mocks base method. func (m *MockOCMInterface) GetTargetCluster(arg0 string) (string, string, error) { m.ctrl.T.Helper() diff --git a/pkg/utils/ocmWrapper.go b/pkg/utils/ocmWrapper.go index dd847a65..ea7f628f 100644 --- a/pkg/utils/ocmWrapper.go +++ b/pkg/utils/ocmWrapper.go @@ -23,6 +23,7 @@ type OCMInterface interface { GetClusterInfoByID(clusterID string) (*cmv1.Cluster, error) IsProduction() (bool, error) GetPullSecret() (string, error) + GetStsSupportJumpRoleARN(clusterID string) (string, error) } type DefaultOCMInterfaceImpl struct{} @@ -261,6 +262,18 @@ func (*DefaultOCMInterfaceImpl) IsProduction() (bool, error) { return connection.URL() == "https://api.openshift.com", nil } +func (*DefaultOCMInterfaceImpl) GetStsSupportJumpRoleARN(clusterID string) (string, error) { + connection, err := ocm.NewConnection().Build() + if err != nil { + return "", fmt.Errorf("failed to create OCM connection: %v", err) + } + response, err := connection.ClustersMgmt().V1().Clusters().Cluster(clusterID).StsSupportJumpRole().Get().Send() + if err != nil { + return "", fmt.Errorf("failed to get STS Support Jump Role for cluster %v, %w", clusterID, err) + } + return response.Body().RoleArn(), nil +} + func getClusters(client *cmv1.ClustersClient, clusterKey string) ([]*cmv1.Cluster, error) { var clusters []*cmv1.Cluster