From 81c57f289defa16a0c955a8baf710cf7aaa0fb52 Mon Sep 17 00:00:00 2001 From: Amir Ben Nun <34831306+amirbenun@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:04:43 +0200 Subject: [PATCH] Use combined External ID for AWS cloud connectors AssumeRole (#48956) This change updates the AWS cloud connectors credential flow in libbeat so that the AssumeRole step uses a combined External ID built from the cloud resource ID and the configured external ID: `CloudConnectorsExternalID(resourceID, externalIDPart)` returns `resourceID-externalIDPart`. This allows the remote (customer) role trust policy to scope access by resource while still using the configured external ID. The credential chain is unchanged: assume the Elastic global role with web identity (OIDC token), then assume the configured role with the new External ID and optional expiry window. Tests are updated to assert the new External ID format and a unit test is added for `CloudConnectorsExternalID`. (cherry picked from commit f909d4fb70c4c502c6aa3f4f63eda26b4fe0686e) # Conflicts: # x-pack/libbeat/common/aws/cloud_connectors.go # x-pack/libbeat/common/aws/cloud_connectors_test.go --- x-pack/libbeat/common/aws/cloud_connectors.go | 111 +++++++++++ .../common/aws/cloud_connectors_test.go | 182 ++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 x-pack/libbeat/common/aws/cloud_connectors.go create mode 100755 x-pack/libbeat/common/aws/cloud_connectors_test.go diff --git a/x-pack/libbeat/common/aws/cloud_connectors.go b/x-pack/libbeat/common/aws/cloud_connectors.go new file mode 100644 index 000000000000..841183dea70e --- /dev/null +++ b/x-pack/libbeat/common/aws/cloud_connectors.go @@ -0,0 +1,111 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package aws + +import ( + "errors" + "fmt" + "os" + "time" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials/stscreds" + "github.com/aws/aws-sdk-go-v2/service/sts" + + "github.com/elastic/elastic-agent-libs/logp" +) + +// These env vars are provided by agentless controller when the cloud connectors flow is enabled. +const ( + CloudConnectorsGlobalRoleEnvVar = "CLOUD_CONNECTORS_GLOBAL_ROLE" + CloudConnectorsJWTPathEnvVar = "CLOUD_CONNECTORS_ID_TOKEN_FILE" + CloudConnectorsCloudResourceIDEnvVar = "CLOUD_RESOURCE_ID" +) + +// CloudConnectorsConfig is the config for the cloud connectors flow +type CloudConnectorsConfig struct { + ElasticGlobalRoleARN string + IDTokenPath string + CloudResourceID string +} + +func parseCloudConnectorsConfigFromEnv() (CloudConnectorsConfig, error) { + cc := CloudConnectorsConfig{ + ElasticGlobalRoleARN: os.Getenv(CloudConnectorsGlobalRoleEnvVar), + IDTokenPath: os.Getenv(CloudConnectorsJWTPathEnvVar), + CloudResourceID: os.Getenv(CloudConnectorsCloudResourceIDEnvVar), + } + + var errs []error + + if cc.ElasticGlobalRoleARN == "" { + errs = append(errs, errors.New("elastic global role arn is not configured")) + } + if cc.IDTokenPath == "" { + errs = append(errs, errors.New("id token path is not configured")) + } + if cc.CloudResourceID == "" { + errs = append(errs, errors.New("cloud resource id is not configured")) + } + + if len(errs) > 0 { + return CloudConnectorsConfig{}, fmt.Errorf("cloud connectors config is invalid: %w", errors.Join(errs...)) + } + + return cc, nil +} + +const defaultIntermediateDuration = 20 * time.Minute + +func addCloudConnectorsCredentials(config ConfigAWS, cloudConnectorsConfig CloudConnectorsConfig, awsConfig *awssdk.Config, logger *logp.Logger) { + logger = logger.Named("addCloudConnectorsCredentials") + logger.Debug("Switching credentials provider to Cloud Connectors") + + addCredentialsChain( + awsConfig, + + // Step 1: Assume the Elastic Global Role with web identity using the ID token provided by the agentless OIDC issuer. + func(c awssdk.Config) awssdk.CredentialsProvider { + provider := stscreds.NewWebIdentityRoleProvider( + sts.NewFromConfig(c), // client uses credentials from previous config. + cloudConnectorsConfig.ElasticGlobalRoleARN, + stscreds.IdentityTokenFile(cloudConnectorsConfig.IDTokenPath), + func(opt *stscreds.WebIdentityRoleOptions) { + opt.Duration = defaultIntermediateDuration + }, + ) + return awssdk.NewCredentialsCache(provider) + }, + + // Step 2: Assume the remote role (the user's configured role), using the previously assumed role in the chain. + func(c awssdk.Config) awssdk.CredentialsProvider { + assumeRoleProvider := stscreds.NewAssumeRoleProvider( + sts.NewFromConfig(c), // client uses credentials from previous config. + config.RoleArn, + func(aro *stscreds.AssumeRoleOptions) { + aro.Duration = config.AssumeRoleDuration + if config.ExternalID != "" { + aro.ExternalID = awssdk.String(cloudConnectorsExternalID(cloudConnectorsConfig.CloudResourceID, config.ExternalID)) + } + }, + ) + return awssdk.NewCredentialsCache(assumeRoleProvider, func(options *awssdk.CredentialsCacheOptions) { + if config.AssumeRoleExpiryWindow > 0 { + options.ExpiryWindow = config.AssumeRoleExpiryWindow + } + }) + }, + ) +} + +func cloudConnectorsExternalID(resourceID, externalIDPart string) string { + return fmt.Sprintf("%s-%s", resourceID, externalIDPart) +} + +func addCredentialsChain(awsConfig *awssdk.Config, chain ...func(awssdk.Config) awssdk.CredentialsProvider) { + for _, fn := range chain { + awsConfig.Credentials = fn(*awsConfig) + } +} diff --git a/x-pack/libbeat/common/aws/cloud_connectors_test.go b/x-pack/libbeat/common/aws/cloud_connectors_test.go new file mode 100755 index 000000000000..11f91f5961cf --- /dev/null +++ b/x-pack/libbeat/common/aws/cloud_connectors_test.go @@ -0,0 +1,182 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package aws + +import ( + "context" + "fmt" + "io" + "net/url" + "os" + "path" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/aws/aws-sdk-go-v2/service/sts/types" + "github.com/aws/smithy-go/middleware" + smithyhttp "github.com/aws/smithy-go/transport/http" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/elastic/elastic-agent-libs/logp/logptest" +) + +func TestAddCloudConnectorsCredentials(t *testing.T) { + config := ConfigAWS{ + RoleArn: "arn:aws:iam::123456789012:role/customer-role", + ExternalID: "external-id-456", + AssumeRoleDuration: 2 * time.Hour, + AssumeRoleExpiryWindow: 10 * time.Minute, + } + cloudConnectorsConfig := CloudConnectorsConfig{ + ElasticGlobalRoleARN: "arn:aws:iam::999999999999:role/elastic-global-role", + CloudResourceID: "abcd1234", + } + tokenFileContent := "abc123" + + tmpDir := t.TempDir() + pth := path.Join(tmpDir, "id_token") + _ = os.WriteFile(path.Join(tmpDir, "id_token"), []byte(tokenFileContent), 0o644) + cloudConnectorsConfig.IDTokenPath = pth + + // Create a base AWS config + awsConfig := &aws.Config{ + Region: "us-east-1", + BaseEndpoint: aws.String("https://aws.mock"), + } + + // Create a test logger + logger := logptest.NewTestingLogger(t, "") + + // mock responses + receivedCalls := 0 + awsConfig.APIOptions = append(awsConfig.APIOptions, func(stack *middleware.Stack) error { + return stack.Finalize.Add( + middleware.FinalizeMiddlewareFunc( + "mock", + func(ctx context.Context, in middleware.FinalizeInput, next middleware.FinalizeHandler) (middleware.FinalizeOutput, middleware.Metadata, error) { + req, is := in.Request.(*smithyhttp.Request) + require.Truef(t, is, "request expected to be of type *smithyhttp.Request, got: %T", in.Request) + receivedCalls++ + bd, err := io.ReadAll(req.GetStream()) + assert.NoError(t, req.RewindStream()) + assert.NoError(t, err) + body := string(bd) + + switch receivedCalls { + + // Expect the first request to be AssumeRoleWithWebIdentity + case 1: + q, err := url.ParseQuery(body) + assert.NoError(t, err) + assert.Equal(t, "AssumeRoleWithWebIdentity", q.Get("Action")) + assert.Equal(t, "1200", q.Get("DurationSeconds")) + assert.Equal(t, cloudConnectorsConfig.ElasticGlobalRoleARN, q.Get("RoleArn")) + assert.Equal(t, tokenFileContent, q.Get("WebIdentityToken")) + return middleware.FinalizeOutput{ + Result: &sts.AssumeRoleWithWebIdentityOutput{ + Credentials: &types.Credentials{ + AccessKeyId: aws.String("AKIAFAKEEXAMPLE00001"), + SecretAccessKey: aws.String("FAKEwJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY1"), + SessionToken: aws.String("FwoGZXIvYXdzEFAaDFAKESESSIONTOKENEXAMPLE1"), + Expiration: aws.Time(time.Now().Add(defaultIntermediateDuration)), + }, + }, + }, middleware.Metadata{}, nil + + // Expect the second request to be AssumeRole + case 2: + q, err := url.ParseQuery(body) + assert.NoError(t, err) + assert.Equal(t, "AssumeRole", q.Get("Action")) + assert.Equal(t, "7200", q.Get("DurationSeconds")) + assert.Equal(t, cloudConnectorsExternalID(cloudConnectorsConfig.CloudResourceID, config.ExternalID), q.Get("ExternalId")) + assert.Equal(t, config.RoleArn, q.Get("RoleArn")) + return middleware.FinalizeOutput{ + Result: &sts.AssumeRoleOutput{ + Credentials: &types.Credentials{ + AccessKeyId: aws.String("AKIAFAKEEXAMPLE00002"), + SecretAccessKey: aws.String("FAKEwJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY2"), + SessionToken: aws.String("FwoGZXIvYXdzEFAaDFAKESESSIONTOKENEXAMPLE2"), + Expiration: aws.Time(time.Now().Add(defaultIntermediateDuration)), + }, + }, + }, middleware.Metadata{}, nil + + default: + t.Fatal("unexpected aws sdk call") + return middleware.FinalizeOutput{}, middleware.Metadata{}, fmt.Errorf("unexpected operation") + } + }, + ), + middleware.After, + ) + }) + + // Call the function under test + addCloudConnectorsCredentials( + config, + cloudConnectorsConfig, + awsConfig, + logger, + ) + + // Verify that credentials provider was set + require.NotNil(t, awsConfig.Credentials, "credentials provider should be set") + + crd, err := awsConfig.Credentials.Retrieve(t.Context()) + require.NoError(t, err) + require.NotNil(t, crd) + require.Equal(t, 2, receivedCalls) +} + +func TestCloudConnectorsExternalID(t *testing.T) { + assert.Equal(t, "resource1-ext-id", cloudConnectorsExternalID("resource1", "ext-id")) + assert.Equal(t, "abc123-external-id-456", cloudConnectorsExternalID("abc123", "external-id-456")) + assert.Equal(t, "single-", cloudConnectorsExternalID("single", "")) // format is always "resourceID-externalIDPart" +} + +func TestParseCloudConnectorsConfigFromEnv(t *testing.T) { + t.Run("happy_path", func(t *testing.T) { + t.Setenv(CloudConnectorsGlobalRoleEnvVar, "arn:aws:iam::999999999999:role/elastic-global-role") + t.Setenv(CloudConnectorsJWTPathEnvVar, "/path/token") + t.Setenv(CloudConnectorsCloudResourceIDEnvVar, "abc123") + + got, err := parseCloudConnectorsConfigFromEnv() + + require.NoError(t, err) + + assert.Equal( + t, + CloudConnectorsConfig{ + ElasticGlobalRoleARN: "arn:aws:iam::999999999999:role/elastic-global-role", + IDTokenPath: "/path/token", + CloudResourceID: "abc123", + }, + got, + ) + }) + + t.Run("missing config single", func(t *testing.T) { + t.Setenv(CloudConnectorsGlobalRoleEnvVar, "arn:aws:iam::999999999999:role/elastic-global-role") + t.Setenv(CloudConnectorsJWTPathEnvVar, "/path/token") + + got, err := parseCloudConnectorsConfigFromEnv() + + require.ErrorContains(t, err, "cloud resource id") + assert.Equal(t, CloudConnectorsConfig{}, got) + }) + + t.Run("missing config all", func(t *testing.T) { + got, err := parseCloudConnectorsConfigFromEnv() + + require.ErrorContains(t, err, "elastic global role") + require.ErrorContains(t, err, "id token") + require.ErrorContains(t, err, "cloud resource id") + assert.Equal(t, CloudConnectorsConfig{}, got) + }) +}