From c054bdadab4adc4b46ba1253697d9ed7f56492e1 Mon Sep 17 00:00:00 2001 From: michaelryanmcneill Date: Wed, 6 May 2026 20:32:31 -0400 Subject: [PATCH] [OCM-24570] feat: add external ID support when assuming customer support roles Signed-off-by: michaelryanmcneill --- README.md | 6 ++ cmd/ocm-backplane/cloud/common.go | 4 + cmd/ocm-backplane/cloud/common_test.go | 100 +++++++++++++++++++++++++ pkg/awsutil/sts.go | 20 ++++- pkg/awsutil/sts_test.go | 75 ++++++++++++++++++- 5 files changed, 199 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0d578def..014cf003 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,12 @@ Logging into multiple clusters via different terminal instances. $ ocm backplane cloud console ``` +## Cloud credentials + +- `ocm backplane cloud credentials` returns temporary credentials for the logged-in cluster's cloud provider (for example AWS access key, secret key, and session token as JSON or shell exports). +- For **AWS**, backplane-api performs the STS role chain and returns credentials after assuming the customer support role. If the cluster has an **STS external ID** in OCM, the API passes it on that assume; no extra CLI flags are required for the default (non-isolated) path. +- **Isolated** access (for example HyperShift hosted clusters and the STS jump-role path) calls backplane-api for an **assume-role sequence**, then the CLI assumes each role locally. When the API response includes optional **`externalId`** (from OCM `AWS.STS.ExternalID`), the CLI supplies it to AWS STS for the **Org** and **Target** roles in the sequence. Older APIs or clusters without an external ID omit the field; behavior matches previous releases. + ## SSM Session Now you can directly start an AWS SSM session in your terminal using a single command for the HCP clusters without logging into their cloud consoles. It will start an AWS session directly in your terminal where you can debug into the worker node for the HCP cluster and carry out further operations. - Before using ssm command check if Session Manager plugin has been properly set up in your device. Follow this official AWS [documentation](https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-prerequisites.html) for further information on setting up AWS SSM. And for installing SSM plugin directly on your device follow this [documentation](https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html). Also check AWS CLI version and SSM version and ensure that those are in required versions. Update if required. diff --git a/cmd/ocm-backplane/cloud/common.go b/cmd/ocm-backplane/cloud/common.go index 803d5dfb..8d79431c 100644 --- a/cmd/ocm-backplane/cloud/common.go +++ b/cmd/ocm-backplane/cloud/common.go @@ -217,6 +217,7 @@ type assumeChainResponse struct { AssumptionSequence []namedRoleArn `json:"assumptionSequence"` CustomerRoleSessionName string `json:"customerRoleSessionName"` SessionPolicyArn string `json:"sessionPolicyArn"` // SessionPolicyArn is the ARN of the session policy + ExternalID string `json:"externalId,omitempty"` } type namedRoleArn struct { @@ -389,6 +390,9 @@ func (cfg *QueryConfig) getIsolatedCredentials(ocmToken string) (aws.Credentials roleArnSession.IsCustomerRole = false } roleArnSession.Name = namedRoleArnEntry.Name + if roleChainResponse.ExternalID != "" && (namedRoleArnEntry.Name == OrgRoleArnName || namedRoleArnEntry.Name == CustomerRoleArnName) { + roleArnSession.ExternalID = roleChainResponse.ExternalID + } assumeRoleArnSessionSequence = append(assumeRoleArnSessionSequence, roleArnSession) } diff --git a/cmd/ocm-backplane/cloud/common_test.go b/cmd/ocm-backplane/cloud/common_test.go index 570c543c..5b238471 100644 --- a/cmd/ocm-backplane/cloud/common_test.go +++ b/cmd/ocm-backplane/cloud/common_test.go @@ -679,6 +679,85 @@ var _ = Describe("getIsolatedCredentials", func() { Expect(err).To(BeNil()) }) + It("should propagate externalId from API into Org and Target assume steps", func() { + testQueryConfig.AwsProxy = aws.String("http://aws-proxy:8080") + ext := "ocm-sts-ext-id-xyz" + extHTTPResp := &http.Response{ + Body: MakeIoReader( + `{"assumptionSequence":[{"name":"SRE-Role-Arn","arn":"arn:aws:iam::10000000:role/SRE"}, + {"name":"Org-Role-Arn","arn":"arn:aws:iam::10000000:role/ORG"}, + {"name":"Target-Role-Arn","arn":"arn:aws:iam::10000000:role/TARGET"}], + "customerRoleSessionName":"b7bb29e58495b17412e15701cea805b7", + "externalId":"` + ext + `"}`, + ), + Header: map[string][]string{}, + StatusCode: http.StatusOK, + } + + originalCheckEgressIP := CheckEgressIP + CheckEgressIP = func(client *http.Client, url string) (net.IP, error) { + return net.ParseIP("209.10.10.10"), nil + } + defer func() { + CheckEgressIP = originalCheckEgressIP + }() + + GetCallerIdentity = func(client *sts.Client) error { + return nil + } + defer func() { + GetCallerIdentity = func(client *sts.Client) error { + _, err := client.GetCallerIdentity(context.TODO(), &sts.GetCallerIdentityInput{}) + return err + } + }() + + ip1 := cmv1.NewTrustedIp().ID("209.10.10.10").Enabled(true) + expectedIPList, err := cmv1.NewTrustedIpList().Items(ip1).Build() + Expect(err).To(BeNil()) + mockOcmInterface.EXPECT().GetTrustedIPList(gomock.Any()).Return(expectedIPList, nil) + + StsClient = 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{ + AccessKeyID: testAccessKeyID, + SecretAccessKey: testSecretAccessKey, + SessionToken: testSessionToken, + }, nil + } + + var capturedSeq []awsutil.RoleArnSession + AssumeRoleSequence = func( + seedClient stscreds.AssumeRoleAPIClient, + roleArnSequence []awsutil.RoleArnSession, + proxyURL *string, + stsClientProviderFunc awsutil.STSClientProviderFunc, + ) (aws.Credentials, error) { + capturedSeq = append([]awsutil.RoleArnSession(nil), roleArnSequence...) + return aws.Credentials{ + AccessKeyID: testAccessKeyID, + SecretAccessKey: testSecretAccessKey, + SessionToken: testSessionToken, + }, nil + } + defer func() { + AssumeRoleSequence = awsutil.AssumeRoleSequence + }() + + mockClientUtil.EXPECT().GetBackplaneClient( + testQueryConfig.BackplaneConfiguration.URL, testOcmToken, testQueryConfig.BackplaneConfiguration.ProxyURL).Return(mockClient, nil) + mockClient.EXPECT().GetAssumeRoleSequence(context.TODO(), testClusterID).Return(extHTTPResp, nil) + + _, err = testQueryConfig.getIsolatedCredentials(testOcmToken) + Expect(err).To(BeNil()) + Expect(capturedSeq).To(HaveLen(3)) + Expect(capturedSeq[0].ExternalID).To(BeEmpty()) + Expect(capturedSeq[1].ExternalID).To(Equal(ext)) + Expect(capturedSeq[2].ExternalID).To(Equal(ext)) + }) + It("should use AWS proxy when both ProxyURL and AwsProxy are configured", func() { // Set both explicit proxy and AWS proxy in configuration testQueryConfig.ProxyURL = aws.String("http://regular-proxy:9090") @@ -1214,12 +1293,33 @@ var _ = Describe("PolicyARNs Integration", func() { roleArnSession.IsCustomerRole = false } roleArnSession.Name = namedRoleArnEntry.Name + if roleChainResponse.ExternalID != "" && (namedRoleArnEntry.Name == OrgRoleArnName || namedRoleArnEntry.Name == CustomerRoleArnName) { + roleArnSession.ExternalID = roleChainResponse.ExternalID + } assumeRoleArnSessionSequence = append(assumeRoleArnSessionSequence, roleArnSession) } return assumeRoleArnSessionSequence } + It("should set ExternalID on Org and Target roles when API returns externalId", func() { + ext := "ocm-cluster-sts-external-id" + roleChainResponse := assumeChainResponse{ + AssumptionSequence: []namedRoleArn{ + {Name: "SRE-Role-Arn", Arn: "arn:aws:iam::111:role/sre"}, + {Name: OrgRoleArnName, Arn: "arn:aws:iam::222:role/org"}, + {Name: CustomerRoleArnName, Arn: "arn:aws:iam::333:role/target"}, + }, + CustomerRoleSessionName: "cust-sess", + ExternalID: ext, + } + seq := simulateGetIsolatedCredentialsLogic(roleChainResponse) + Expect(len(seq)).To(Equal(3)) + Expect(seq[0].ExternalID).To(BeEmpty()) + Expect(seq[1].ExternalID).To(Equal(ext)) + Expect(seq[2].ExternalID).To(Equal(ext)) + }) + // Generated by Cursor Context("when creating RoleArnSession with SessionPolicyArn", func() { It("should set PolicyARNs for customer roles", func() { diff --git a/pkg/awsutil/sts.go b/pkg/awsutil/sts.go index 72660341..26c4a068 100644 --- a/pkg/awsutil/sts.go +++ b/pkg/awsutil/sts.go @@ -43,7 +43,7 @@ type AWSSigninTokenResponse struct { var httpGetFunc = http.Get -// Returns a new stsclient, proxy is optional. +// StsClient returns a new STS API client; proxyURL is optional. func StsClient(proxyURL *string) (*sts.Client, error) { cfg := aws.Config{ Region: "us-east-1", // We don't care about region here, but the API still wants to see one set @@ -70,6 +70,7 @@ func (j IdentityTokenValue) GetIdentityToken() ([]byte, error) { return []byte(j), nil } +// AssumeRoleWithJWT exchanges jwt for temporary credentials for roleArn using the given STS client. func AssumeRoleWithJWT(jwt string, roleArn string, stsClient stscreds.AssumeRoleWithWebIdentityAPIClient) (aws.Credentials, error) { logger.Debug("JWT Assuming role: ", roleArn) email, err := utils.GetStringFieldFromJWT(jwt, "email") @@ -97,12 +98,14 @@ func AssumeRoleWithJWT(jwt string, roleArn string, stsClient stscreds.AssumeRole return result, nil } +// AssumeRole calls STS AssumeRole for roleArn. When externalID is non-empty it is passed as the IAM external ID (cross-account trust). func AssumeRole( stsClient stscreds.AssumeRoleAPIClient, roleSessionName string, roleArn string, inlinePolicy *PolicyDocument, policyARNs []types.PolicyDescriptorType, + externalID string, ) (aws.Credentials, error) { assumeRoleProvider := stscreds.NewAssumeRoleProvider(stsClient, roleArn, func(options *stscreds.AssumeRoleOptions) { options.RoleSessionName = roleSessionName @@ -115,6 +118,9 @@ func AssumeRole( if len(policyARNs) > 0 { options.PolicyARNs = policyARNs } + if externalID != "" { + options.ExternalID = aws.String(externalID) + } }) result, err := assumeRoleProvider.Retrieve(context.TODO()) if err != nil { @@ -124,8 +130,10 @@ func AssumeRole( return result, nil } +// STSClientProviderFunc loads an STS AssumeRole API client (e.g. with static credentials from a prior assume step). type STSClientProviderFunc func(optFns ...func(*config.LoadOptions) error) (stscreds.AssumeRoleAPIClient, error) +// DefaultSTSClientProviderFunc is the default STSClientProviderFunc using the AWS SDK default config chain. var DefaultSTSClientProviderFunc STSClientProviderFunc = func(optnFns ...func(options *config.LoadOptions) error) (stscreds.AssumeRoleAPIClient, error) { cfg, err := config.LoadDefaultConfig(context.TODO(), optnFns...) if err != nil { @@ -134,6 +142,7 @@ var DefaultSTSClientProviderFunc STSClientProviderFunc = func(optnFns ...func(op return sts.NewFromConfig(cfg), nil } +// RoleArnSession is one step in a chained AssumeRole sequence (seed credentials assume into RoleArn, optionally with session policy and external ID). type RoleArnSession struct { Name string RoleSessionName string @@ -141,8 +150,11 @@ type RoleArnSession struct { IsCustomerRole bool Policy *PolicyDocument PolicyARNs []types.PolicyDescriptorType + // ExternalID is the OCM/AWS STS external ID for this assume step (empty when not used). + ExternalID string } +// AssumeRoleSequence assumes each role in order using credentials from the previous step (or seedClient for the first). func AssumeRoleSequence( seedClient stscreds.AssumeRoleAPIClient, roleArnSessionSequence []RoleArnSession, @@ -164,7 +176,7 @@ func AssumeRoleSequence( roleArnSession.RoleSessionName, roleArnSession.IsCustomerRole, ) - result, err := AssumeRole(nextClient, roleArnSession.RoleSessionName, roleArnSession.RoleArn, roleArnSession.Policy, roleArnSession.PolicyARNs) + result, err := AssumeRole(nextClient, roleArnSession.RoleSessionName, roleArnSession.RoleArn, roleArnSession.Policy, roleArnSession.PolicyARNs, roleArnSession.ExternalID) retryCount := 0 for err != nil { // IAM policy updates can take a few seconds to resolve, and the sts.Client in AWS' Go SDK doesn't refresh itself on retries. @@ -177,7 +189,7 @@ func AssumeRoleSequence( return aws.Credentials{}, fmt.Errorf("failed to create client with credentials for role %v: %w", roleArnSession.RoleArn, err) } - result, err = AssumeRole(nextClient, roleArnSession.RoleSessionName, roleArnSession.RoleArn, roleArnSession.Policy, roleArnSession.PolicyARNs) + result, err = AssumeRole(nextClient, roleArnSession.RoleSessionName, roleArnSession.RoleArn, roleArnSession.Policy, roleArnSession.PolicyARNs, roleArnSession.ExternalID) if err != nil { logger.Debugf("failed to create client with credentials for role %s: name:%s %v", roleArnSession.RoleArn, roleArnSession.Name, err) } @@ -220,6 +232,7 @@ func createAssumeRoleSequenceClient(stsClientProviderFunc STSClientProviderFunc, ) } +// GetSigninToken requests an AWS console federation sign-in token for the given temporary credentials. func GetSigninToken(awsCredentials aws.Credentials, region string) (*AWSSigninTokenResponse, error) { sessionData := AWSFederatedSessionData{ SessionID: awsCredentials.AccessKeyID, @@ -266,6 +279,7 @@ func GetSigninToken(awsCredentials aws.Credentials, region string) (*AWSSigninTo return &resp, nil } +// GetConsoleURL builds the federated console login URL for signinToken in region, optionally extending session duration. func GetConsoleURL(signinToken string, region string, sessionDurationMinutes int) (*url.URL, error) { signinParams := url.Values{} signinParams.Add("Action", "login") diff --git a/pkg/awsutil/sts_test.go b/pkg/awsutil/sts_test.go index 6221dc6f..fba75e04 100644 --- a/pkg/awsutil/sts_test.go +++ b/pkg/awsutil/sts_test.go @@ -175,7 +175,7 @@ func TestAssumeRole(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := AssumeRole(tt.stsClient, "", "", tt.inlinePolicy, []types.PolicyDescriptorType{}) + got, err := AssumeRole(tt.stsClient, "", "", tt.inlinePolicy, []types.PolicyDescriptorType{}, "") if (err != nil) != tt.wantErr { t.Errorf("AssumeRole() error = %v, wantErr %v", err, tt.wantErr) return @@ -402,7 +402,7 @@ func TestAssumeRole_PolicyARNs(t *testing.T) { t.Run(tt.name, func(t *testing.T) { stsClient := defaultSuccessMockSTSClient() - got, err := AssumeRole(stsClient, "test-session", "arn:aws:iam::123456789012:role/test-role", nil, tt.policyARNs) + got, err := AssumeRole(stsClient, "test-session", "arn:aws:iam::123456789012:role/test-role", nil, tt.policyARNs, "") if (err != nil) != tt.wantErr { t.Errorf("AssumeRole() error = %v, wantErr %v", err, tt.wantErr) @@ -595,7 +595,7 @@ func TestAssumeRole_PolicyARNs_ErrorScenarios(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := AssumeRole(tt.stsClient, "test-session", "arn:aws:iam::123456789012:role/test-role", nil, tt.policyARNs) + got, err := AssumeRole(tt.stsClient, "test-session", "arn:aws:iam::123456789012:role/test-role", nil, tt.policyARNs, "") if (err != nil) != tt.wantErr { t.Errorf("AssumeRole() error = %v, wantErr %v", err, tt.wantErr) @@ -817,6 +817,75 @@ func TestGetSigninToken(t *testing.T) { } } +// stsAssumeRoleCapture records the last AssumeRole input for tests. +type stsAssumeRoleCapture struct { + lastInput *sts.AssumeRoleInput +} + +func (s *stsAssumeRoleCapture) AssumeRole(ctx context.Context, in *sts.AssumeRoleInput, _ ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) { + s.lastInput = in + return &sts.AssumeRoleOutput{ + Credentials: &types.Credentials{ + AccessKeyId: aws.String("a"), + SecretAccessKey: aws.String("b"), + SessionToken: aws.String("c"), + Expiration: aws.Time(time.Now().Add(time.Hour)), + }, + }, nil +} + +// TestAssumeRole_externalID verifies non-empty externalID is sent on the STS AssumeRole input. +func TestAssumeRole_externalID(t *testing.T) { + c := &stsAssumeRoleCapture{} + _, err := AssumeRole(c, "sess", "arn:aws:iam::123456789012:role/r", nil, nil, "my-external-id") + if err != nil { + t.Fatal(err) + } + if c.lastInput == nil || c.lastInput.ExternalId == nil || *c.lastInput.ExternalId != "my-external-id" { + t.Fatalf("STS AssumeRole ExternalId: got %+v", c.lastInput) + } +} + +// TestAssumeRole_emptyExternalID verifies empty externalID does not set ExternalId on the STS input. +func TestAssumeRole_emptyExternalID(t *testing.T) { + c := &stsAssumeRoleCapture{} + _, err := AssumeRole(c, "sess", "arn:aws:iam::123456789012:role/r", nil, nil, "") + if err != nil { + t.Fatal(err) + } + if c.lastInput == nil { + t.Fatal("expected capture") + } + if c.lastInput.ExternalId != nil { + t.Fatalf("expected nil ExternalId, got %q", *c.lastInput.ExternalId) + } +} + +// TestAssumeRoleSequence_propagatesExternalID verifies RoleArnSession.ExternalID reaches STS via AssumeRoleSequence. +func TestAssumeRoleSequence_propagatesExternalID(t *testing.T) { + capture := &stsAssumeRoleCapture{} + loader := func(...func(*config.LoadOptions) error) (stscreds.AssumeRoleAPIClient, error) { + return capture, nil + } + seq := []RoleArnSession{ + { + RoleArn: "arn:aws:iam::123456789012:role/r", + RoleSessionName: "sess", + ExternalID: "my-ext-id", + }, + } + _, err := AssumeRoleSequence(capture, seq, nil, loader) + if err != nil { + t.Fatal(err) + } + if capture.lastInput == nil { + t.Fatal("expected non-nil last STS AssumeRole input") + } + if capture.lastInput.ExternalId == nil || *capture.lastInput.ExternalId != "my-ext-id" { + t.Fatalf("STS AssumeRole ExternalId: got %+v", capture.lastInput) + } +} + func TestGetConsoleUrl(t *testing.T) { tests := []struct { name string