diff --git a/internal/flavors/assetinventory/strategy.go b/internal/flavors/assetinventory/strategy.go index 5743eddeea..f33412092f 100644 --- a/internal/flavors/assetinventory/strategy.go +++ b/internal/flavors/assetinventory/strategy.go @@ -100,7 +100,7 @@ func (s *strategy) initAzureFetchers(_ context.Context) ([]inventory.AssetFetche } func (s *strategy) initGcpFetchers(ctx context.Context) ([]inventory.AssetFetcher, error) { - cfgProvider := &gcp_auth.ConfigProvider{AuthProvider: &gcp_auth.GoogleAuthProvider{}} + cfgProvider := gcp_auth.NewConfigProvider() gcpConfig, err := cfgProvider.GetGcpClientConfig(ctx, s.cfg.CloudConfig.Gcp, s.logger) if err != nil { return nil, fmt.Errorf("failed to initialize gcp config: %w", err) diff --git a/internal/flavors/benchmark/strategy.go b/internal/flavors/benchmark/strategy.go index e1a7e8ad1d..b2d01307f0 100644 --- a/internal/flavors/benchmark/strategy.go +++ b/internal/flavors/benchmark/strategy.go @@ -72,7 +72,7 @@ func GetStrategy(cfg *config.Config, log *clog.Logger, statusHandler statushandl }, nil case config.CIS_GCP: return &GCP{ - CfgProvider: &gcp_auth.ConfigProvider{AuthProvider: &gcp_auth.GoogleAuthProvider{}}, + CfgProvider: gcp_auth.NewConfigProvider(), inventoryInitializer: &gcp_inventory.ProviderInitializer{}, }, nil case config.CIS_AZURE: diff --git a/internal/resources/providers/gcplib/auth/audience.go b/internal/resources/providers/gcplib/auth/audience.go new file mode 100644 index 0000000000..a88ee2fe89 --- /dev/null +++ b/internal/resources/providers/gcplib/auth/audience.go @@ -0,0 +1,40 @@ +// 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 auth + +import "strings" + +// projectNumberFromAudience extracts the GCP project number from a Workload Identity +// Federation audience URL. Format: //iam.googleapis.com/projects/PROJECT_NUMBER/locations/... +// Returns the project number and true if found, or empty string and false otherwise. +func projectNumberFromAudience(audience string) (string, bool) { + if audience == "" { + return "", false + } + parts := strings.Split(audience, "/") + for i := 0; i < len(parts)-1; i++ { + if parts[i] == "projects" { + num := strings.TrimSpace(parts[i+1]) + if num != "" { + return num, true + } + return "", false + } + } + return "", false +} diff --git a/internal/resources/providers/gcplib/auth/audience_test.go b/internal/resources/providers/gcplib/auth/audience_test.go new file mode 100644 index 0000000000..80aba8be7e --- /dev/null +++ b/internal/resources/providers/gcplib/auth/audience_test.go @@ -0,0 +1,89 @@ +// 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 auth + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestProjectNumberFromAudience(t *testing.T) { + tests := []struct { + name string + audience string + wantNum string + wantOK bool + }{ + { + name: "empty audience returns false", + audience: "", + wantNum: "", + wantOK: false, + }, + { + name: "Workload Identity Federation audience format", + audience: "//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/test-pool/providers/test-provider", + wantNum: "123456", + wantOK: true, + }, + { + name: "audience with no projects segment", + audience: "//iam.googleapis.com/locations/global/workloadIdentityPools/test-pool", + wantNum: "", + wantOK: false, + }, + { + name: "projects segment with empty next segment", + audience: "//iam.googleapis.com/projects//locations/global", + wantNum: "", + wantOK: false, + }, + { + name: "project number with surrounding whitespace is trimmed", + audience: "prefix/projects/ 987654 /locations/global", + wantNum: "987654", + wantOK: true, + }, + { + name: "returns first project number when multiple projects segments", + audience: "projects/111/locations/global/projects/222/locations/eu", + wantNum: "111", + wantOK: true, + }, + { + name: "projects at end without number", + audience: "//iam.googleapis.com/some/path/projects", + wantNum: "", + wantOK: false, + }, + { + name: "single segment projects", + audience: "projects/42", + wantNum: "42", + wantOK: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotNum, gotOK := projectNumberFromAudience(tt.audience) + assert.Equal(t, tt.wantNum, gotNum) + assert.Equal(t, tt.wantOK, gotOK) + }) + } +} diff --git a/internal/resources/providers/gcplib/auth/credentials.go b/internal/resources/providers/gcplib/auth/credentials.go index 33ebd2885c..4648628f48 100644 --- a/internal/resources/providers/gcplib/auth/credentials.go +++ b/internal/resources/providers/gcplib/auth/credentials.go @@ -46,8 +46,31 @@ type GoogleAuthProviderAPI interface { FindCloudConnectorsCredentials(ctx context.Context, ccConfig config.CloudConnectorsConfig, audience string, serviceAccountEmail string) ([]option.ClientOption, error) } +// DefaultCredentialsFinder is the minimal interface needed to resolve project ID from +// application default credentials (e.g. metadata server on GCP). *GoogleAuthProvider implements it. +type DefaultCredentialsFinder interface { + FindDefaultCredentials(ctx context.Context) (*google.Credentials, error) +} + +// ParentResolver returns the GCP parent (e.g. "projects/pid" or "organizations/oid") +// for the given config and client options. +type ParentResolver interface { + GetParent(ctx context.Context, cfg config.GcpConfig, clientOpts []option.ClientOption) (string, error) +} + type ConfigProvider struct { - AuthProvider GoogleAuthProviderAPI + AuthProvider GoogleAuthProviderAPI + ParentResolver ParentResolver // required; use DefaultParentResolver in production +} + +// NewConfigProvider returns a ConfigProvider wired with the default auth provider +// and default parent resolver (project + organization). Use this in production. +func NewConfigProvider() *ConfigProvider { + auth := &GoogleAuthProvider{} + return &ConfigProvider{ + AuthProvider: auth, + ParentResolver: NewDefaultParentResolver(auth), + } } var ErrMissingOrgId = errors.New("organization ID is required for organization account type") @@ -70,7 +93,7 @@ func (p *ConfigProvider) GetGcpClientConfig(ctx context.Context, cfg config.GcpC } func (p *ConfigProvider) getGcpFactoryConfig(ctx context.Context, cfg config.GcpConfig, clientOpts []option.ClientOption) (*GcpFactoryConfig, error) { - parent, err := getGcpConfigParentValue(ctx, *p, cfg) + parent, err := p.ParentResolver.GetParent(ctx, cfg, clientOpts) if err != nil { return nil, err } @@ -118,19 +141,6 @@ func (p *ConfigProvider) getCustomCredentials(ctx context.Context, cfg config.Gc return p.getGcpFactoryConfig(ctx, cfg, opts) } -func (p *ConfigProvider) getProjectId(ctx context.Context, cfg config.GcpConfig) (string, error) { - if cfg.ProjectId != "" { - return cfg.ProjectId, nil - } - - // Try to get project ID from metadata server in case we are running on GCP VM - cred, err := p.AuthProvider.FindDefaultCredentials(ctx) - if err == nil { - return cred.ProjectID, nil - } - - return "", ErrProjectNotFound -} func validateJSONFromFile(filePath string) error { if _, err := os.Stat(filePath); errors.Is(err, os.ErrNotExist) { @@ -148,21 +158,3 @@ func validateJSONFromFile(filePath string) error { return nil } - -func getGcpConfigParentValue(ctx context.Context, provider ConfigProvider, cfg config.GcpConfig) (string, error) { - switch cfg.AccountType { - case config.OrganizationAccount: - if cfg.OrganizationId == "" { - return "", ErrMissingOrgId - } - return fmt.Sprintf("organizations/%s", cfg.OrganizationId), nil - case config.SingleAccount: - projectId, err := provider.getProjectId(ctx, cfg) - if err != nil { - return "", fmt.Errorf("failed to get project ID: %v", err) - } - return fmt.Sprintf("projects/%s", projectId), nil - default: - return "", fmt.Errorf("invalid gcp account type: %s", cfg.AccountType) - } -} diff --git a/internal/resources/providers/gcplib/auth/credentials_mock.go b/internal/resources/providers/gcplib/auth/credentials_mock.go index bb8585bf62..a273ff1416 100644 --- a/internal/resources/providers/gcplib/auth/credentials_mock.go +++ b/internal/resources/providers/gcplib/auth/credentials_mock.go @@ -301,3 +301,191 @@ func (_c *MockGoogleAuthProviderAPI_FindDefaultCredentials_Call) RunAndReturn(ru _c.Call.Return(run) return _c } + +// NewMockDefaultCredentialsFinder creates a new instance of MockDefaultCredentialsFinder. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockDefaultCredentialsFinder(t interface { + mock.TestingT + Cleanup(func()) +}) *MockDefaultCredentialsFinder { + mock := &MockDefaultCredentialsFinder{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockDefaultCredentialsFinder is an autogenerated mock type for the DefaultCredentialsFinder type +type MockDefaultCredentialsFinder struct { + mock.Mock +} + +type MockDefaultCredentialsFinder_Expecter struct { + mock *mock.Mock +} + +func (_m *MockDefaultCredentialsFinder) EXPECT() *MockDefaultCredentialsFinder_Expecter { + return &MockDefaultCredentialsFinder_Expecter{mock: &_m.Mock} +} + +// FindDefaultCredentials provides a mock function for the type MockDefaultCredentialsFinder +func (_mock *MockDefaultCredentialsFinder) FindDefaultCredentials(ctx context.Context) (*google.Credentials, error) { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for FindDefaultCredentials") + } + + var r0 *google.Credentials + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context) (*google.Credentials, error)); ok { + return returnFunc(ctx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context) *google.Credentials); ok { + r0 = returnFunc(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*google.Credentials) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = returnFunc(ctx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockDefaultCredentialsFinder_FindDefaultCredentials_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindDefaultCredentials' +type MockDefaultCredentialsFinder_FindDefaultCredentials_Call struct { + *mock.Call +} + +// FindDefaultCredentials is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockDefaultCredentialsFinder_Expecter) FindDefaultCredentials(ctx interface{}) *MockDefaultCredentialsFinder_FindDefaultCredentials_Call { + return &MockDefaultCredentialsFinder_FindDefaultCredentials_Call{Call: _e.mock.On("FindDefaultCredentials", ctx)} +} + +func (_c *MockDefaultCredentialsFinder_FindDefaultCredentials_Call) Run(run func(ctx context.Context)) *MockDefaultCredentialsFinder_FindDefaultCredentials_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockDefaultCredentialsFinder_FindDefaultCredentials_Call) Return(credentials *google.Credentials, err error) *MockDefaultCredentialsFinder_FindDefaultCredentials_Call { + _c.Call.Return(credentials, err) + return _c +} + +func (_c *MockDefaultCredentialsFinder_FindDefaultCredentials_Call) RunAndReturn(run func(ctx context.Context) (*google.Credentials, error)) *MockDefaultCredentialsFinder_FindDefaultCredentials_Call { + _c.Call.Return(run) + return _c +} + +// NewMockParentResolver creates a new instance of MockParentResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockParentResolver(t interface { + mock.TestingT + Cleanup(func()) +}) *MockParentResolver { + mock := &MockParentResolver{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockParentResolver is an autogenerated mock type for the ParentResolver type +type MockParentResolver struct { + mock.Mock +} + +type MockParentResolver_Expecter struct { + mock *mock.Mock +} + +func (_m *MockParentResolver) EXPECT() *MockParentResolver_Expecter { + return &MockParentResolver_Expecter{mock: &_m.Mock} +} + +// GetParent provides a mock function for the type MockParentResolver +func (_mock *MockParentResolver) GetParent(ctx context.Context, cfg config.GcpConfig, clientOpts []option.ClientOption) (string, error) { + ret := _mock.Called(ctx, cfg, clientOpts) + + if len(ret) == 0 { + panic("no return value specified for GetParent") + } + + var r0 string + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, config.GcpConfig, []option.ClientOption) (string, error)); ok { + return returnFunc(ctx, cfg, clientOpts) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, config.GcpConfig, []option.ClientOption) string); ok { + r0 = returnFunc(ctx, cfg, clientOpts) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, config.GcpConfig, []option.ClientOption) error); ok { + r1 = returnFunc(ctx, cfg, clientOpts) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockParentResolver_GetParent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetParent' +type MockParentResolver_GetParent_Call struct { + *mock.Call +} + +// GetParent is a helper method to define mock.On call +// - ctx context.Context +// - cfg config.GcpConfig +// - clientOpts []option.ClientOption +func (_e *MockParentResolver_Expecter) GetParent(ctx interface{}, cfg interface{}, clientOpts interface{}) *MockParentResolver_GetParent_Call { + return &MockParentResolver_GetParent_Call{Call: _e.mock.On("GetParent", ctx, cfg, clientOpts)} +} + +func (_c *MockParentResolver_GetParent_Call) Run(run func(ctx context.Context, cfg config.GcpConfig, clientOpts []option.ClientOption)) *MockParentResolver_GetParent_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 config.GcpConfig + if args[1] != nil { + arg1 = args[1].(config.GcpConfig) + } + var arg2 []option.ClientOption + if args[2] != nil { + arg2 = args[2].([]option.ClientOption) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockParentResolver_GetParent_Call) Return(s string, err error) *MockParentResolver_GetParent_Call { + _c.Call.Return(s, err) + return _c +} + +func (_c *MockParentResolver_GetParent_Call) RunAndReturn(run func(ctx context.Context, cfg config.GcpConfig, clientOpts []option.ClientOption) (string, error)) *MockParentResolver_GetParent_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 e6fcafa2e9..5196a33e7c 100644 --- a/internal/resources/providers/gcplib/auth/credentials_test.go +++ b/internal/resources/providers/gcplib/auth/credentials_test.go @@ -345,7 +345,8 @@ func TestGetGcpClientConfig(t *testing.T) { } for _, tt := range tests { p := ConfigProvider{ - AuthProvider: tt.authProvider, + AuthProvider: tt.authProvider, + ParentResolver: NewDefaultParentResolver(tt.authProvider), } t.Run(tt.name, func(t *testing.T) { for idx, cfg := range tt.cfg { diff --git a/internal/resources/providers/gcplib/auth/organization_parent_resolver.go b/internal/resources/providers/gcplib/auth/organization_parent_resolver.go new file mode 100644 index 0000000000..b1be5f00b0 --- /dev/null +++ b/internal/resources/providers/gcplib/auth/organization_parent_resolver.go @@ -0,0 +1,88 @@ +// 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 auth + +import ( + "context" + "fmt" + + crmv1 "google.golang.org/api/cloudresourcemanager/v1" + "google.golang.org/api/cloudresourcemanager/v3" + "google.golang.org/api/option" + + "github.com/elastic/cloudbeat/internal/config" +) + +// defaultOrganizationParentResolver resolves organization parent from config or audience. +type defaultOrganizationParentResolver struct{} + +// NewDefaultOrganizationParentResolver returns an OrganizationParentResolver that uses +// the Resource Manager API to resolve org from audience when needed. +func NewDefaultOrganizationParentResolver() OrganizationParentResolver { + return &defaultOrganizationParentResolver{} +} + +func (r *defaultOrganizationParentResolver) GetOrganizationParent(ctx context.Context, cfg config.GcpConfig, clientOpts []option.ClientOption) (string, error) { + if cfg.OrganizationId != "" { + return fmt.Sprintf("organizations/%s", cfg.OrganizationId), nil + } + if len(clientOpts) > 0 && cfg.Audience != "" { + orgId, err := resolveOrganizationIdFromAudience(ctx, clientOpts, cfg.Audience) + if err != nil { + return "", fmt.Errorf("failed to resolve organization ID: %w", err) + } + return fmt.Sprintf("organizations/%s", orgId), nil + } + return "", ErrMissingOrgId +} + +const resourceIdTypeOrganization = "organization" + +// resolveOrganizationIdFromAudience uses the project number in the audience to get +// the project via v3, then calls v1 getAncestry to resolve the organization in one call. +// clientOpts must authenticate with resourcemanager.projects.get. +func resolveOrganizationIdFromAudience(ctx context.Context, clientOpts []option.ClientOption, audience string) (string, error) { + projectNumber, ok := projectNumberFromAudience(audience) + if !ok { + return "", fmt.Errorf("audience does not contain a valid project number: %w", ErrMissingOrgId) + } + opts := append([]option.ClientOption{option.WithScopes(cloudresourcemanager.CloudPlatformReadOnlyScope)}, clientOpts...) + svcV3, err := cloudresourcemanager.NewService(ctx, opts...) + if err != nil { + return "", fmt.Errorf("failed to create Resource Manager client: %w", err) + } + project, err := svcV3.Projects.Get("projects/" + projectNumber).Context(ctx).Do() + if err != nil { + return "", fmt.Errorf("failed to get project (number %s): %w", projectNumber, err) + } + optsV1 := append([]option.ClientOption{option.WithScopes(crmv1.CloudPlatformReadOnlyScope)}, clientOpts...) + svcV1, err := crmv1.NewService(ctx, optsV1...) + if err != nil { + return "", fmt.Errorf("failed to create Resource Manager v1 client: %w", err) + } + resp, err := svcV1.Projects.GetAncestry(project.ProjectId, &crmv1.GetAncestryRequest{}).Context(ctx).Do() + if err != nil { + return "", fmt.Errorf("failed to get project ancestry: %w", err) + } + for _, anc := range resp.Ancestor { + if anc.ResourceId != nil && anc.ResourceId.Type == resourceIdTypeOrganization && anc.ResourceId.Id != "" { + return anc.ResourceId.Id, nil + } + } + return "", fmt.Errorf("no organization found in resource hierarchy: %w", ErrMissingOrgId) +} diff --git a/internal/resources/providers/gcplib/auth/organization_parent_resolver_test.go b/internal/resources/providers/gcplib/auth/organization_parent_resolver_test.go new file mode 100644 index 0000000000..9156977840 --- /dev/null +++ b/internal/resources/providers/gcplib/auth/organization_parent_resolver_test.go @@ -0,0 +1,86 @@ +// 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 auth + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/api/option" + + "github.com/elastic/cloudbeat/internal/config" +) + +func TestNewDefaultOrganizationParentResolver(t *testing.T) { + resolver := NewDefaultOrganizationParentResolver() + require.NotNil(t, resolver) +} + +func TestDefaultOrganizationParentResolver_GetOrganizationParent(t *testing.T) { + ctx := context.Background() + + t.Run("returns organization parent from config when OrganizationId is set", func(t *testing.T) { + resolver := NewDefaultOrganizationParentResolver() + cfg := config.GcpConfig{OrganizationId: "123456789"} + + parent, err := resolver.GetOrganizationParent(ctx, cfg, nil) + require.NoError(t, err) + assert.Equal(t, "organizations/123456789", parent) + }) + + t.Run("returns ErrMissingOrgId when OrganizationId is empty and no clientOpts", func(t *testing.T) { + resolver := NewDefaultOrganizationParentResolver() + cfg := config.GcpConfig{OrganizationId: ""} + + parent, err := resolver.GetOrganizationParent(ctx, cfg, nil) + require.Error(t, err) + assert.ErrorIs(t, err, ErrMissingOrgId) + assert.Empty(t, parent) + }) + + t.Run("returns ErrMissingOrgId when OrganizationId is empty and no audience", func(t *testing.T) { + resolver := NewDefaultOrganizationParentResolver() + cfg := config.GcpConfig{ + OrganizationId: "", + GcpClientOpt: config.GcpClientOpt{Audience: ""}, + } + clientOpts := []option.ClientOption{option.WithRequestReason("test")} + + parent, err := resolver.GetOrganizationParent(ctx, cfg, clientOpts) + require.Error(t, err) + assert.ErrorIs(t, err, ErrMissingOrgId) + assert.Empty(t, parent) + }) + + t.Run("returns error when clientOpts and audience set but audience has no valid project number", func(t *testing.T) { + resolver := NewDefaultOrganizationParentResolver() + cfg := config.GcpConfig{ + OrganizationId: "", + GcpClientOpt: config.GcpClientOpt{Audience: "//iam.googleapis.com/locations/global/not-a-valid-audience"}, + } + clientOpts := []option.ClientOption{option.WithRequestReason("test")} + + parent, err := resolver.GetOrganizationParent(ctx, cfg, clientOpts) + require.Error(t, err) + assert.ErrorIs(t, err, ErrMissingOrgId) + assert.ErrorContains(t, err, "audience does not contain a valid project number") + assert.Empty(t, parent) + }) +} diff --git a/internal/resources/providers/gcplib/auth/parent_resolver.go b/internal/resources/providers/gcplib/auth/parent_resolver.go new file mode 100644 index 0000000000..1cfcbc4fc2 --- /dev/null +++ b/internal/resources/providers/gcplib/auth/parent_resolver.go @@ -0,0 +1,67 @@ +// 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 auth + +import ( + "context" + "fmt" + + "google.golang.org/api/option" + + "github.com/elastic/cloudbeat/internal/config" +) + +// ProjectParentResolver resolves the GCP project parent string (e.g. "projects/project-id"). +// Defined here (consumer) per Go best practice: interfaces belong in the package that uses them. +type ProjectParentResolver interface { + GetProjectParent(ctx context.Context, cfg config.GcpConfig, clientOpts []option.ClientOption) (string, error) +} + +// OrganizationParentResolver resolves the GCP organization parent string (e.g. "organizations/org-id"). +// Defined here (consumer) per Go best practice: interfaces belong in the package that uses them. +type OrganizationParentResolver interface { + GetOrganizationParent(ctx context.Context, cfg config.GcpConfig, clientOpts []option.ClientOption) (string, error) +} + +// defaultParentResolver is a facade that delegates to ProjectParentResolver or +// OrganizationParentResolver based on cfg.AccountType. +type defaultParentResolver struct { + project ProjectParentResolver + org OrganizationParentResolver +} + +// NewDefaultParentResolver returns a ParentResolver that delegates to the default +// project and organization resolvers. The auth finder is used by the project resolver +// for the ADC fallback when project_id is not in config or audience. +func NewDefaultParentResolver(auth DefaultCredentialsFinder) ParentResolver { + return &defaultParentResolver{ + project: NewDefaultProjectParentResolver(auth), + org: NewDefaultOrganizationParentResolver(), + } +} + +func (r *defaultParentResolver) GetParent(ctx context.Context, cfg config.GcpConfig, clientOpts []option.ClientOption) (string, error) { + switch cfg.AccountType { + case config.OrganizationAccount: + return r.org.GetOrganizationParent(ctx, cfg, clientOpts) + case config.SingleAccount: + return r.project.GetProjectParent(ctx, cfg, clientOpts) + default: + return "", fmt.Errorf("invalid gcp account type: %s", cfg.AccountType) + } +} diff --git a/internal/resources/providers/gcplib/auth/parent_resolver_mock.go b/internal/resources/providers/gcplib/auth/parent_resolver_mock.go new file mode 100644 index 0000000000..329881359c --- /dev/null +++ b/internal/resources/providers/gcplib/auth/parent_resolver_mock.go @@ -0,0 +1,229 @@ +// 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. +//go:build !release + +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package auth + +import ( + "context" + + "github.com/elastic/cloudbeat/internal/config" + mock "github.com/stretchr/testify/mock" + "google.golang.org/api/option" +) + +// NewMockProjectParentResolver creates a new instance of MockProjectParentResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockProjectParentResolver(t interface { + mock.TestingT + Cleanup(func()) +}) *MockProjectParentResolver { + mock := &MockProjectParentResolver{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockProjectParentResolver is an autogenerated mock type for the ProjectParentResolver type +type MockProjectParentResolver struct { + mock.Mock +} + +type MockProjectParentResolver_Expecter struct { + mock *mock.Mock +} + +func (_m *MockProjectParentResolver) EXPECT() *MockProjectParentResolver_Expecter { + return &MockProjectParentResolver_Expecter{mock: &_m.Mock} +} + +// GetProjectParent provides a mock function for the type MockProjectParentResolver +func (_mock *MockProjectParentResolver) GetProjectParent(ctx context.Context, cfg config.GcpConfig, clientOpts []option.ClientOption) (string, error) { + ret := _mock.Called(ctx, cfg, clientOpts) + + if len(ret) == 0 { + panic("no return value specified for GetProjectParent") + } + + var r0 string + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, config.GcpConfig, []option.ClientOption) (string, error)); ok { + return returnFunc(ctx, cfg, clientOpts) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, config.GcpConfig, []option.ClientOption) string); ok { + r0 = returnFunc(ctx, cfg, clientOpts) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, config.GcpConfig, []option.ClientOption) error); ok { + r1 = returnFunc(ctx, cfg, clientOpts) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockProjectParentResolver_GetProjectParent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetProjectParent' +type MockProjectParentResolver_GetProjectParent_Call struct { + *mock.Call +} + +// GetProjectParent is a helper method to define mock.On call +// - ctx context.Context +// - cfg config.GcpConfig +// - clientOpts []option.ClientOption +func (_e *MockProjectParentResolver_Expecter) GetProjectParent(ctx interface{}, cfg interface{}, clientOpts interface{}) *MockProjectParentResolver_GetProjectParent_Call { + return &MockProjectParentResolver_GetProjectParent_Call{Call: _e.mock.On("GetProjectParent", ctx, cfg, clientOpts)} +} + +func (_c *MockProjectParentResolver_GetProjectParent_Call) Run(run func(ctx context.Context, cfg config.GcpConfig, clientOpts []option.ClientOption)) *MockProjectParentResolver_GetProjectParent_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 config.GcpConfig + if args[1] != nil { + arg1 = args[1].(config.GcpConfig) + } + var arg2 []option.ClientOption + if args[2] != nil { + arg2 = args[2].([]option.ClientOption) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockProjectParentResolver_GetProjectParent_Call) Return(s string, err error) *MockProjectParentResolver_GetProjectParent_Call { + _c.Call.Return(s, err) + return _c +} + +func (_c *MockProjectParentResolver_GetProjectParent_Call) RunAndReturn(run func(ctx context.Context, cfg config.GcpConfig, clientOpts []option.ClientOption) (string, error)) *MockProjectParentResolver_GetProjectParent_Call { + _c.Call.Return(run) + return _c +} + +// NewMockOrganizationParentResolver creates a new instance of MockOrganizationParentResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockOrganizationParentResolver(t interface { + mock.TestingT + Cleanup(func()) +}) *MockOrganizationParentResolver { + mock := &MockOrganizationParentResolver{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockOrganizationParentResolver is an autogenerated mock type for the OrganizationParentResolver type +type MockOrganizationParentResolver struct { + mock.Mock +} + +type MockOrganizationParentResolver_Expecter struct { + mock *mock.Mock +} + +func (_m *MockOrganizationParentResolver) EXPECT() *MockOrganizationParentResolver_Expecter { + return &MockOrganizationParentResolver_Expecter{mock: &_m.Mock} +} + +// GetOrganizationParent provides a mock function for the type MockOrganizationParentResolver +func (_mock *MockOrganizationParentResolver) GetOrganizationParent(ctx context.Context, cfg config.GcpConfig, clientOpts []option.ClientOption) (string, error) { + ret := _mock.Called(ctx, cfg, clientOpts) + + if len(ret) == 0 { + panic("no return value specified for GetOrganizationParent") + } + + var r0 string + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, config.GcpConfig, []option.ClientOption) (string, error)); ok { + return returnFunc(ctx, cfg, clientOpts) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, config.GcpConfig, []option.ClientOption) string); ok { + r0 = returnFunc(ctx, cfg, clientOpts) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, config.GcpConfig, []option.ClientOption) error); ok { + r1 = returnFunc(ctx, cfg, clientOpts) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockOrganizationParentResolver_GetOrganizationParent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetOrganizationParent' +type MockOrganizationParentResolver_GetOrganizationParent_Call struct { + *mock.Call +} + +// GetOrganizationParent is a helper method to define mock.On call +// - ctx context.Context +// - cfg config.GcpConfig +// - clientOpts []option.ClientOption +func (_e *MockOrganizationParentResolver_Expecter) GetOrganizationParent(ctx interface{}, cfg interface{}, clientOpts interface{}) *MockOrganizationParentResolver_GetOrganizationParent_Call { + return &MockOrganizationParentResolver_GetOrganizationParent_Call{Call: _e.mock.On("GetOrganizationParent", ctx, cfg, clientOpts)} +} + +func (_c *MockOrganizationParentResolver_GetOrganizationParent_Call) Run(run func(ctx context.Context, cfg config.GcpConfig, clientOpts []option.ClientOption)) *MockOrganizationParentResolver_GetOrganizationParent_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 config.GcpConfig + if args[1] != nil { + arg1 = args[1].(config.GcpConfig) + } + var arg2 []option.ClientOption + if args[2] != nil { + arg2 = args[2].([]option.ClientOption) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockOrganizationParentResolver_GetOrganizationParent_Call) Return(s string, err error) *MockOrganizationParentResolver_GetOrganizationParent_Call { + _c.Call.Return(s, err) + return _c +} + +func (_c *MockOrganizationParentResolver_GetOrganizationParent_Call) RunAndReturn(run func(ctx context.Context, cfg config.GcpConfig, clientOpts []option.ClientOption) (string, error)) *MockOrganizationParentResolver_GetOrganizationParent_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/resources/providers/gcplib/auth/parent_resolver_test.go b/internal/resources/providers/gcplib/auth/parent_resolver_test.go new file mode 100644 index 0000000000..68ece9b76d --- /dev/null +++ b/internal/resources/providers/gcplib/auth/parent_resolver_test.go @@ -0,0 +1,114 @@ +// 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 auth + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/elastic/cloudbeat/internal/config" +) + +func TestDefaultParentResolver_GetParent(t *testing.T) { + ctx := context.Background() + cfgSingle := config.GcpConfig{AccountType: config.SingleAccount, ProjectId: "my-project"} + cfgOrg := config.GcpConfig{AccountType: config.OrganizationAccount, OrganizationId: "my-org"} + + t.Run("SingleAccount delegates to project resolver", func(t *testing.T) { + mockProject := NewMockProjectParentResolver(t) + mockOrg := NewMockOrganizationParentResolver(t) + + mockProject.EXPECT(). + GetProjectParent(ctx, cfgSingle, mock.AnythingOfType("[]option.ClientOption")). + Return("projects/my-project", nil) + + resolver := &defaultParentResolver{project: mockProject, org: mockOrg} + parent, err := resolver.GetParent(ctx, cfgSingle, nil) + require.NoError(t, err) + assert.Equal(t, "projects/my-project", parent) + }) + + t.Run("OrganizationAccount delegates to organization resolver", func(t *testing.T) { + mockProject := NewMockProjectParentResolver(t) + mockOrg := NewMockOrganizationParentResolver(t) + + mockOrg.EXPECT(). + GetOrganizationParent(ctx, cfgOrg, mock.AnythingOfType("[]option.ClientOption")). + Return("organizations/my-org", nil) + + resolver := &defaultParentResolver{project: mockProject, org: mockOrg} + parent, err := resolver.GetParent(ctx, cfgOrg, nil) + require.NoError(t, err) + assert.Equal(t, "organizations/my-org", parent) + }) + + t.Run("SingleAccount returns project resolver error", func(t *testing.T) { + mockProject := NewMockProjectParentResolver(t) + mockOrg := NewMockOrganizationParentResolver(t) + wantErr := errors.New("project resolution failed") + + mockProject.EXPECT(). + GetProjectParent(ctx, cfgSingle, mock.AnythingOfType("[]option.ClientOption")). + Return("", wantErr) + + resolver := &defaultParentResolver{project: mockProject, org: mockOrg} + parent, err := resolver.GetParent(ctx, cfgSingle, nil) + require.ErrorIs(t, err, wantErr) + assert.Empty(t, parent) + }) + + t.Run("OrganizationAccount returns organization resolver error", func(t *testing.T) { + mockProject := NewMockProjectParentResolver(t) + mockOrg := NewMockOrganizationParentResolver(t) + wantErr := errors.New("org resolution failed") + + mockOrg.EXPECT(). + GetOrganizationParent(ctx, cfgOrg, mock.AnythingOfType("[]option.ClientOption")). + Return("", wantErr) + + resolver := &defaultParentResolver{project: mockProject, org: mockOrg} + parent, err := resolver.GetParent(ctx, cfgOrg, nil) + require.ErrorIs(t, err, wantErr) + assert.Empty(t, parent) + }) + + t.Run("invalid account type returns error", func(t *testing.T) { + mockProject := NewMockProjectParentResolver(t) + mockOrg := NewMockOrganizationParentResolver(t) + + resolver := &defaultParentResolver{project: mockProject, org: mockOrg} + cfgInvalid := config.GcpConfig{AccountType: "invalid"} + parent, err := resolver.GetParent(ctx, cfgInvalid, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid gcp account type") + assert.Empty(t, parent) + }) +} + +func TestNewDefaultParentResolver(t *testing.T) { + // Smoke test: constructor returns a non-nil resolver that can be called. + // Full behavior is tested via GetParent with mocks and via credentials tests. + mockAuth := NewMockDefaultCredentialsFinder(t) + resolver := NewDefaultParentResolver(mockAuth) + require.NotNil(t, resolver) +} diff --git a/internal/resources/providers/gcplib/auth/project_parent_resolver.go b/internal/resources/providers/gcplib/auth/project_parent_resolver.go new file mode 100644 index 0000000000..b50bf1003a --- /dev/null +++ b/internal/resources/providers/gcplib/auth/project_parent_resolver.go @@ -0,0 +1,83 @@ +// 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 auth + +import ( + "context" + "fmt" + + "google.golang.org/api/cloudresourcemanager/v3" + "google.golang.org/api/option" + + "github.com/elastic/cloudbeat/internal/config" +) + +// defaultProjectParentResolver resolves project parent from config, audience, or ADC. +type defaultProjectParentResolver struct { + auth DefaultCredentialsFinder +} + +// NewDefaultProjectParentResolver returns a ProjectParentResolver that uses the given +// credentials finder for the ADC fallback when project_id is not in config or audience. +func NewDefaultProjectParentResolver(auth DefaultCredentialsFinder) ProjectParentResolver { + return &defaultProjectParentResolver{auth: auth} +} + +func (r *defaultProjectParentResolver) GetProjectParent(ctx context.Context, cfg config.GcpConfig, clientOpts []option.ClientOption) (string, error) { + if cfg.ProjectId != "" { + return fmt.Sprintf("projects/%s", cfg.ProjectId), nil + } + if len(clientOpts) > 0 && cfg.Audience != "" { + projectId, err := resolveProjectIdFromAudience(ctx, clientOpts, cfg.Audience) + if err != nil { + return "", fmt.Errorf("failed to get project ID: %w", err) + } + return fmt.Sprintf("projects/%s", projectId), nil + } + cred, err := r.auth.FindDefaultCredentials(ctx) + if err != nil { + return "", fmt.Errorf("failed to get project ID: %w", err) + } + if cred.ProjectID == "" { + return "", ErrProjectNotFound + } + return fmt.Sprintf("projects/%s", cred.ProjectID), nil +} + +// resolveProjectIdFromAudience uses the Cloud Resource Manager v3 API to resolve +// the project ID (user-assigned string) from the project number in the audience. +// clientOpts must authenticate as an identity with resourcemanager.projects.get. +func resolveProjectIdFromAudience(ctx context.Context, clientOpts []option.ClientOption, audience string) (string, error) { + projectNumber, ok := projectNumberFromAudience(audience) + if !ok { + return "", fmt.Errorf("audience does not contain a valid project number (expected format: //iam.googleapis.com/projects/PROJECT_NUMBER/...): %w", ErrProjectNotFound) + } + opts := append([]option.ClientOption{option.WithScopes(cloudresourcemanager.CloudPlatformReadOnlyScope)}, clientOpts...) + svc, err := cloudresourcemanager.NewService(ctx, opts...) + if err != nil { + return "", fmt.Errorf("failed to create Resource Manager client: %w", err) + } + project, err := svc.Projects.Get("projects/" + projectNumber).Context(ctx).Do() + if err != nil { + return "", fmt.Errorf("failed to get project (number %s): %w", projectNumber, err) + } + if project.ProjectId == "" { + return "", fmt.Errorf("project response missing project_id: %w", ErrProjectNotFound) + } + return project.ProjectId, nil +} diff --git a/internal/resources/providers/gcplib/auth/project_parent_resolver_test.go b/internal/resources/providers/gcplib/auth/project_parent_resolver_test.go new file mode 100644 index 0000000000..5fccbc97c7 --- /dev/null +++ b/internal/resources/providers/gcplib/auth/project_parent_resolver_test.go @@ -0,0 +1,134 @@ +// 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 auth + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2/google" + "google.golang.org/api/option" + + "github.com/elastic/cloudbeat/internal/config" +) + +func TestNewDefaultProjectParentResolver(t *testing.T) { + mockAuth := NewMockDefaultCredentialsFinder(t) + resolver := NewDefaultProjectParentResolver(mockAuth) + require.NotNil(t, resolver) +} + +func TestDefaultProjectParentResolver_GetProjectParent(t *testing.T) { + ctx := context.Background() + + t.Run("returns project parent from config when ProjectId is set", func(t *testing.T) { + mockAuth := NewMockDefaultCredentialsFinder(t) + resolver := NewDefaultProjectParentResolver(mockAuth) + cfg := config.GcpConfig{ProjectId: "my-project-id"} + + parent, err := resolver.GetProjectParent(ctx, cfg, nil) + require.NoError(t, err) + assert.Equal(t, "projects/my-project-id", parent) + // FindDefaultCredentials must not be called when ProjectId is in config + mockAuth.AssertNotCalled(t, "FindDefaultCredentials") + }) + + t.Run("returns project parent from default credentials when ProjectId and audience path not used", func(t *testing.T) { + mockAuth := NewMockDefaultCredentialsFinder(t) + resolver := NewDefaultProjectParentResolver(mockAuth) + cfg := config.GcpConfig{ProjectId: ""} + + mockAuth.EXPECT(). + FindDefaultCredentials(ctx). + Return(&google.Credentials{ProjectID: "adc-project"}, nil) + + parent, err := resolver.GetProjectParent(ctx, cfg, nil) + require.NoError(t, err) + assert.Equal(t, "projects/adc-project", parent) + }) + + t.Run("returns error when FindDefaultCredentials fails", func(t *testing.T) { + mockAuth := NewMockDefaultCredentialsFinder(t) + resolver := NewDefaultProjectParentResolver(mockAuth) + cfg := config.GcpConfig{ProjectId: ""} + wantErr := errors.New("credentials not found") + + mockAuth.EXPECT(). + FindDefaultCredentials(ctx). + Return(nil, wantErr) + + parent, err := resolver.GetProjectParent(ctx, cfg, nil) + require.Error(t, err) + assert.ErrorContains(t, err, "failed to get project ID") + assert.ErrorIs(t, err, wantErr) + assert.Empty(t, parent) + }) + + t.Run("returns ErrProjectNotFound when default credentials have empty ProjectID", func(t *testing.T) { + mockAuth := NewMockDefaultCredentialsFinder(t) + resolver := NewDefaultProjectParentResolver(mockAuth) + cfg := config.GcpConfig{ProjectId: ""} + + mockAuth.EXPECT(). + FindDefaultCredentials(ctx). + Return(&google.Credentials{ProjectID: ""}, nil) + + parent, err := resolver.GetProjectParent(ctx, cfg, nil) + require.Error(t, err) + assert.ErrorIs(t, err, ErrProjectNotFound) + assert.Empty(t, parent) + }) + + t.Run("returns error when audience is set with clientOpts but audience has no valid project number", func(t *testing.T) { + mockAuth := NewMockDefaultCredentialsFinder(t) + resolver := NewDefaultProjectParentResolver(mockAuth) + cfg := config.GcpConfig{ + ProjectId: "", + GcpClientOpt: config.GcpClientOpt{Audience: "//iam.googleapis.com/locations/global/not-a-valid-audience"}, + } + clientOpts := []option.ClientOption{option.WithRequestReason("test")} + + parent, err := resolver.GetProjectParent(ctx, cfg, clientOpts) + require.Error(t, err) + assert.ErrorIs(t, err, ErrProjectNotFound) + assert.ErrorContains(t, err, "audience does not contain a valid project number") + assert.Empty(t, parent) + // Should not fall back to ADC when clientOpts and Audience are present + mockAuth.AssertNotCalled(t, "FindDefaultCredentials") + }) + + t.Run("falls back to default credentials when ProjectId is empty and no clientOpts", func(t *testing.T) { + mockAuth := NewMockDefaultCredentialsFinder(t) + resolver := NewDefaultProjectParentResolver(mockAuth) + cfg := config.GcpConfig{ + ProjectId: "", + GcpClientOpt: config.GcpClientOpt{Audience: "//iam.googleapis.com/projects/123/locations/global"}, + } + + mockAuth.EXPECT(). + FindDefaultCredentials(ctx). + Return(&google.Credentials{ProjectID: "fallback-project"}, nil) + + parent, err := resolver.GetProjectParent(ctx, cfg, nil) + require.NoError(t, err) + assert.Equal(t, "projects/fallback-project", parent) + }) +}