diff --git a/google/cloud/google_cloud_cpp_rest_internal.bzl b/google/cloud/google_cloud_cpp_rest_internal.bzl index 6fcc004b1de88..81603086f1e8e 100644 --- a/google/cloud/google_cloud_cpp_rest_internal.bzl +++ b/google/cloud/google_cloud_cpp_rest_internal.bzl @@ -54,6 +54,7 @@ google_cloud_cpp_rest_internal_hdrs = [ "internal/oauth2_logging_credentials.h", "internal/oauth2_minimal_iam_credentials_rest.h", "internal/oauth2_refreshing_credentials_wrapper.h", + "internal/oauth2_regional_access_boundary_token_manager.h", "internal/oauth2_service_account_credentials.h", "internal/oauth2_universe_domain.h", "internal/parse_service_account_p12_file.h", @@ -113,6 +114,7 @@ google_cloud_cpp_rest_internal_srcs = [ "internal/oauth2_logging_credentials.cc", "internal/oauth2_minimal_iam_credentials_rest.cc", "internal/oauth2_refreshing_credentials_wrapper.cc", + "internal/oauth2_regional_access_boundary_token_manager.cc", "internal/oauth2_service_account_credentials.cc", "internal/oauth2_universe_domain.cc", "internal/openssl/parse_service_account_p12_file.cc", diff --git a/google/cloud/google_cloud_cpp_rest_internal.cmake b/google/cloud/google_cloud_cpp_rest_internal.cmake index 2906820d25289..41926d4906226 100644 --- a/google/cloud/google_cloud_cpp_rest_internal.cmake +++ b/google/cloud/google_cloud_cpp_rest_internal.cmake @@ -92,6 +92,8 @@ add_library( internal/oauth2_minimal_iam_credentials_rest.h internal/oauth2_refreshing_credentials_wrapper.cc internal/oauth2_refreshing_credentials_wrapper.h + internal/oauth2_regional_access_boundary_token_manager.cc + internal/oauth2_regional_access_boundary_token_manager.h internal/oauth2_service_account_credentials.cc internal/oauth2_service_account_credentials.h internal/oauth2_universe_domain.cc @@ -284,6 +286,7 @@ if (BUILD_TESTING) internal/oauth2_logging_credentials_test.cc internal/oauth2_minimal_iam_credentials_rest_test.cc internal/oauth2_refreshing_credentials_wrapper_test.cc + internal/oauth2_regional_access_boundary_token_manager_test.cc internal/oauth2_service_account_credentials_test.cc internal/oauth2_universe_domain_test.cc internal/populate_rest_options_test.cc diff --git a/google/cloud/google_cloud_cpp_rest_internal_unit_tests.bzl b/google/cloud/google_cloud_cpp_rest_internal_unit_tests.bzl index b80f9ce502207..97315183034dc 100644 --- a/google/cloud/google_cloud_cpp_rest_internal_unit_tests.bzl +++ b/google/cloud/google_cloud_cpp_rest_internal_unit_tests.bzl @@ -51,6 +51,7 @@ google_cloud_cpp_rest_internal_unit_tests = [ "internal/oauth2_logging_credentials_test.cc", "internal/oauth2_minimal_iam_credentials_rest_test.cc", "internal/oauth2_refreshing_credentials_wrapper_test.cc", + "internal/oauth2_regional_access_boundary_token_manager_test.cc", "internal/oauth2_service_account_credentials_test.cc", "internal/oauth2_universe_domain_test.cc", "internal/populate_rest_options_test.cc", diff --git a/google/cloud/internal/oauth2_decorate_credentials.cc b/google/cloud/internal/oauth2_decorate_credentials.cc index d499a0737e04e..4df3206aec658 100644 --- a/google/cloud/internal/oauth2_decorate_credentials.cc +++ b/google/cloud/internal/oauth2_decorate_credentials.cc @@ -16,6 +16,7 @@ #include "google/cloud/common_options.h" #include "google/cloud/internal/oauth2_cached_credentials.h" #include "google/cloud/internal/oauth2_logging_credentials.h" +#include "google/cloud/internal/oauth2_regional_access_boundary_token_manager.h" namespace google { namespace cloud { @@ -23,10 +24,18 @@ namespace oauth2_internal { GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN std::shared_ptr Decorate( - std::shared_ptr impl, Options const& opts) { + std::shared_ptr impl, + HttpClientFactory client_factory, Options const& opts) { impl = WithLogging(std::move(impl), opts, "refresh"); impl = WithCaching(std::move(impl)); - return WithLogging(std::move(impl), opts, "cached"); + impl = WithLogging(std::move(impl), opts, "cached"); + if (!std::holds_alternative( + impl->AllowedLocationsRequest())) { + impl = WithRegionalAccessBoundary(std::move(impl), + std::move(client_factory), opts); + impl = WithLogging(std::move(impl), opts, "rab"); + } + return impl; } std::shared_ptr WithLogging( @@ -42,6 +51,13 @@ std::shared_ptr WithCaching( return std::make_shared(std::move(impl)); } +std::shared_ptr WithRegionalAccessBoundary( + std::shared_ptr impl, + HttpClientFactory client_factory, Options options) { + return RegionalAccessBoundaryTokenManager::Create( + std::move(impl), std::move(client_factory), std::move(options)); +} + GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END } // namespace oauth2_internal } // namespace cloud diff --git a/google/cloud/internal/oauth2_decorate_credentials.h b/google/cloud/internal/oauth2_decorate_credentials.h index 8863898f0044b..a0c27da4ecd98 100644 --- a/google/cloud/internal/oauth2_decorate_credentials.h +++ b/google/cloud/internal/oauth2_decorate_credentials.h @@ -16,6 +16,7 @@ #define GOOGLE_CLOUD_CPP_GOOGLE_CLOUD_INTERNAL_OAUTH2_DECORATE_CREDENTIALS_H #include "google/cloud/internal/oauth2_credentials.h" +#include "google/cloud/internal/oauth2_http_client_factory.h" #include "google/cloud/options.h" #include "google/cloud/version.h" #include @@ -29,7 +30,8 @@ GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN /// Add a full stack of logging (if requested in @p opts) and caching decorators /// to the credentials. std::shared_ptr Decorate( - std::shared_ptr impl, Options const& opts); + std::shared_ptr impl, + HttpClientFactory client_factory, Options const& opts); /// Add only a logging decorator to the credentials if requested in @p opts std::shared_ptr WithLogging( @@ -40,6 +42,11 @@ std::shared_ptr WithLogging( std::shared_ptr WithCaching( std::shared_ptr impl); +/// Add regional access boundary decorator to the credentials. +std::shared_ptr WithRegionalAccessBoundary( + std::shared_ptr impl, + HttpClientFactory client_factory, Options options); + GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END } // namespace oauth2_internal } // namespace cloud diff --git a/google/cloud/internal/oauth2_regional_access_boundary_token_manager.cc b/google/cloud/internal/oauth2_regional_access_boundary_token_manager.cc new file mode 100644 index 0000000000000..279fe542d6b11 --- /dev/null +++ b/google/cloud/internal/oauth2_regional_access_boundary_token_manager.cc @@ -0,0 +1,262 @@ +// Copyright 2026 Google LLC +// +// Licensed 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 +// +// https://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. + +#include "google/cloud/internal/oauth2_regional_access_boundary_token_manager.h" +#include "google/cloud/internal/algorithm.h" +#include "google/cloud/internal/rest_response.h" +#include "google/cloud/log.h" +#include "absl/strings/str_cat.h" + +namespace google { +namespace cloud { +namespace oauth2_internal { +GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN +namespace { + +auto constexpr kTokenTtl = std::chrono::seconds(6 * 3600); +auto constexpr kTtlGracePeriod = std::chrono::seconds(3600); +auto constexpr kMaximumRetryDuration = std::chrono::seconds(60); +auto constexpr kInitialBackoffDelay = std::chrono::seconds(1); +auto constexpr kMaximumBackoffDelay = std::chrono::seconds(5); +auto constexpr kBackoffScaling = 2.0; +auto constexpr kFailedLookupInitialBackoffDelay = std::chrono::seconds(15); +auto constexpr kFailedLookupMaximumBackoffDelay = std::chrono::seconds(120); +auto constexpr kFailedLookupBackoffScaling = 1.75; + +} // namespace + +bool RegionalAccessBoundaryTokenManager::RetryTraits::IsPermanentFailure( + Status const& s) { + // Http status codes 500, 502, 503, and 504 are mapped to kUnavailable, and + // some others that we don't mind retrying. + return s.code() != StatusCode::kUnavailable; +} + +class RegionalAccessBoundaryTokenManager::RefreshTokenLimitedTimeRetryPolicy + : public RefreshTokenRetryPolicy { + public: + template + explicit RefreshTokenLimitedTimeRetryPolicy( + std::chrono::duration maximum_duration) + : impl_(maximum_duration) {} + + RefreshTokenLimitedTimeRetryPolicy( + RefreshTokenLimitedTimeRetryPolicy&& rhs) noexcept + : RefreshTokenLimitedTimeRetryPolicy(rhs.maximum_duration()) {} + RefreshTokenLimitedTimeRetryPolicy( + RefreshTokenLimitedTimeRetryPolicy const& rhs) noexcept + : RefreshTokenLimitedTimeRetryPolicy(rhs.maximum_duration()) {} + + std::chrono::milliseconds maximum_duration() const { + return impl_.maximum_duration(); + } + + bool OnFailure(Status const& status) override { + return impl_.OnFailure(status); + } + bool IsExhausted() const override { return impl_.IsExhausted(); } + bool IsPermanentFailure(Status const& status) const override { + return impl_.IsPermanentFailure(status); + } + std::unique_ptr clone() const override { + return std::make_unique( + maximum_duration()); + } + + // This is provided only for backwards compatibility. + using BaseType = RefreshTokenRetryPolicy; + + private: + google::cloud::internal::LimitedTimeRetryPolicy impl_; +}; + +std::shared_ptr +RegionalAccessBoundaryTokenManager::Create(std::shared_ptr child, + HttpClientFactory client_factory, + Options options) { + auto iam_stub = MakeMinimalIamCredentialsRestStub(child, options, + std::move(client_factory)); + return std::shared_ptr( + new RegionalAccessBoundaryTokenManager( + std::move(child), std::move(iam_stub), + std::make_unique< + rest_internal::AutomaticallyCreatedRestPureBackgroundThreads>(), + std::move(options), FailedLookupBackoffPolicy, + std::make_shared())); +} + +std::shared_ptr +RegionalAccessBoundaryTokenManager::Create( + std::shared_ptr child, + std::shared_ptr iam_stub, Options options) { + return std::shared_ptr( + new RegionalAccessBoundaryTokenManager( + std::move(child), std::move(iam_stub), + std::make_unique< + rest_internal::AutomaticallyCreatedRestPureBackgroundThreads>(), + std::move(options), FailedLookupBackoffPolicy, + std::make_shared())); +} + +std::shared_ptr +RegionalAccessBoundaryTokenManager::Create( + std::shared_ptr child, + std::shared_ptr iam_stub, Options options, + std::function()> + failed_lookup_backoff_policy_fn, + std::shared_ptr clock, AllowedLocationsResponse allowed_locations) { + return std::shared_ptr( + new RegionalAccessBoundaryTokenManager( + std::move(child), std::move(iam_stub), + std::make_unique< + rest_internal::AutomaticallyCreatedRestPureBackgroundThreads>(), + std::move(options), std::move(failed_lookup_backoff_policy_fn), + std::move(clock), std::move(allowed_locations))); +} + +std::unique_ptr +RegionalAccessBoundaryTokenManager::FailedLookupBackoffPolicy() { + return std::make_unique( + kFailedLookupInitialBackoffDelay, kFailedLookupMaximumBackoffDelay, + kFailedLookupBackoffScaling); +} + +RegionalAccessBoundaryTokenManager::RegionalAccessBoundaryTokenManager( + std::shared_ptr child, + std::shared_ptr iam_stub, + std::unique_ptr background, + Options options, + std::function()> + failed_lookup_backoff_policy_fn, + std::shared_ptr clock, AllowedLocationsResponse allowed_locations) + : child_(std::move(child)), + background_(std::move(background)), + options_(std::move(options)), + clock_(std::move(clock)), + retry_policy_(std::make_unique( + kMaximumRetryDuration)), + backoff_policy_(std::make_unique( + kInitialBackoffDelay, kMaximumBackoffDelay, kBackoffScaling)), + failed_lookup_backoff_policy_fn_( + std::move(failed_lookup_backoff_policy_fn)), + iam_stub_(std::move(iam_stub)), + allowed_locations_(std::move(allowed_locations)) { + if (!allowed_locations_.encoded_locations.empty()) { + expire_time_ = clock_->Now() + TokenTtl(); + } +} + +bool RegionalAccessBoundaryTokenManager::DoesEndpointRequireToken( + std::string_view endpoint) { + return absl::EndsWithIgnoreCase(endpoint, ".googleapis.com") && + !absl::EndsWithIgnoreCase(endpoint, ".rep.googleapis.com") && + !absl::EndsWithIgnoreCase(endpoint, (".rep.sandbox.googleapis.com")); +} + +bool RegionalAccessBoundaryTokenManager::IsTokenValid( + std::scoped_lock const&, + std::chrono::system_clock::time_point tp) const { + return !allowed_locations_.encoded_locations.empty() && tp < expire_time_; +} + +std::chrono::seconds RegionalAccessBoundaryTokenManager::TtlGracePeriod() { + return kTtlGracePeriod; +} + +std::chrono::seconds RegionalAccessBoundaryTokenManager::TokenTtl() { + return kTokenTtl; +} + +StatusOr +RegionalAccessBoundaryTokenManager::AllowedLocations( + std::chrono::system_clock::time_point tp, std::string_view endpoint) { + auto request = child_->AllowedLocationsRequest(); + struct Visitor { + StatusOr operator()(std::monostate) const { + return rest_internal::HttpHeader{}; + } + StatusOr operator()( + ServiceAccountAllowedLocationsRequest const& r) const { + return m.GetAllowedLocationsHeader(r, tp, endpoint); + } + StatusOr operator()( + WorkforceIdentityAllowedLocationsRequest const& r) const { + return m.GetAllowedLocationsHeader(r, tp, endpoint); + } + StatusOr operator()( + WorkloadIdentityAllowedLocationsRequest const& r) const { + return m.GetAllowedLocationsHeader(r, tp, endpoint); + } + + RegionalAccessBoundaryTokenManager& m; + std::chrono::system_clock::time_point tp; + std::string_view endpoint; + }; + return std::visit(Visitor{*this, tp, endpoint}, request); +} + +StatusOr> +RegionalAccessBoundaryTokenManager::SignBlob( + absl::optional const& signing_service_account, + std::string const& string_to_sign) const { + return child_->SignBlob(signing_service_account, string_to_sign); +} + +std::string RegionalAccessBoundaryTokenManager::AccountEmail() const { + return child_->AccountEmail(); +} + +std::string RegionalAccessBoundaryTokenManager::KeyId() const { + return child_->KeyId(); +} + +StatusOr RegionalAccessBoundaryTokenManager::universe_domain() + const { + return child_->universe_domain(); +} + +StatusOr RegionalAccessBoundaryTokenManager::universe_domain( + google::cloud::Options const& options) const { + return child_->universe_domain(options); +} + +StatusOr RegionalAccessBoundaryTokenManager::project_id() const { + return child_->project_id(); +} + +StatusOr RegionalAccessBoundaryTokenManager::project_id( + Options const& options) const { + return child_->project_id(options); +} + +StatusOr +RegionalAccessBoundaryTokenManager::Authorization( + std::chrono::system_clock::time_point tp) { + return child_->Authorization(tp); +} + +StatusOr RegionalAccessBoundaryTokenManager::GetToken( + std::chrono::system_clock::time_point tp) { + return child_->GetToken(tp); +} + +Credentials::AllowedLocationsRequestType +RegionalAccessBoundaryTokenManager::AllowedLocationsRequest() const { + return child_->AllowedLocationsRequest(); +} + +GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END +} // namespace oauth2_internal +} // namespace cloud +} // namespace google diff --git a/google/cloud/internal/oauth2_regional_access_boundary_token_manager.h b/google/cloud/internal/oauth2_regional_access_boundary_token_manager.h new file mode 100644 index 0000000000000..85d1ba3f01245 --- /dev/null +++ b/google/cloud/internal/oauth2_regional_access_boundary_token_manager.h @@ -0,0 +1,252 @@ +// Copyright 2026 Google LLC +// +// Licensed 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 +// +// https://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. + +#ifndef GOOGLE_CLOUD_CPP_GOOGLE_CLOUD_INTERNAL_OAUTH2_REGIONAL_ACCESS_BOUNDARY_TOKEN_MANAGER_H +#define GOOGLE_CLOUD_CPP_GOOGLE_CLOUD_INTERNAL_OAUTH2_REGIONAL_ACCESS_BOUNDARY_TOKEN_MANAGER_H + +#include "google/cloud/backoff_policy.h" +#include "google/cloud/internal/clock.h" +#include "google/cloud/internal/http_header.h" +#include "google/cloud/internal/oauth2_minimal_iam_credentials_rest.h" +#include "google/cloud/internal/rest_pure_background_threads_impl.h" +#include "google/cloud/internal/rest_retry_loop.h" +#include "google/cloud/internal/retry_policy_impl.h" +#include "google/cloud/log.h" +#include "google/cloud/options.h" +#include "google/cloud/status_or.h" +#include "google/cloud/version.h" +#include "absl/strings/match.h" +#include +#include + +namespace google { +namespace cloud { +namespace oauth2_internal { +GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN + +/** + * Manages fetching and caching routing tokens for RAB. + * + * Regional endpoints (those ending with ".rep.googleapis.com" or + * ".rep.sandbox.googleapis.com") do not require a routing token. Non-GDU + * endpoints, likewise do not require a routing token. + * + * The routing tokens are refreshed asynchronously by a background thread. + * + * Supported credential types: + * - Service Account + * - Impersonated Service Account + * - Workload Identity Federation + * - Workforce Identity Federation + * - Self Signed JWT + * + * Supported environments: + * - GDU only + */ +class RegionalAccessBoundaryTokenManager + : public Credentials, + public std::enable_shared_from_this { + public: + using Clock = ::google::cloud::internal::SystemClock; + + struct RetryTraits { + static bool IsPermanentFailure(Status const&); + }; + + static std::shared_ptr Create( + std::shared_ptr child, HttpClientFactory client_factory, + Options options); + + static std::shared_ptr Create( + std::shared_ptr child, + std::shared_ptr iam_stub, Options options); + + static std::chrono::seconds TtlGracePeriod(); + static std::chrono::seconds TokenTtl(); + + // Decorator overrides from Credentials that simply call the same method on + // child_. + StatusOr> SignBlob( + absl::optional const& signing_service_account, + std::string const& string_to_sign) const override; + std::string AccountEmail() const override; + std::string KeyId() const override; + StatusOr universe_domain() const override; + StatusOr universe_domain( + google::cloud::Options const& options) const override; + StatusOr project_id() const override; + StatusOr project_id(Options const&) const override; + StatusOr Authorization( + std::chrono::system_clock::time_point tp) override; + StatusOr GetToken( + std::chrono::system_clock::time_point tp) override; + Credentials::AllowedLocationsRequestType AllowedLocationsRequest() + const override; + + // Decorator override that has an implementation. + StatusOr AllowedLocations( + std::chrono::system_clock::time_point tp, + std::string_view endpoint) override; + + template + StatusOr GetAllowedLocationsHeader( + Request const& request, std::chrono::system_clock::time_point tp, + std::string_view endpoint) { + // If the endpoint does not need a token, return immediately. + if (!DoesEndpointRequireToken(endpoint)) return rest_internal::HttpHeader{}; + std::scoped_lock lock(mu_); + if (!refresh_in_progress_ && pending_refresh_.valid()) { + pending_refresh_.get(); + } + if (IsTokenValid(lock, tp)) { + // Check to see if we're near expiry and if so, start refresh process. + if (tp > (expire_time_ - TtlGracePeriod())) RefreshToken(lock, request); + return rest_internal::HttpHeader{"x-allowed-locations", + allowed_locations_.encoded_locations}; + } + RefreshToken(lock, request); + // Don't wait for a valid token, just return an empty header. + return rest_internal::HttpHeader{}; + } + + // Used for testing. + static std::shared_ptr Create( + std::shared_ptr child, + std::shared_ptr iam_stub, Options options, + std::function()> + failed_lookup_backoff_policy_fn, + std::shared_ptr clock = std::make_shared(), + AllowedLocationsResponse allowed_locations = AllowedLocationsResponse{}); + + // Snapshot read useful only in testing. + bool IsOnCooldown() const { + std::scoped_lock lock(mu_); + return failed_lookup_cooldown_.valid() && + !failed_lookup_cooldown_.is_ready(); + } + + // Snapshot read useful only in testing. + bool IsRefreshPending() const { + std::scoped_lock lock(mu_); + return refresh_in_progress_; + } + + private: + class RefreshTokenRetryPolicy : public ::google::cloud::RetryPolicy { + public: + virtual std::unique_ptr clone() const = 0; + }; + + class RefreshTokenLimitedTimeRetryPolicy; + + static bool DoesEndpointRequireToken(std::string_view endpoint); + static std::unique_ptr FailedLookupBackoffPolicy(); + + RegionalAccessBoundaryTokenManager( + std::shared_ptr child, + std::shared_ptr iam_stub, + std::unique_ptr background, + Options options, + std::function()> + failed_lookup_backoff_policy_fn, + std::shared_ptr clock = std::make_shared(), + AllowedLocationsResponse allowed_locations = AllowedLocationsResponse{}); + + bool IsTokenValid(std::scoped_lock const&, + std::chrono::system_clock::time_point tp) const; + + template + void RefreshToken(std::scoped_lock const&, + Request const& request) { + if (refresh_in_progress_) return; + if (failed_lookup_cooldown_.valid()) { + if (!failed_lookup_cooldown_.is_ready()) return; + (void)failed_lookup_cooldown_.get(); + } + + promise pending_refresh; + pending_refresh_ = pending_refresh.get_future(); + auto constexpr kLocation = __func__; + auto pending_refresh_fn = [p = std::move(pending_refresh), + weak = weak_from_this(), request, + stub = iam_stub_, + retry_policy = retry_policy_->clone(), + backoff_policy = backoff_policy_->clone(), + options = options_]() mutable { + auto refresh_attempt_fn = [stub](rest_internal::RestContext&, + Options const&, Request const& request) { + return stub->AllowedLocations(request); + }; + + StatusOr allowed_locations = + rest_internal::RestRetryLoop( + *retry_policy, *backoff_policy, Idempotency::kIdempotent, + refresh_attempt_fn, options, request, kLocation); + + if (!allowed_locations.ok()) { + GCP_LOG(WARNING) << "AllowedLocations refresh failed with status=" + << allowed_locations.status(); + } + + auto self = weak.lock(); + if (!self) return; + std::scoped_lock lock(self->mu_); + if (allowed_locations.ok()) { + self->allowed_locations_ = *allowed_locations; + self->expire_time_ = self->clock_->Now() + TokenTtl(); + self->failed_lookup_backoff_policy_.reset(); + p.set_value(Status{}); + } else { + self->allowed_locations_ = AllowedLocationsResponse{}; + if (!self->failed_lookup_backoff_policy_) { + self->failed_lookup_backoff_policy_ = + self->failed_lookup_backoff_policy_fn_(); + } + self->failed_lookup_cooldown_ = + self->background_->cq().MakeRelativeTimer( + self->failed_lookup_backoff_policy_->OnCompletion()); + p.set_value(allowed_locations.status()); + } + self->refresh_in_progress_ = false; + }; + + refresh_in_progress_ = true; + background_->cq().RunAsync(std::move(pending_refresh_fn)); + } + + mutable std::mutex mu_; + std::shared_ptr child_; + std::unique_ptr background_; + Options options_; + std::shared_ptr clock_; + std::unique_ptr retry_policy_; + std::unique_ptr backoff_policy_; + std::function()> + failed_lookup_backoff_policy_fn_; + std::unique_ptr failed_lookup_backoff_policy_; + std::shared_ptr iam_stub_; + future> + failed_lookup_cooldown_; + std::chrono::system_clock::time_point expire_time_; + AllowedLocationsResponse allowed_locations_; + future pending_refresh_; + bool refresh_in_progress_ = false; +}; + +GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END +} // namespace oauth2_internal +} // namespace cloud +} // namespace google + +#endif // GOOGLE_CLOUD_CPP_GOOGLE_CLOUD_INTERNAL_OAUTH2_REGIONAL_ACCESS_BOUNDARY_TOKEN_MANAGER_H diff --git a/google/cloud/internal/oauth2_regional_access_boundary_token_manager_test.cc b/google/cloud/internal/oauth2_regional_access_boundary_token_manager_test.cc new file mode 100644 index 0000000000000..7a3e43e49b851 --- /dev/null +++ b/google/cloud/internal/oauth2_regional_access_boundary_token_manager_test.cc @@ -0,0 +1,380 @@ +// Copyright 2026 Google LLC +// +// Licensed 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 +// +// https://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. + +#include "google/cloud/internal/oauth2_regional_access_boundary_token_manager.h" +#include "google/cloud/internal/make_status.h" +#include "google/cloud/internal/rest_pure_background_threads_impl.h" +#include "google/cloud/internal/rest_response.h" +#include "google/cloud/internal/unified_rest_credentials.h" +#include "google/cloud/testing_util/fake_clock.h" +#include "google/cloud/testing_util/status_matchers.h" +#include + +namespace google { +namespace cloud { +namespace oauth2_internal { +GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN +namespace { + +using ::google::cloud::testing_util::IsOkAndHolds; +using ::testing::A; +using ::testing::Eq; +using ::testing::IsEmpty; +using ::testing::MockFunction; +using ::testing::Return; + +class MockCredentials : public Credentials { + public: + MOCK_METHOD(StatusOr>, SignBlob, + (absl::optional const&, std::string const&), + (const, override)); + MOCK_METHOD(std::string, AccountEmail, (), (const, override)); + MOCK_METHOD(std::string, KeyId, (), (const, override)); + MOCK_METHOD(StatusOr, universe_domain, (), (const, override)); + MOCK_METHOD(StatusOr, universe_domain, + (google::cloud::Options const&), (const, override)); + MOCK_METHOD(StatusOr, project_id, (), (const, override)); + MOCK_METHOD(StatusOr, project_id, (Options const&), + (const, override)); + MOCK_METHOD(StatusOr, Authorization, + (std::chrono::system_clock::time_point), (override)); + MOCK_METHOD(StatusOr, AllowedLocations, + (std::chrono::system_clock::time_point, std::string_view), + (override)); + MOCK_METHOD(StatusOr, GetToken, + (std::chrono::system_clock::time_point), (override)); + MOCK_METHOD(AllowedLocationsRequestType, AllowedLocationsRequest, (), + (const, override)); +}; + +class MockMinimalIamCredentialsRest : public MinimalIamCredentialsRest { + public: + MOCK_METHOD(StatusOr, GenerateAccessToken, + (GenerateAccessTokenRequest const&), (override)); + MOCK_METHOD(StatusOr, AllowedLocations, + (ServiceAccountAllowedLocationsRequest const&), (override)); + MOCK_METHOD(StatusOr, AllowedLocations, + (WorkloadIdentityAllowedLocationsRequest const&), (override)); + MOCK_METHOD(StatusOr, AllowedLocations, + (WorkforceIdentityAllowedLocationsRequest const&), (override)); + MOCK_METHOD(StatusOr, universe_domain, (Options const& options), + (override, const)); +}; + +class MockBackoffPolicy : public BackoffPolicy { + public: + MOCK_METHOD(std::unique_ptr, clone, (), (const, override)); + MOCK_METHOD(std::chrono::milliseconds, OnCompletion, (), (override)); +}; + +TEST(RegionalAccessBoundaryTokenManagerRetryTraitsTest, RetryTraits) { + auto http_status_500 = + rest_internal::AsStatus(rest_internal::kInternalServerError, {}); + auto http_status_502 = + rest_internal::AsStatus(rest_internal::kBadGateway, {}); + auto http_status_503 = + rest_internal::AsStatus(rest_internal::kServiceUnavailable, {}); + auto http_status_504 = + rest_internal::AsStatus(rest_internal::kGatewayTimeout, {}); + + using RetryTraits = RegionalAccessBoundaryTokenManager::RetryTraits; + + EXPECT_FALSE(RetryTraits::IsPermanentFailure(http_status_500)); + EXPECT_FALSE(RetryTraits::IsPermanentFailure(http_status_502)); + EXPECT_FALSE(RetryTraits::IsPermanentFailure(http_status_503)); + EXPECT_FALSE(RetryTraits::IsPermanentFailure(http_status_504)); + + auto http_status_404 = rest_internal::AsStatus(rest_internal::kNotFound, {}); + EXPECT_TRUE(RetryTraits::IsPermanentFailure(http_status_404)); +} + +class RegionalAccessBoundaryTokenManagerTest : public ::testing::Test { + protected: + RegionalAccessBoundaryTokenManagerTest() + : mock_credentials_(std::make_shared()), + mock_iam_stub_(std::make_shared()), + fake_clock_(std::make_shared()) {} + + std::shared_ptr mock_credentials_; + std::shared_ptr mock_iam_stub_; + std::shared_ptr fake_clock_; +}; + +TEST_F(RegionalAccessBoundaryTokenManagerTest, + GetAllowedLocationsHeaderNonApplicableEndpoints) { + auto manager = RegionalAccessBoundaryTokenManager::Create(mock_credentials_, + mock_iam_stub_, {}); + + ServiceAccountAllowedLocationsRequest request; + auto header = manager->GetAllowedLocationsHeader( + request, std::chrono::system_clock::now(), "service.rep.googleapis.com"); + EXPECT_THAT(header, IsOkAndHolds(IsEmpty())); + + auto header_sandbox = manager->GetAllowedLocationsHeader( + request, std::chrono::system_clock::now(), + "service.rep.sandbox.googleapis.com"); + EXPECT_THAT(header_sandbox, IsOkAndHolds(IsEmpty())); + + auto header_non_gdu = manager->GetAllowedLocationsHeader( + request, std::chrono::system_clock::now(), "service.bar.com"); + EXPECT_THAT(header_non_gdu, IsOkAndHolds(IsEmpty())); +} + +TEST_F(RegionalAccessBoundaryTokenManagerTest, + GetAllowedLocationsHeaderValidTokenSoftExpiry) { + fake_clock_->SetTime(std::chrono::system_clock::now()); + MockFunction()> backoff_fn; + EXPECT_CALL(backoff_fn, Call).Times(0); + + EXPECT_CALL(*mock_credentials_, AllowedLocationsRequest) + .WillRepeatedly(Return(WorkforceIdentityAllowedLocationsRequest{})); + + AllowedLocationsResponse allowed_locations; + allowed_locations.locations = {"location1"}; + allowed_locations.encoded_locations = "encoded-location"; + + auto manager = RegionalAccessBoundaryTokenManager::Create( + mock_credentials_, mock_iam_stub_, {}, backoff_fn.AsStdFunction(), + fake_clock_, allowed_locations); + + fake_clock_->AdvanceTime(std::chrono::seconds(1)); + + auto header = + manager->AllowedLocations(fake_clock_->Now(), "service.googleapis.com"); + EXPECT_THAT(header, + IsOkAndHolds(rest_internal::HttpHeader{ + "x-allowed-locations", allowed_locations.encoded_locations})); + + promise sync_threads; + AllowedLocationsResponse refreshed_allowed_locations; + refreshed_allowed_locations.locations = {"location2"}; + refreshed_allowed_locations.encoded_locations = "encoded-location-2"; + // Refresh is called due to soft expiry, but current token is still returned. + EXPECT_CALL( + *mock_iam_stub_, + AllowedLocations(A())) + .WillOnce([&, f = sync_threads.get_future()]( + WorkforceIdentityAllowedLocationsRequest const&) mutable { + f.get(); + return refreshed_allowed_locations; + }); + + fake_clock_->AdvanceTime( + RegionalAccessBoundaryTokenManager::TokenTtl() - + RegionalAccessBoundaryTokenManager::TtlGracePeriod()); + + header = + manager->AllowedLocations(fake_clock_->Now(), "service.googleapis.com"); + EXPECT_THAT(header, + IsOkAndHolds(rest_internal::HttpHeader{ + "x-allowed-locations", allowed_locations.encoded_locations})); + EXPECT_TRUE(manager->IsRefreshPending()); + sync_threads.set_value(); + + // Give background thread a chance to call AllowedLocations. + std::this_thread::sleep_for(std::chrono::seconds(2)); + header = + manager->AllowedLocations(fake_clock_->Now(), "service.googleapis.com"); + EXPECT_THAT(header, IsOkAndHolds(rest_internal::HttpHeader{ + "x-allowed-locations", + refreshed_allowed_locations.encoded_locations})); +} + +TEST_F(RegionalAccessBoundaryTokenManagerTest, + GetAllowedLocationsHeaderNoInitialValidTokenWithRetry) { + EXPECT_CALL(*mock_credentials_, AllowedLocationsRequest) + .WillRepeatedly(Return(WorkloadIdentityAllowedLocationsRequest{})); + + AllowedLocationsResponse response; + response.locations = {"location1"}; + response.encoded_locations = "encoded-location"; + EXPECT_CALL( + *mock_iam_stub_, + AllowedLocations(A())) + .WillOnce([&](WorkloadIdentityAllowedLocationsRequest const&) { + return internal::UnavailableError("unavailable"); + }) + .WillOnce([&](WorkloadIdentityAllowedLocationsRequest const&) { + return response; + }); + + auto manager = RegionalAccessBoundaryTokenManager::Create(mock_credentials_, + mock_iam_stub_, {}); + + auto header = manager->AllowedLocations(std::chrono::system_clock::now(), + "service.googleapis.com"); + EXPECT_THAT(header, IsOkAndHolds(IsEmpty())); + + // Give the background thread a chance to run the future::then callback + // and update the token. + std::this_thread::sleep_for(std::chrono::seconds(2)); + + header = manager->AllowedLocations(std::chrono::system_clock::now(), + "service.googleapis.com"); + EXPECT_THAT(header, IsOkAndHolds(rest_internal::HttpHeader{ + "x-allowed-locations", response.encoded_locations})); +} + +TEST_F(RegionalAccessBoundaryTokenManagerTest, + GetAllowedLocationsHeaderPermanentFailureAndRecovery) { + EXPECT_CALL(*mock_credentials_, AllowedLocationsRequest) + .WillRepeatedly(Return(ServiceAccountAllowedLocationsRequest{})); + + AllowedLocationsResponse response; + response.locations = {"location1"}; + response.encoded_locations = "encoded-location"; + EXPECT_CALL( + *mock_iam_stub_, + AllowedLocations(A())) + .WillOnce([&](ServiceAccountAllowedLocationsRequest const&) { + return internal::UnavailableError("unavailable"); + }) + .WillOnce([&](ServiceAccountAllowedLocationsRequest const&) { + return internal::InternalError("uh oh"); + }) + .WillOnce([&](ServiceAccountAllowedLocationsRequest const&) { + return response; + }); + + MockFunction()> backoff_fn; + EXPECT_CALL(backoff_fn, Call).WillOnce([]() { + auto mock_backoff = std::make_unique(); + EXPECT_CALL(*mock_backoff, OnCompletion) + .WillOnce(Return(std::chrono::milliseconds(1000))); + return mock_backoff; + }); + + auto manager = RegionalAccessBoundaryTokenManager::Create( + mock_credentials_, mock_iam_stub_, {}, backoff_fn.AsStdFunction()); + + auto header = manager->AllowedLocations(std::chrono::system_clock::now(), + "service.googleapis.com"); + EXPECT_THAT(header, IsOkAndHolds(IsEmpty())); + + // Give the background thread a chance to run and update the token. + std::this_thread::sleep_for(std::chrono::seconds(2)); + + header = manager->AllowedLocations(std::chrono::system_clock::now(), + "service.googleapis.com"); + EXPECT_THAT(header, IsOkAndHolds(IsEmpty())); + // Permanent error encountered; verify cooldown has been set. + EXPECT_TRUE(manager->IsOnCooldown()); + + // With no valid token and active cooldown, no call to AllowedLocations on the + // iam stub should occur. + header = manager->AllowedLocations(std::chrono::system_clock::now(), + "service.googleapis.com"); + EXPECT_THAT(header, IsOkAndHolds(IsEmpty())); + + // With the mock backoff returning a short failure cooldown, let it expire. + std::this_thread::sleep_for(std::chrono::seconds(1)); + + header = manager->AllowedLocations(std::chrono::system_clock::now(), + "service.googleapis.com"); + EXPECT_THAT(header, IsOkAndHolds(IsEmpty())); + + // Give the background thread a chance to run and update the token. + std::this_thread::sleep_for(std::chrono::seconds(2)); + + header = manager->AllowedLocations(std::chrono::system_clock::now(), + "service.googleapis.com"); + EXPECT_THAT(header, IsOkAndHolds(rest_internal::HttpHeader{ + "x-allowed-locations", response.encoded_locations})); +} + +TEST_F(RegionalAccessBoundaryTokenManagerTest, + GetAllowedLocationsMonostateRequest) { + EXPECT_CALL(*mock_credentials_, AllowedLocationsRequest) + .WillRepeatedly(Return(std::monostate{})); + + EXPECT_CALL( + *mock_iam_stub_, + AllowedLocations(A())) + .Times(0); + EXPECT_CALL( + *mock_iam_stub_, + AllowedLocations(A())) + .Times(0); + EXPECT_CALL( + *mock_iam_stub_, + AllowedLocations(A())) + .Times(0); + + auto manager = RegionalAccessBoundaryTokenManager::Create(mock_credentials_, + mock_iam_stub_, {}); + auto header = manager->AllowedLocations(std::chrono::system_clock::now(), + "service.googleapis.com"); + EXPECT_THAT(header, IsOkAndHolds(IsEmpty())); + + // Give the background thread a chance to run the future::then callback + // and update the token. + std::this_thread::sleep_for(std::chrono::seconds(2)); + + header = manager->AllowedLocations(std::chrono::system_clock::now(), + "service.googleapis.com"); + EXPECT_THAT(header, IsOkAndHolds(IsEmpty())); +} + +TEST_F(RegionalAccessBoundaryTokenManagerTest, DecoratorMethodPassThrough) { + auto now = std::chrono::system_clock::now(); + auto options = Options{}.set("my-user-project"); + + EXPECT_CALL(*mock_credentials_, SignBlob(Eq("sa"), Eq("string"))) + .WillOnce(Return(StatusOr>({42}))); + EXPECT_CALL(*mock_credentials_, AccountEmail).WillOnce(Return("my-email")); + EXPECT_CALL(*mock_credentials_, KeyId).WillOnce(Return("my-keyid")); + EXPECT_CALL(*mock_credentials_, universe_domain()) + .WillOnce(Return(StatusOr("my-ud"))); + EXPECT_CALL(*mock_credentials_, + universe_domain(A())) + .WillOnce([](Options const& opts) -> StatusOr { + EXPECT_EQ(opts.get(), "my-user-project"); + return std::string{"my-ud"}; + }); + EXPECT_CALL(*mock_credentials_, project_id()) + .WillOnce(Return(StatusOr("my-project"))); + EXPECT_CALL(*mock_credentials_, + project_id(A())) + .WillOnce([](Options const& opts) -> StatusOr { + EXPECT_EQ(opts.get(), "my-user-project"); + return std::string{"my-project"}; + }); + EXPECT_CALL(*mock_credentials_, Authorization(Eq(now))) + .WillOnce(Return(StatusOr{})); + EXPECT_CALL(*mock_credentials_, GetToken(Eq(now))) + .WillOnce(Return(StatusOr{})); + EXPECT_CALL(*mock_credentials_, AllowedLocationsRequest) + .WillOnce( + Return(Credentials::AllowedLocationsRequestType{std::monostate{}})); + + auto manager = RegionalAccessBoundaryTokenManager::Create(mock_credentials_, + mock_iam_stub_, {}); + + (void)manager->SignBlob("sa", "string"); + (void)manager->AccountEmail(); + (void)manager->KeyId(); + (void)manager->universe_domain(); + (void)manager->universe_domain(options); + (void)manager->project_id(); + (void)manager->project_id(options); + (void)manager->Authorization(now); + (void)manager->GetToken(now); + (void)manager->AllowedLocationsRequest(); +} + +} // namespace +GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END +} // namespace oauth2_internal +} // namespace cloud +} // namespace google diff --git a/google/cloud/internal/unified_rest_credentials.cc b/google/cloud/internal/unified_rest_credentials.cc index 34c9ef726c53d..596617c57d7d9 100644 --- a/google/cloud/internal/unified_rest_credentials.cc +++ b/google/cloud/internal/unified_rest_credentials.cc @@ -81,9 +81,10 @@ std::shared_ptr MapCredentials( void visit(GoogleDefaultCredentialsConfig const& cfg) override { auto credentials = google::cloud::oauth2_internal::GoogleDefaultCredentials( - cfg.options(), std::move(client_factory_)); + cfg.options(), client_factory_); if (credentials) { - result = Decorate(*std::move(credentials), cfg.options()); + result = Decorate(*std::move(credentials), std::move(client_factory_), + cfg.options()); return; } result = MakeErrorCredentials(std::move(credentials).status()); @@ -97,19 +98,20 @@ std::shared_ptr MapCredentials( void visit(ImpersonateServiceAccountConfig const& cfg) override { result = std::make_shared< oauth2_internal::ImpersonateServiceAccountCredentials>( - cfg, std::move(client_factory_)); - result = Decorate(std::move(result), cfg.options()); + cfg, client_factory_); + result = Decorate(std::move(result), std::move(client_factory_), + cfg.options()); } void visit(ServiceAccountConfig const& cfg) override { StatusOr> creds; if (cfg.file_path().has_value()) { creds = oauth2_internal::CreateServiceAccountCredentialsFromFilePath( - *cfg.file_path(), cfg.options(), std::move(client_factory_)); + *cfg.file_path(), cfg.options(), client_factory_); } else if (cfg.json_object().has_value()) { creds = oauth2_internal::CreateServiceAccountCredentialsFromJsonContents( - *cfg.json_object(), cfg.options(), std::move(client_factory_)); + *cfg.json_object(), cfg.options(), client_factory_); } else { creds = MakeErrorCredentials(internal::InternalError( "ServiceAccountConfig has neither json_object nor file_path", @@ -117,7 +119,7 @@ std::shared_ptr MapCredentials( } if (creds.ok()) { - result = Decorate(*creds, cfg.options()); + result = Decorate(*creds, std::move(client_factory_), cfg.options()); } else { result = MakeErrorCredentials(std::move(creds).status()); } @@ -131,10 +133,11 @@ std::shared_ptr MapCredentials( result = MakeErrorCredentials(std::move(info).status()); return; } - result = Decorate( + auto creds = std::make_shared( - *std::move(info), std::move(client_factory_), cfg.options()), - cfg.options()); + *std::move(info), client_factory_, cfg.options()); + result = + Decorate(std::move(creds), std::move(client_factory_), cfg.options()); } void visit(ApiKeyConfig const& cfg) override { @@ -143,11 +146,11 @@ std::shared_ptr MapCredentials( } void visit(ComputeEngineCredentialsConfig const& cfg) override { - result = Decorate( - std::make_shared< - google::cloud::oauth2_internal::ComputeEngineCredentials>( - cfg.options(), std::move(client_factory_)), - cfg.options()); + auto creds = std::make_shared< + google::cloud::oauth2_internal::ComputeEngineCredentials>( + cfg.options(), client_factory_); + result = + Decorate(std::move(creds), std::move(client_factory_), cfg.options()); } private: diff --git a/google/cloud/internal/unified_rest_credentials_test.cc b/google/cloud/internal/unified_rest_credentials_test.cc index b3d769bf462f0..ab66fa764858d 100644 --- a/google/cloud/internal/unified_rest_credentials_test.cc +++ b/google/cloud/internal/unified_rest_credentials_test.cc @@ -244,7 +244,7 @@ TEST(UnifiedRestCredentialsTest, AdcIsComputeEngine) { ScopedEnvironment(oauth2_internal::GoogleGcloudAdcFileEnvVar(), filename); auto const now = std::chrono::system_clock::now(); - auto metadata_client = []() { + auto metadata_client_1 = []() { auto client = std::make_unique(); auto expected_request = AllOf( Property(&RestRequest::path, @@ -258,6 +258,27 @@ TEST(UnifiedRestCredentialsTest, AdcIsComputeEngine) { Status{StatusCode::kPermissionDenied, "uh-oh - GCE metadata"})); return client; }(); + + // TODO(#16079): Remove conditional and else clause when GA. +#ifdef GOOGLE_CLOUD_CPP_TESTING_ENABLE_RAB + // If the first MDS call is not successful in updating the service account + // email, we will try again. + auto metadata_client_2 = []() { + auto client = std::make_unique(); + auto expected_request = AllOf( + Property(&RestRequest::path, + absl::StrCat("http://metadata.google.internal/", + "computeMetadata/v1/instance/service-accounts/", + "default/")), + Property(&RestRequest::headers, + Contains(Pair("metadata-flavor", Contains("Google"))))); + EXPECT_CALL(*client, Get(_, expected_request)) + .WillOnce(Return( + Status{StatusCode::kPermissionDenied, "uh-oh - GCE metadata"})); + return client; + }(); +#endif + auto token_client = []() { auto client = std::make_unique(); auto expected_request = AllOf( @@ -275,7 +296,11 @@ TEST(UnifiedRestCredentialsTest, AdcIsComputeEngine) { MockClientFactory client_factory; EXPECT_CALL(client_factory, Call) - .WillOnce(Return(ByMove(std::move(metadata_client)))) + .WillOnce(Return(ByMove(std::move(metadata_client_1)))) + // TODO(#16079): Remove conditional and else clause when GA. +#ifdef GOOGLE_CLOUD_CPP_TESTING_ENABLE_RAB + .WillOnce(Return(ByMove(std::move(metadata_client_2)))) +#endif .WillOnce(Return(ByMove(std::move(token_client)))); auto const config = internal::GoogleDefaultCredentialsConfig(Options{});