diff --git a/internal/config/config.go b/internal/config/config.go index 93aec23866..6a147dddac 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -91,6 +91,8 @@ type GcpConfig struct { GcpCallOpt GcpCallOpt `config:"call_options"` GcpClientOpt `config:"credentials"` + + CloudConnectorsConfig CloudConnectorsConfig } type GcpClientOpt struct { @@ -204,6 +206,11 @@ func New(cfg *config.C) (*Config, error) { c.CloudConfig.Aws.CloudConnectorsConfig = newCloudConnectorsConfig() } + // GCP Cloud Connectors flow is indicated by service_account_email being set + if c.CloudConfig.Gcp.ServiceAccountEmail != "" { + c.CloudConfig.Gcp.CloudConnectorsConfig = newCloudConnectorsConfig() + } + return c, nil } @@ -266,6 +273,7 @@ type CloudConnectorsConfig struct { LocalRoleARN string GlobalRoleARN string ResourceID string + JWTFilePath string } func newCloudConnectorsConfig() CloudConnectorsConfig { @@ -273,6 +281,7 @@ func newCloudConnectorsConfig() CloudConnectorsConfig { LocalRoleARN: os.Getenv(CloudConnectorsLocalRoleEnvVar), GlobalRoleARN: os.Getenv(CloudConnectorsGlobalRoleEnvVar), ResourceID: os.Getenv(CloudResourceIDEnvVar), + JWTFilePath: os.Getenv(CloudConnectorsJWTPathEnvVar), } } diff --git a/internal/resources/providers/awslib/role_chaining.go b/internal/resources/providers/awslib/role_chaining.go index 24524e5f60..104e6aee6c 100644 --- a/internal/resources/providers/awslib/role_chaining.go +++ b/internal/resources/providers/awslib/role_chaining.go @@ -76,14 +76,7 @@ type WebIdentityRoleStep struct { // BuildCredentialsCache implements AWSRoleChainingStep for AssumeRoleWithWebIdentity operations. func (s *WebIdentityRoleStep) BuildCredentialsCache(client *sts.Client) *aws.CredentialsCache { - tokenRetriever := stscreds.IdentityTokenFile(s.WebIdentityTokenFile) - webIdentityProvider := stscreds.NewWebIdentityRoleProvider( - client, - s.RoleARN, - tokenRetriever, - s.Options, - ) - return aws.NewCredentialsCache(webIdentityProvider) + return NewWebIdentityCredentialsCache(client, s.RoleARN, s.WebIdentityTokenFile, s.Options) } // Compile-time checks to ensure types implement AWSRoleChainingStep diff --git a/internal/resources/providers/awslib/web_identity.go b/internal/resources/providers/awslib/web_identity.go new file mode 100644 index 0000000000..4d57bbc06c --- /dev/null +++ b/internal/resources/providers/awslib/web_identity.go @@ -0,0 +1,43 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package awslib + +import ( + "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" +) + +// NewWebIdentityCredentialsCache creates a credentials cache for JWT/OIDC-based authentication. +// It wraps a WebIdentityRoleProvider that assumes the specified role using the token from the file. +// The returned cache automatically refreshes credentials when they expire. +func NewWebIdentityCredentialsCache( + client *sts.Client, + roleARN string, + tokenFilePath string, + options func(*stscreds.WebIdentityRoleOptions), +) *aws.CredentialsCache { + tokenRetriever := stscreds.IdentityTokenFile(tokenFilePath) + webIdentityProvider := stscreds.NewWebIdentityRoleProvider( + client, + roleARN, + tokenRetriever, + options, + ) + return aws.NewCredentialsCache(webIdentityProvider) +} diff --git a/internal/resources/providers/gcplib/auth/auth_provider.go b/internal/resources/providers/gcplib/auth/auth_provider.go index b0137dd07d..5ad5255c28 100644 --- a/internal/resources/providers/gcplib/auth/auth_provider.go +++ b/internal/resources/providers/gcplib/auth/auth_provider.go @@ -19,14 +19,18 @@ package auth import ( "context" + "errors" "fmt" - "os" + "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" "golang.org/x/oauth2/google" "golang.org/x/oauth2/google/externalaccount" "google.golang.org/api/option" "github.com/elastic/cloudbeat/internal/config" + "github.com/elastic/cloudbeat/internal/resources/providers/awslib" ) const ( @@ -34,10 +38,12 @@ const ( gcpSTSTokenURL = "https://sts.googleapis.com/v1/token" // GCP IAM Credentials API endpoint for service account impersonation gcpIAMCredentialsURL = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/" - // Token type for JWT-based authentication - jwtTokenType = "urn:ietf:params:oauth:token-type:jwt" + // Token type for AWS-based authentication + awsTokenType = "urn:ietf:params:aws:token-type:aws4_request" // Default scope for GCP Cloud Platform access gcpCloudPlatformScope = "https://www.googleapis.com/auth/cloud-platform" + // Default AWS region for STS operations + defaultAWSRegion = "us-east-1" ) type GoogleAuthProvider struct{} @@ -47,23 +53,51 @@ func (p *GoogleAuthProvider) FindDefaultCredentials(ctx context.Context) (*googl return google.FindDefaultCredentials(ctx) } -// FindCloudConnectorsCredentials creates GCP client options using OIDC/Web Identity token-based authentication -// with direct service account impersonation. The target Service Account must trust the OIDC provider. -func (p *GoogleAuthProvider) FindCloudConnectorsCredentials(ctx context.Context, audience string, serviceAccountEmail string) ([]option.ClientOption, error) { - jwtFilePath := os.Getenv(config.CloudConnectorsJWTPathEnvVar) - if jwtFilePath == "" { - return nil, fmt.Errorf("environment variable %s is required for cloud connectors credentials", config.CloudConnectorsJWTPathEnvVar) +// FindCloudConnectorsCredentials creates GCP client options using AWS Workload Identity Federation +// with direct service account impersonation. +// +// The authentication flow: +// 1. Reads JWT from file (ccConfig.JWTFilePath) +// 2. Assumes Elastic's AWS role using AssumeRoleWithWebIdentity +// 3. Uses AWS credentials for GCP Workload Identity Federation token exchange +// 4. Impersonates the target service account in the customer's GCP project +func (p *GoogleAuthProvider) FindCloudConnectorsCredentials(ctx context.Context, ccConfig config.CloudConnectorsConfig, audience string, serviceAccountEmail string) ([]option.ClientOption, error) { + // Validate required configuration + if ccConfig.JWTFilePath == "" { + return nil, errors.New("cloud connectors config JWTFilePath is required") } - cfg := externalaccount.Config{ - Audience: audience, - SubjectTokenType: jwtTokenType, - TokenURL: gcpSTSTokenURL, - Scopes: []string{gcpCloudPlatformScope}, - CredentialSource: &externalaccount.CredentialSource{ - File: jwtFilePath, - Format: externalaccount.Format{Type: "text"}, + if ccConfig.GlobalRoleARN == "" { + return nil, errors.New("cloud connectors config GlobalRoleARN is required") + } + + if ccConfig.ResourceID == "" { + return nil, errors.New("cloud connectors config ResourceID is required") + } + + // Create STS client and credentials cache at initialization (like role chaining) + stsClient := sts.New(sts.Options{Region: defaultAWSRegion}) + credsCache := awslib.NewWebIdentityCredentialsCache( + stsClient, + ccConfig.GlobalRoleARN, + ccConfig.JWTFilePath, + func(o *stscreds.WebIdentityRoleOptions) { + o.RoleSessionName = ccConfig.ResourceID }, + ) + + // Create the AWS credentials supplier with the pre-initialized cache + credSupplier := &awsCredentialsSupplier{ + region: defaultAWSRegion, + credsCache: credsCache, + } + + cfg := externalaccount.Config{ + Audience: audience, + SubjectTokenType: awsTokenType, + TokenURL: gcpSTSTokenURL, + Scopes: []string{gcpCloudPlatformScope}, + AwsSecurityCredentialsSupplier: credSupplier, ServiceAccountImpersonationURL: gcpIAMCredentialsURL + serviceAccountEmail + ":generateAccessToken", } @@ -74,3 +108,31 @@ func (p *GoogleAuthProvider) FindCloudConnectorsCredentials(ctx context.Context, return []option.ClientOption{option.WithTokenSource(tokenSource)}, nil } + +// awsCredentialsSupplier implements externalaccount.AwsSecurityCredentialsSupplier +// It provides cached AWS credentials to GCP for Workload Identity Federation. +// The credentials cache is initialized once and automatically refreshes when expired. +type awsCredentialsSupplier struct { + region string + credsCache *aws.CredentialsCache +} + +// AwsRegion returns the AWS region for the credentials. +func (s *awsCredentialsSupplier) AwsRegion(_ context.Context, _ externalaccount.SupplierOptions) (string, error) { + return s.region, nil +} + +// AwsSecurityCredentials retrieves cached AWS credentials for GCP WIF. +// The cache automatically refreshes credentials when they expire. +func (s *awsCredentialsSupplier) AwsSecurityCredentials(ctx context.Context, _ externalaccount.SupplierOptions) (*externalaccount.AwsSecurityCredentials, error) { + creds, err := s.credsCache.Retrieve(ctx) + if err != nil { + return nil, fmt.Errorf("failed to retrieve AWS credentials: %w", err) + } + + return &externalaccount.AwsSecurityCredentials{ + AccessKeyID: creds.AccessKeyID, + SecretAccessKey: creds.SecretAccessKey, + SessionToken: creds.SessionToken, + }, nil +} diff --git a/internal/resources/providers/gcplib/auth/credentials.go b/internal/resources/providers/gcplib/auth/credentials.go index 80f2e3fbe7..33ebd2885c 100644 --- a/internal/resources/providers/gcplib/auth/credentials.go +++ b/internal/resources/providers/gcplib/auth/credentials.go @@ -43,7 +43,7 @@ type ConfigProviderAPI interface { type GoogleAuthProviderAPI interface { FindDefaultCredentials(ctx context.Context) (*google.Credentials, error) - FindCloudConnectorsCredentials(ctx context.Context, audience string, serviceAccountEmail string) ([]option.ClientOption, error) + FindCloudConnectorsCredentials(ctx context.Context, ccConfig config.CloudConnectorsConfig, audience string, serviceAccountEmail string) ([]option.ClientOption, error) } type ConfigProvider struct { @@ -87,9 +87,9 @@ func (p *ConfigProvider) getApplicationDefaultCredentials(ctx context.Context, c } func (p *ConfigProvider) getCloudConnectorsCredentials(ctx context.Context, cfg config.GcpConfig, log *clog.Logger) (*GcpFactoryConfig, error) { - log.Info("creating credentials using OIDC token and service account impersonation", "provider", "GCP") + log.Info("creating credentials using AWS Workload Identity Federation and service account impersonation", "provider", "GCP") - opts, err := p.AuthProvider.FindCloudConnectorsCredentials(ctx, cfg.Audience, cfg.ServiceAccountEmail) + opts, err := p.AuthProvider.FindCloudConnectorsCredentials(ctx, cfg.CloudConnectorsConfig, cfg.Audience, cfg.ServiceAccountEmail) if err != nil { return nil, fmt.Errorf("failed to get cloud connectors credentials: %w", err) } diff --git a/internal/resources/providers/gcplib/auth/credentials_mock.go b/internal/resources/providers/gcplib/auth/credentials_mock.go index f1cdd03e0f..bb8585bf62 100644 --- a/internal/resources/providers/gcplib/auth/credentials_mock.go +++ b/internal/resources/providers/gcplib/auth/credentials_mock.go @@ -161,8 +161,8 @@ func (_m *MockGoogleAuthProviderAPI) EXPECT() *MockGoogleAuthProviderAPI_Expecte } // FindCloudConnectorsCredentials provides a mock function for the type MockGoogleAuthProviderAPI -func (_mock *MockGoogleAuthProviderAPI) FindCloudConnectorsCredentials(ctx context.Context, audience string, serviceAccountEmail string) ([]option.ClientOption, error) { - ret := _mock.Called(ctx, audience, serviceAccountEmail) +func (_mock *MockGoogleAuthProviderAPI) FindCloudConnectorsCredentials(ctx context.Context, ccConfig config.CloudConnectorsConfig, audience string, serviceAccountEmail string) ([]option.ClientOption, error) { + ret := _mock.Called(ctx, ccConfig, audience, serviceAccountEmail) if len(ret) == 0 { panic("no return value specified for FindCloudConnectorsCredentials") @@ -170,18 +170,18 @@ func (_mock *MockGoogleAuthProviderAPI) FindCloudConnectorsCredentials(ctx conte var r0 []option.ClientOption var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) ([]option.ClientOption, error)); ok { - return returnFunc(ctx, audience, serviceAccountEmail) + if returnFunc, ok := ret.Get(0).(func(context.Context, config.CloudConnectorsConfig, string, string) ([]option.ClientOption, error)); ok { + return returnFunc(ctx, ccConfig, audience, serviceAccountEmail) } - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) []option.ClientOption); ok { - r0 = returnFunc(ctx, audience, serviceAccountEmail) + if returnFunc, ok := ret.Get(0).(func(context.Context, config.CloudConnectorsConfig, string, string) []option.ClientOption); ok { + r0 = returnFunc(ctx, ccConfig, audience, serviceAccountEmail) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]option.ClientOption) } } - if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { - r1 = returnFunc(ctx, audience, serviceAccountEmail) + if returnFunc, ok := ret.Get(1).(func(context.Context, config.CloudConnectorsConfig, string, string) error); ok { + r1 = returnFunc(ctx, ccConfig, audience, serviceAccountEmail) } else { r1 = ret.Error(1) } @@ -195,30 +195,36 @@ type MockGoogleAuthProviderAPI_FindCloudConnectorsCredentials_Call struct { // FindCloudConnectorsCredentials is a helper method to define mock.On call // - ctx context.Context +// - ccConfig config.CloudConnectorsConfig // - audience string // - serviceAccountEmail string -func (_e *MockGoogleAuthProviderAPI_Expecter) FindCloudConnectorsCredentials(ctx interface{}, audience interface{}, serviceAccountEmail interface{}) *MockGoogleAuthProviderAPI_FindCloudConnectorsCredentials_Call { - return &MockGoogleAuthProviderAPI_FindCloudConnectorsCredentials_Call{Call: _e.mock.On("FindCloudConnectorsCredentials", ctx, audience, serviceAccountEmail)} +func (_e *MockGoogleAuthProviderAPI_Expecter) FindCloudConnectorsCredentials(ctx interface{}, ccConfig interface{}, audience interface{}, serviceAccountEmail interface{}) *MockGoogleAuthProviderAPI_FindCloudConnectorsCredentials_Call { + return &MockGoogleAuthProviderAPI_FindCloudConnectorsCredentials_Call{Call: _e.mock.On("FindCloudConnectorsCredentials", ctx, ccConfig, audience, serviceAccountEmail)} } -func (_c *MockGoogleAuthProviderAPI_FindCloudConnectorsCredentials_Call) Run(run func(ctx context.Context, audience string, serviceAccountEmail string)) *MockGoogleAuthProviderAPI_FindCloudConnectorsCredentials_Call { +func (_c *MockGoogleAuthProviderAPI_FindCloudConnectorsCredentials_Call) Run(run func(ctx context.Context, ccConfig config.CloudConnectorsConfig, audience string, serviceAccountEmail string)) *MockGoogleAuthProviderAPI_FindCloudConnectorsCredentials_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } - var arg1 string + var arg1 config.CloudConnectorsConfig if args[1] != nil { - arg1 = args[1].(string) + arg1 = args[1].(config.CloudConnectorsConfig) } var arg2 string if args[2] != nil { arg2 = args[2].(string) } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } run( arg0, arg1, arg2, + arg3, ) }) return _c @@ -229,7 +235,7 @@ func (_c *MockGoogleAuthProviderAPI_FindCloudConnectorsCredentials_Call) Return( return _c } -func (_c *MockGoogleAuthProviderAPI_FindCloudConnectorsCredentials_Call) RunAndReturn(run func(ctx context.Context, audience string, serviceAccountEmail string) ([]option.ClientOption, error)) *MockGoogleAuthProviderAPI_FindCloudConnectorsCredentials_Call { +func (_c *MockGoogleAuthProviderAPI_FindCloudConnectorsCredentials_Call) RunAndReturn(run func(ctx context.Context, ccConfig config.CloudConnectorsConfig, audience string, serviceAccountEmail string) ([]option.ClientOption, error)) *MockGoogleAuthProviderAPI_FindCloudConnectorsCredentials_Call { _c.Call.Return(run) return _c } diff --git a/internal/resources/providers/gcplib/auth/credentials_test.go b/internal/resources/providers/gcplib/auth/credentials_test.go index 084ff774a8..e6fcafa2e9 100644 --- a/internal/resources/providers/gcplib/auth/credentials_test.go +++ b/internal/resources/providers/gcplib/auth/credentials_test.go @@ -396,7 +396,7 @@ func mockGoogleAuthProvider(err error) *MockGoogleAuthProviderAPI { func mockGoogleAuthProviderWithCloudConnectors(err error) *MockGoogleAuthProviderAPI { googleProviderAPI := &MockGoogleAuthProviderAPI{} - on := googleProviderAPI.EXPECT().FindCloudConnectorsCredentials(mock.Anything, testAudience, testServiceAccountEmail) + on := googleProviderAPI.EXPECT().FindCloudConnectorsCredentials(mock.Anything, mock.Anything, testAudience, testServiceAccountEmail) if err == nil { on.Return( []option.ClientOption{option.WithTokenSource(nil)},