Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions cmd/ocm-backplane/cloud/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down
100 changes: 100 additions & 0 deletions cmd/ocm-backplane/cloud/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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() {
Expand Down
20 changes: 17 additions & 3 deletions pkg/awsutil/sts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -134,15 +142,19 @@ 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
RoleArn string
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,
Expand All @@ -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.
Expand All @@ -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)
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down
75 changes: 72 additions & 3 deletions pkg/awsutil/sts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down