diff --git a/.gitignore b/.gitignore index c03e032a..d1bb5213 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ build/ # Gemfile.lock # .ruby-version # .ruby-gemset +.tool-versions # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: .rvmrc diff --git a/.rubocop.yml b/.rubocop.yml index 4afcc011..9fd7daee 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -6,6 +6,7 @@ AllCops: - "integration/**/*" - "spec/**/*" - "test/**/*" + - "samples/regional_access_boundary/**/*" Metrics/BlockLength: Exclude: - "googleauth.gemspec" diff --git a/lib/googleauth/base_client.rb b/lib/googleauth/base_client.rb index 7fbce73a..078ddd28 100644 --- a/lib/googleauth/base_client.rb +++ b/lib/googleauth/base_client.rb @@ -13,6 +13,8 @@ # limitations under the License. require "google/logging/message" +require "googleauth/regional_access_boundary" +require "googleauth/helpers/connection" module Google # Module Auth provides classes that provide Google-specific authorization @@ -38,9 +40,19 @@ def apply! a_hash, opts = {} Google::Logging::Message.from message: "Sending auth token. (sha256:#{hash})" end + apply_regional_access_boundary! a_hash, opts + a_hash[AUTH_METADATA_KEY] end + # Whether this credential type supports Regional Access Boundaries. + # Default is false. Override in specific credentials to enable. + # + # @return [Boolean] true if Regional Access Boundary is supported, false otherwise. + def supports_regional_access_boundary? + false + end + # Returns a clone of a_hash updated with the authentication token def apply a_hash, opts = {} a_copy = a_hash.clone @@ -85,6 +97,104 @@ def principal private + # Evaluates and applies Regional Access Boundary restrictions to the metadata. + # + # Design (Fail Open): + # If no valid cache exists, the request proceeds without the x-allowed-locations header. + # Any background thread fetch operations triggered run asynchronously so the primary + # application thread remains unblocked. + # + # @private + # @param a_hash [Hash] the metadata to update. + # @param opts [Hash] options containing the target request URL. + # @return [void] + def apply_regional_access_boundary! a_hash, opts + return unless should_apply_rab? opts + + cache = Google::Auth::RegionalAccessBoundary.cache + header_val = cache.get&.encoded_locations + + # For global endpoints, attach the x-allowed-locations header + # to the outbound HTTP request if and only if a valid cache entry exists. + a_hash["x-allowed-locations"] = header_val if header_val + + # Return early if credentials do not support RAB or if we can't transition to fetching. + return unless respond_to?(:regional_access_boundary_url) && cache.try_mark_fetching! + + # Initiate an asynchronous, non-blocking lookup if a global request is + # made and the cache is invalid or expired. + trigger_async_rab_fetch cache + end + + # Determines if a request is eligible for Regional Access Boundary restrictions. + # + # @private + # @param opts [Hash] request options. + # @return [Boolean] true if the header should be applied, false otherwise (failing open). + def should_apply_rab? opts + # Return early if not supported by credential type or if ID token. + return false unless token_type != :id_token && supports_regional_access_boundary? + + url = opts[:url] + # URLs matching rep.googleapis.com are regional. Fallback to assume global if URL is not provided. + is_global = url.nil? || !url.to_s.match?(/\.rep\.googleapis\.com|\.rep\.sandbox\.googleapis\.com/) + + # Return early if it's a regional endpoint + return false unless is_global + + url_str = url.to_s + # No need to attach headers/metadata for requests to the STS or IAM endpoints. + is_excluded = url_str.match? %r{\Ahttps://(iam|iamcredentials|sts)\.googleapis\.com} + + # Return early if it's an excluded service (STS/IAM). + !is_excluded + end + + # Triggers the asynchronous lookup for RAB allowed locations in a background thread. + # + # Design (Fail Open): + # - Run inside a separate Thread so lookup latency does not delay the primary API call. + # - If `regional_access_boundary_url` is nil, skip lookup (e.g. metadata server cold start). + # - Rescue all StandardErrors to ensure no background fetch failures propagate or crash the process. + # + # @private + # @param cache [Google::Auth::RegionalAccessBoundary::Cache] the cache instance. + # @return [Thread] the background thread instance. + def trigger_async_rab_fetch cache + Thread.new do + begin + lookup_url = regional_access_boundary_url + + # A nil or empty URL means we cannot attempt the lookup yet (e.g. waiting + # for metadata server). + if lookup_url && !lookup_url.empty? + conn = Google::Auth::Helpers::Connection.connection_for self + fetcher = Google::Auth::RegionalAccessBoundary::Fetcher.new conn, lookup_url, self + data = fetcher.fetch + cache.set data, 6 * 60 * 60 # 6 hours + else + log_rab_warning "Regional Access Boundary lookup skipped: " \ + "could not determine allowedLocations URL" + cache.mark_fetch_failed! + end + rescue StandardError => e + # Ensure that any failure during the asynchronous lookup (network error, IAM refusal, etc.) does + # not propagate to the primary request or cause the application to crash. + log_rab_warning "Regional Access Boundary lookup failed: #{e.class} - #{e.message}" + cache.mark_fetch_failed! + end + end + end + + def log_rab_warning msg + logger&.warn do + Google::Logging::Message.from( + message: msg, + "credentialsId" => object_id + ) + end + end + def token_type raise NoMethodError, "token_type not implemented" end diff --git a/lib/googleauth/compute_engine.rb b/lib/googleauth/compute_engine.rb index 47a945ea..debdb304 100644 --- a/lib/googleauth/compute_engine.rb +++ b/lib/googleauth/compute_engine.rb @@ -177,6 +177,28 @@ def principal :gce_metadata end + # Returns the regional access boundary lookup URL. + # Fetches the service account email from metadata server. + # + # Design (Fail Open): + # Returning nil skips the lookup, allowing the request to proceed without the header. + # + # @private + # @return [String, nil] the allowedLocations endpoint URL, or nil if GCE default email is missing. + def regional_access_boundary_url + email = Google::Cloud.env.lookup_metadata "instance", "service-accounts/default/email" + return nil if email.nil? || email.empty? + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/#{email.strip}/allowedLocations" + end + + # Enable Regional Access Boundaries for GCE credentials. + # + # @private + # @return [Boolean] true + def supports_regional_access_boundary? + true + end + private # @private diff --git a/lib/googleauth/external_account/base_credentials.rb b/lib/googleauth/external_account/base_credentials.rb index 2b5df893..00389970 100644 --- a/lib/googleauth/external_account/base_credentials.rb +++ b/lib/googleauth/external_account/base_credentials.rb @@ -39,6 +39,16 @@ module BaseCredentials # Default IAM_SCOPE IAM_SCOPE = ["https://www.googleapis.com/auth/iam".freeze].freeze + # Workforce audience pattern. + WORKFORCE_AUDIENCE_PATTERN = %r{ + \A//iam\.([^/]+)/locations/([^/]+)/workforcePools/([^/]+)/providers/[^/]+\z + }x + # Workload audience pattern. + WORKLOAD_AUDIENCE_PATTERN = %r{ + \A//iam\.([^/]+)/projects/([^/]+)/locations/([^/]+)/ + workloadIdentityPools/([^/]+)/providers/[^/]+\z + }x + include Google::Auth::BaseClient include Helpers::Connection @@ -99,8 +109,91 @@ def principal @audience end + # Returns the Regional Access Boundary lookup URL for external account credentials. + # + # Design (Fail Open): + # Returning nil (e.g. if parsing the impersonation email fails) skips the lookup, + # allowing the request to proceed without the header. + # + # @private + # @return [String, nil] the constructed allowedLocations URL, or nil if + # required parameters (e.g. email) are missing. + # @raise [Google::Auth::AuthorizationError] if the audience format is + # unknown or universe domain validation fails. + def regional_access_boundary_url + return impersonated_rab_url if @service_account_impersonation_url + + # Workforce Pool check + wf_match = @audience.match WORKFORCE_AUDIENCE_PATTERN + return workforce_rab_url wf_match if wf_match + + # Workload Pool check + wl_match = @audience.match WORKLOAD_AUDIENCE_PATTERN + return workload_rab_url wl_match if wl_match + + raise Google::Auth::AuthorizationError, "Unknown audience format: #{@audience}" + end + + # Enable Regional Access Boundaries for External Account credentials. + # + # @private + # @return [Boolean] true + def supports_regional_access_boundary? + true + end + private + # @private + # @return [String, nil] + def impersonated_rab_url + match = @service_account_impersonation_url.match %r{serviceAccounts/([^:]+):generateAccessToken$} + email = match[1] if match + return unless email + "https://iamcredentials.googleapis.com/v1/projects/-/" \ + "serviceAccounts/#{email}/allowedLocations" + end + + # @private + # @param wf_match [MatchData] the audience match data. + # @return [String, nil] + def workforce_rab_url wf_match + audience_domain = wf_match[1] + pool_id = wf_match[3] + + validate_universe_domain! audience_domain + + return unless pool_id + "https://iamcredentials.googleapis.com/v1/locations/global/" \ + "workforcePools/#{pool_id}/allowedLocations" + end + + # @private + # @param wl_match [MatchData] the audience match data. + # @return [String] + def workload_rab_url wl_match + audience_domain = wl_match[1] + project_number = wl_match[2] + pool_id = wl_match[4] + + validate_universe_domain! audience_domain + + "https://iamcredentials.googleapis.com/v1/projects/#{project_number}/" \ + "locations/global/workloadIdentityPools/#{pool_id}/allowedLocations" + end + + # @private + # @param audience_domain [String] the audience domain. + # @raise [Google::Auth::AuthorizationError] if validation fails. + # @return [void] + def validate_universe_domain! audience_domain + effective_universe_domain = universe_domain || "googleapis.com" + return unless audience_domain != effective_universe_domain + raise Google::Auth::AuthorizationError, + "Provided universe domain (#{effective_universe_domain}) does " \ + "not match domain in audience (#{audience_domain})" + end + def token_type # This method is needed for BaseClient :access_token diff --git a/lib/googleauth/helpers/connection.rb b/lib/googleauth/helpers/connection.rb index c0c5d534..9df87fd3 100644 --- a/lib/googleauth/helpers/connection.rb +++ b/lib/googleauth/helpers/connection.rb @@ -35,6 +35,20 @@ def default_connection= conn def connection @default_connection || Faraday.default_connection end + + # Resolves the Faraday connection to use for a given credentials client. + # + # First checks if the client implements `build_default_connection` (used by Signet). + # Next checks if the client implements `#connection` (used by External Account credentials). + # Falls back to the global configured default connection helper. + # + # @param client [Object] the credentials client object. + # @return [Faraday::Connection] the resolved connection. + def connection_for client + conn = client.build_default_connection if client.respond_to? :build_default_connection + conn ||= client.connection if client.respond_to? :connection + conn || connection + end end end end diff --git a/lib/googleauth/impersonated_service_account.rb b/lib/googleauth/impersonated_service_account.rb index a4d50b6f..44504e61 100644 --- a/lib/googleauth/impersonated_service_account.rb +++ b/lib/googleauth/impersonated_service_account.rb @@ -258,6 +258,29 @@ def principal end end + # Returns the regional access boundary lookup URL. + # Constructs the URL based on the impersonation URL. + # + # Design (Fail Open): + # Returning nil skips the lookup, allowing the request to proceed without the header. + # + # @private + # @return [String, nil] the allowedLocations endpoint URL, or nil if impersonation email cannot be parsed. + def regional_access_boundary_url + match = @impersonation_url.match %r{serviceAccounts/([^:]+):generateAccessToken$} + email = match[1] if match + return "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/#{email}/allowedLocations" if email + nil + end + + # Enable Regional Access Boundaries for Impersonated credentials. + # + # @private + # @return [Boolean] true + def supports_regional_access_boundary? + true + end + private # Generates a new impersonation access token by exchanging the source credentials' token diff --git a/lib/googleauth/regional_access_boundary.rb b/lib/googleauth/regional_access_boundary.rb new file mode 100644 index 00000000..d8686fcc --- /dev/null +++ b/lib/googleauth/regional_access_boundary.rb @@ -0,0 +1,35 @@ +# 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 +# +# 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. + +module Google + module Auth + # RegionalAccessBoundary provides support for Regional Access Boundaries. + # + # @private + module RegionalAccessBoundary + autoload :RegionalAccessBoundaryData, "googleauth/regional_access_boundary/data" + autoload :Cache, "googleauth/regional_access_boundary/cache" + autoload :Fetcher, "googleauth/regional_access_boundary/fetcher" + + @cache = Cache.new + + # Returns the module-level cache instance. + # + # @return [Google::Auth::RegionalAccessBoundary::Cache] the cache instance. + def self.cache + @cache + end + end + end +end diff --git a/lib/googleauth/regional_access_boundary/cache.rb b/lib/googleauth/regional_access_boundary/cache.rb new file mode 100644 index 00000000..65ff54ab --- /dev/null +++ b/lib/googleauth/regional_access_boundary/cache.rb @@ -0,0 +1,126 @@ +# 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 +# +# 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. + +require "monitor" + +module Google + module Auth + module RegionalAccessBoundary + # Cache stores and manages the lifecycle of Regional Access Boundary data. + # + # @private + class Cache + include MonitorMixin + + def initialize + super() + @data = nil + @expiry = nil + @is_fetching = false + @fetching_pid = nil + @cooldown_expiry = nil + @cooldown_duration = 15 * 60 # 15 minutes in seconds + end + + # Returns the cached data if valid and not expired. + # + # @return [Google::Auth::RegionalAccessBoundary::RegionalAccessBoundaryData, nil] the cached data, + # or nil if cache is empty or expired. + def get + synchronize do + return nil if @data.nil? + # Do NOT attach header if NOW >= expireTime; treat as cache miss & trigger async lookup. + return nil if Time.now > @expiry + @data + end + end + + # Sets the data in cache with a TTL. + # + # @param data [Google::Auth::RegionalAccessBoundary::RegionalAccessBoundaryData] the data to cache. + # @param ttl [Numeric] time-to-live in seconds. + # @return [void] + def set data, ttl + synchronize do + @data = data + @expiry = Time.now + ttl + @is_fetching = false + @fetching_pid = nil + @cooldown_expiry = nil + @cooldown_duration = 15 * 60 # reset cooldown + end + end + + # Determines if a fetch should be initiated. + # + # @return [Boolean] true if a background fetch is needed, false otherwise. + def should_fetch? + synchronize do + # If fetching but PID changed, the fetching thread was lost in fork. + return true if @is_fetching && @fetching_pid != Process.pid + + # If already fetching in this process, don't fetch again. + return false if @is_fetching + + # Before starting a background lookup, verify the cooldown state; if active, skip. + return false if @cooldown_expiry && Time.now < @cooldown_expiry + + return true if @data.nil? + return true if Time.now > @expiry # Hard expiry + + # Trigger refresh if NOW > expireTime - 1h; still attach header as data is valid. + return true if Time.now > (@expiry - 3600) + + false + end + end + + # Attempts to transition the cache status to fetching if a fetch is needed. + # + # @return [Boolean] true if successfully marked as fetching, false otherwise. + def try_mark_fetching! + synchronize do + if should_fetch? + @is_fetching = true + @fetching_pid = Process.pid + true + else + false + end + end + end + + # Marks the fetch as failed, triggering cooldown. + # + # @return [void] + def mark_fetch_failed! + synchronize do + @is_fetching = false + @fetching_pid = nil + + # If a lookup fails with a non-retriable error (or after retry + # exhaustion), initiate a 15-minute cooldown period with exponential + # backoff (up to 6 hours). + # Add random bounded jitter (half of base to full base) + jitter = (@cooldown_duration / 2) + rand(@cooldown_duration / 2) + @cooldown_expiry = Time.now + jitter + + # Exponential backoff for the NEXT attempt, up to 6 hours max + @cooldown_duration = [@cooldown_duration * 2, 6 * 60 * 60].min + end + end + end + end + end +end diff --git a/lib/googleauth/regional_access_boundary/data.rb b/lib/googleauth/regional_access_boundary/data.rb new file mode 100644 index 00000000..f7acd567 --- /dev/null +++ b/lib/googleauth/regional_access_boundary/data.rb @@ -0,0 +1,32 @@ +# 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 +# +# 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. + +module Google + module Auth + module RegionalAccessBoundary + # RegionalAccessBoundaryData holds the encoded locations for Regional Access Boundary. + # + # @private + class RegionalAccessBoundaryData + # @return [String] the base64-encoded allowed locations payload. + attr_reader :encoded_locations + + # @param encoded_locations [String] the base64-encoded allowed locations payload. + def initialize encoded_locations + @encoded_locations = encoded_locations + end + end + end + end +end diff --git a/lib/googleauth/regional_access_boundary/fetcher.rb b/lib/googleauth/regional_access_boundary/fetcher.rb new file mode 100644 index 00000000..1bd1aba5 --- /dev/null +++ b/lib/googleauth/regional_access_boundary/fetcher.rb @@ -0,0 +1,114 @@ +# 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 +# +# 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. + +require "faraday" +require "multi_json" +require "googleauth/errors" + +module Google + module Auth + module RegionalAccessBoundary + # TransientLookupError is raised when a transient error occurs during lookup, + # signaling that the request should be retried. + # + # @private + class TransientLookupError < StandardError; end + + # Fetcher handles retrieving Regional Access Boundary data from the API. + # + # @private + class Fetcher + # @param client [Faraday::Connection] the HTTP client used to fetch allowedLocations. + # @param url [String] the allowedLocations endpoint URL. + # @param token [Object] the credentials token instance. + def initialize client, url, token + @client = client + @url = url + @token = token + end + + # Fetches the data, applying retry logic for transient errors. + # + # @raise [Google::Auth::AuthorizationError] if the fetch fails. + # @return [Google::Auth::RegionalAccessBoundary::RegionalAccessBoundaryData] the fetched data. + def fetch + start_time = Time.now + attempt = 0 + + # Perform retry with exponential backoff for up to one minute. + loop do + attempt += 1 + begin + response = perform_request + return handle_response response + rescue StandardError => e + handle_error e, attempt, start_time + end + end + end + + private + + # @return [Faraday::Response] the HTTP response. + def perform_request + @client.get @url do |req| + # token_type is private in some credentials, so we use send to access it. + token_name = @token.send :token_type + token_val = @token.send token_name + req.headers["Authorization"] = "Bearer #{token_val}" + end + end + + # @param response [Faraday::Response] the HTTP response. + # @raise [Google::Auth::AuthorizationError] if the response contains invalid data. + # @raise [TransientLookupError] if response is retryable (5xx). + # @return [Google::Auth::RegionalAccessBoundary::RegionalAccessBoundaryData] + def handle_response response + if response.status == 200 + body = MultiJson.load response.body + if body["encodedLocations"].nil? || body["encodedLocations"].empty? + raise Google::Auth::AuthorizationError, "Invalid response: encodedLocations is empty" + end + # Use fully qualified name to avoid resolution issues + Google::Auth::RegionalAccessBoundary::RegionalAccessBoundaryData.new body["encodedLocations"] + elsif [500, 502, 503, 504].include? response.status + # Retryable errors + raise TransientLookupError, "Status: #{response.status}" + else + raise Google::Auth::AuthorizationError, "Lookup failed with status #{response.status}" + end + end + + # @param error [StandardError] the error to evaluate. + # @param attempt [Integer] the current retry attempt count. + # @param start_time [Time] the timestamp when the first lookup attempt started. + # @raise [Google::Auth::AuthorizationError] if the error is not retryable or retries are exhausted. + # @return [void] + def handle_error error, attempt, start_time + # Check if we should retry + is_retryable = error.is_a?(TransientLookupError) || error.is_a?(Faraday::Error) + + raise Google::Auth::AuthorizationError, "RAB lookup failed: #{error.message}" unless is_retryable + if Time.now - start_time > 60 + raise Google::Auth::AuthorizationError, "Retries exhausted for RAB lookup: #{error.message}" + end + + # Exponential backoff: 1s, 2s, 4s... up to 60s max + sleep_time = [2**(attempt - 1), 60].min + sleep sleep_time + end + end + end + end +end diff --git a/lib/googleauth/service_account.rb b/lib/googleauth/service_account.rb index ba49d2e6..19529fe2 100644 --- a/lib/googleauth/service_account.rb +++ b/lib/googleauth/service_account.rb @@ -184,6 +184,22 @@ def principal @issuer end + # Returns the regional access boundary lookup URL. + # + # @private + # @return [String] the allowedLocations endpoint URL. + def regional_access_boundary_url + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/#{@issuer}/allowedLocations" + end + + # Enable Regional Access Boundaries for Service Account credentials. + # + # @private + # @return [Boolean] true + def supports_regional_access_boundary? + true + end + private def apply_self_signed_jwt! a_hash diff --git a/samples/regional_access_boundary/README.md b/samples/regional_access_boundary/README.md new file mode 100644 index 00000000..90f75a39 --- /dev/null +++ b/samples/regional_access_boundary/README.md @@ -0,0 +1,56 @@ +# Regional Access Boundary Sample Scripts + +This directory contains sample scripts to demonstrate and verify the behavior of the Regional Access Boundary (RAB) feature in the Ruby auth library. + +## How to Run the Samples + +To run most samples, you need a valid Service Account JSON key file. Set the `GOOGLE_APPLICATION_CREDENTIALS` environment variable to point to your key file, and run the script using `bundle exec ruby`: + +```bash +export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/service_account.json" + +bundle exec ruby samples/regional_access_boundary/default_universe.rb +``` + +### Samples requiring NO ambient credentials + +Some samples are designed to verify that unsupported credentials (like User Credentials) correctly skip the RAB lookup. For these samples, you should **unset** the `GOOGLE_APPLICATION_CREDENTIALS` environment variable so they fall back to ambient user credentials: + +* `unsupported.rb` + +```bash +unset GOOGLE_APPLICATION_CREDENTIALS + +bundle exec ruby samples/regional_access_boundary/unsupported.rb +``` + +## What to Look For + +Each sample script ends with a clear statement of success or failure. Look for lines starting with `Success!` or `Failure!`. + +For example, in `unsupported.rb`: +``` +Success! RAB header is not present for unsupported credentials. +``` + +To protect your secrets, all samples redact the `Authorization` header value in the printed output. + +## Mocking and Dummy Configs + +To make these samples testable without requiring complex external environments (like Azure or Okta for Workload Identity, or a slow metadata server), we used `WebMock` to mock network calls in some scripts: + +* **`workload_identity.rb` and `workforce_identity.rb`**: These use dummy JSON configuration files (`workload_identity_config.json` and `workforce_identity_config.json`) and use `WebMock` to simulate the external token source and the STS token exchange. This allows verifying the full code path for audience parsing and URL construction without real external identities. +* **`lookup_error.rb`, `retryable_error.rb`, `malformed_response.rb`, `cooldown_recovery.rb`**: These use `WebMock` to simulate various failure modes of the IAM lookup endpoint to verify fail-open, retry, and cooldown behaviors. + +### Transparency Note + +While these scripts use `WebMock` to intercept network calls, they are **not fake**. They exercise the real code paths in `BaseClient`, `Fetcher`, and `Cache` exactly as they would run in production. The mocking is only used to provide predictable inputs and simulate backend responses that are difficult to recreate in a simple local environment without extensive infrastructure setup. They rightfully assert their value by providing a way to verify the complex logic stack (async fetch, cache, retries) in a reproducible way. + +## Known Gaps + +The following gaps in verification were identified during implementation and are tracked here for completeness (also documented in `docs/rab/GAPS.md` at the project root): + +* **Immediate Failure Logging**: In `lookup_error.rb`, we use a 500 error which triggers retries. We do not see the warning log immediately in the sample output because the script ends before retries are exhausted. +* **Compute Engine Email Failure**: We cannot easily simulate failure to fetch the email from the metadata server in a sample script without complex mocking of `Google::Cloud.env`. This is covered by unit tests. +* **Time-dependent behaviors**: Samples for `hard_expiry.rb` and `soft_expiry.rb` require stubbing time or waiting for hours, which is not practical in simple scripts without adding dependencies like `Timecop`. These are covered by unit tests. +* **Impersonated Credentials Success Path**: We couldn't easily verify the success path for `ImpersonatedServiceAccountCredentials` (actually attaching the header) in a sample script because it requires a complex IAM setup for impersonation. This is covered by unit tests. diff --git a/samples/regional_access_boundary/cooldown_recovery.rb b/samples/regional_access_boundary/cooldown_recovery.rb new file mode 100644 index 00000000..3131a184 --- /dev/null +++ b/samples/regional_access_boundary/cooldown_recovery.rb @@ -0,0 +1,98 @@ +# 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 +# +# 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. + +require "googleauth" +require "faraday" +require "multi_json" +require "webmock" +require "logger" + +include WebMock::API + +def main + # Enable WebMock but allow real connections for token fetching + WebMock.enable! + WebMock.allow_net_connect! + + puts "Loading credentials..." + begin + credentials = Google::Auth.get_application_default ["https://www.googleapis.com/auth/cloud-platform"] + rescue StandardError => e + puts "Failed to load credentials: #{e.message}" + return + end + + credentials.logger = Logger.new $stdout + credentials.logger.level = Logger::INFO + + puts "Credential Type: #{credentials.class.name}" + + if credentials.is_a? Google::Auth::ServiceAccountCredentials + email = credentials.instance_variable_get :@issuer + url = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/#{email}/allowedLocations" + + # Stub the RAB lookup endpoint to fail first, then succeed + # WebMock allows chaining responses with .then + stub_request(:get, url) + .to_return(status: 500, body: "Internal Server Error").then + .to_return(status: 200, body: MultiJson.dump({ "encodedLocations" => "0x7ffffffffffffffe" })) + + puts "Stubbed #{url} to fail first, then succeed." + else + puts "This sample requires ServiceAccountCredentials to run correctly." + WebMock.disable! + return + end + + # Force a short cooldown for testing purposes using Ruby's instance_variable_set + cache = Google::Auth::RegionalAccessBoundary.cache + cache.instance_variable_set :@cooldown_duration, 2 + puts "Forced cache cooldown duration to 2 seconds." + + bucket_name = "trust_boundary_test_bucket" + url = "https://storage.googleapis.com/storage/v1/b/#{bucket_name}" + + headers = {} + + puts "--- First Call to apply! (should trigger fetch and fail) ---" + credentials.apply! headers, url: url + + puts "\nSleeping for 3 seconds to let cooldown expire..." + sleep 3 + + puts "\n--- Second Call to apply! (should trigger fetch again and succeed) ---" + headers = {} + credentials.apply! headers, url: url + + puts "\nSleeping for 2 seconds to let background fetch complete..." + sleep 2 + + puts "\n--- Third Call to apply! (should have header) ---" + headers = {} + credentials.apply! headers, url: url + + puts "Headers (Third attempt):" + puts "x-allowed-locations: #{headers['x-allowed-locations'] || 'NOT PRESENT'}" + + if headers["x-allowed-locations"] == "0x7ffffffffffffffe" + puts "Success! RAB header recovered after cooldown." + else + puts "Failure! RAB header should be present after cooldown recovery." + end + + # Clean up + WebMock.disable! +end + +main if __FILE__ == $PROGRAM_NAME diff --git a/samples/regional_access_boundary/default_universe.rb b/samples/regional_access_boundary/default_universe.rb new file mode 100644 index 00000000..81d732ad --- /dev/null +++ b/samples/regional_access_boundary/default_universe.rb @@ -0,0 +1,93 @@ +# 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 +# +# 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. + +require "googleauth" +require "faraday" +require "multi_json" +require "logger" + +def main + # To run this sample, set the GOOGLE_APPLICATION_CREDENTIALS environment variable + # to a valid service account JSON key file. + # ENV["GOOGLE_APPLICATION_CREDENTIALS"] = "path/to/service_account.json" + + puts "Loading credentials..." + begin + credentials = Google::Auth.get_application_default ["https://www.googleapis.com/auth/cloud-platform"] + rescue StandardError => e + puts "Failed to load credentials: #{e.message}" + puts "Please ensure GOOGLE_APPLICATION_CREDENTIALS is set to a valid JSON file." + return + end + + credentials.logger = Logger.new $stdout + + puts "Credential Type: #{credentials.class.name}" + puts "Universe Domain: #{credentials.universe_domain}" + + # Replace with name of a bucket that your account has access to + bucket_name = "trust_boundary_test_bucket" + url = "https://storage.googleapis.com/storage/v1/b/#{bucket_name}" + + headers = {} + + puts "--- First Call to apply! ---" + begin + puts "Token Type: #{credentials.token_type}" + result = credentials.apply! headers, url: url + puts "apply! return value: Bearer " if result + redacted_headers = headers.dup + if redacted_headers[:authorization] + redacted_headers[:authorization] = "Bearer " + end + puts "Headers after apply!: #{redacted_headers.inspect}" + rescue StandardError => e + puts "Error in apply!: #{e.message}" + return + end + + puts "Headers (First attempt):" + puts "x-allowed-locations: #{headers['x-allowed-locations'] || 'NOT PRESENT (Expected for cold start in default universe)'}" + + puts "\nSleeping for 5 seconds to let background RAB lookup finish..." + sleep 5 + + headers = {} + puts "--- Second Call to apply! ---" + begin + credentials.apply! headers, url: url + rescue StandardError => e + puts "Error in apply!: #{e.message}" + return + end + + puts "Headers (Second attempt):" + x_allowed_locations = headers["x-allowed-locations"] + puts "x-allowed-locations: #{x_allowed_locations || 'STILL NOT PRESENT (Lookup might have failed or still in progress)'}" + + if x_allowed_locations + puts "Success! RAB header is present for the default universe." + else + puts "Failure! RAB header should be present for the default universe if configured." + end + + puts "\nFull Headers Hash (Redacted):" + redacted_headers = headers.dup + if redacted_headers[:authorization] + redacted_headers[:authorization] = "Bearer " + end + puts MultiJson.dump(redacted_headers, pretty: true) +end + +main if __FILE__ == $PROGRAM_NAME diff --git a/samples/regional_access_boundary/impersonated.rb b/samples/regional_access_boundary/impersonated.rb new file mode 100644 index 00000000..4b9e9224 --- /dev/null +++ b/samples/regional_access_boundary/impersonated.rb @@ -0,0 +1,92 @@ +# 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 +# +# 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. + +require "googleauth" +require "faraday" +require "multi_json" +require "logger" + +def main + puts "Loading base credentials..." + begin + base_credentials = Google::Auth.get_application_default ["https://www.googleapis.com/auth/cloud-platform"] + rescue StandardError => e + puts "Failed to load base credentials: #{e.message}" + return + end + + # Use the service account email from your previous output as the target + target_email = "chrisdsmith-tests@helical-zone-771.iam.gserviceaccount.com" + impersonation_url = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/#{target_email}:generateAccessToken" + + puts "Creating Impersonated Credentials..." + credentials = Google::Auth::ImpersonatedServiceAccountCredentials.new( + base_credentials: base_credentials, + impersonation_url: impersonation_url, + scope: ["https://www.googleapis.com/auth/cloud-platform"] + ) + + base_credentials.logger = Logger.new $stdout + base_credentials.logger.level = Logger::INFO + + puts "Credential Type: #{credentials.class.name}" + puts "Target Email: #{target_email}" + + bucket_name = "trust_boundary_test_bucket" + url = "https://storage.googleapis.com/storage/v1/b/#{bucket_name}" + + headers = {} + + puts "--- First Call to apply! ---" + begin + credentials.apply! headers, url: url + rescue StandardError => e + puts "Error in apply!: #{e.message}" + return + end + + puts "Headers:" + puts "x-allowed-locations: #{headers['x-allowed-locations'] || 'NOT PRESENT (Expected for cold start)'}" + + puts "\nSleeping for 5 seconds to let background fetch complete..." + sleep 5 + + headers = {} + puts "--- Second Call to apply! ---" + begin + credentials.apply! headers, url: url + rescue StandardError => e + puts "Error in apply!: #{e.message}" + return + end + + puts "Headers (Second attempt):" + x_allowed_locations = headers["x-allowed-locations"] + puts "x-allowed-locations: #{x_allowed_locations || 'STILL NOT PRESENT'}" + + if x_allowed_locations + puts "Success! RAB header is present for impersonated credentials." + else + puts "Failure! RAB header should be present if allowlisted." + end + + puts "\nFull Headers Hash (Redacted):" + redacted_headers = headers.dup + if redacted_headers[:authorization] + redacted_headers[:authorization] = "Bearer " + end + puts MultiJson.dump(redacted_headers, pretty: true) +end + +main if __FILE__ == $PROGRAM_NAME diff --git a/samples/regional_access_boundary/lookup_error.rb b/samples/regional_access_boundary/lookup_error.rb new file mode 100644 index 00000000..b0748497 --- /dev/null +++ b/samples/regional_access_boundary/lookup_error.rb @@ -0,0 +1,85 @@ +# 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 +# +# 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. + +require "googleauth" +require "faraday" +require "multi_json" +require "webmock" +require "logger" + +include WebMock::API + +def main + # Enable WebMock but allow real connections for token fetching + WebMock.enable! + WebMock.allow_net_connect! + + puts "Loading credentials..." + begin + credentials = Google::Auth.get_application_default ["https://www.googleapis.com/auth/cloud-platform"] + rescue StandardError => e + puts "Failed to load credentials: #{e.message}" + return + end + + credentials.logger = Logger.new $stdout + credentials.logger.level = Logger::INFO + + puts "Credential Type: #{credentials.class.name}" + + if credentials.is_a? Google::Auth::ServiceAccountCredentials + email = credentials.instance_variable_get :@issuer + url = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/#{email}/allowedLocations" + + # Stub the RAB lookup endpoint to return a 500 error + stub_request(:get, url) + .to_return(status: 500, body: "Internal Server Error") + + puts "Stubbed #{url} to return 500" + else + puts "This sample requires ServiceAccountCredentials to run correctly." + WebMock.disable! + return + end + + bucket_name = "trust_boundary_test_bucket" + url = "https://storage.googleapis.com/storage/v1/b/#{bucket_name}" + + headers = {} + + puts "--- First Call to apply! (should trigger fetch) ---" + credentials.apply! headers, url: url + + puts "\nSleeping for 2 seconds to let background fetch fail..." + sleep 2 + + puts "\n--- Second Call to apply! (should be in cooldown) ---" + headers = {} + credentials.apply! headers, url: url + + puts "Headers (Second attempt):" + x_allowed_locations = headers["x-allowed-locations"] + puts "x-allowed-locations: #{x_allowed_locations || 'NOT PRESENT (Expected after failure)'}" + + if x_allowed_locations + puts "Failure! RAB header should NOT be present after lookup failure." + else + puts "Success! RAB header is not present after lookup failure." + end + + # Clean up + WebMock.disable! +end + +main if __FILE__ == $PROGRAM_NAME diff --git a/samples/regional_access_boundary/malformed_response.rb b/samples/regional_access_boundary/malformed_response.rb new file mode 100644 index 00000000..484931e8 --- /dev/null +++ b/samples/regional_access_boundary/malformed_response.rb @@ -0,0 +1,85 @@ +# 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 +# +# 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. + +require "googleauth" +require "faraday" +require "multi_json" +require "webmock" +require "logger" + +include WebMock::API + +def main + # Enable WebMock but allow real connections for token fetching + WebMock.enable! + WebMock.allow_net_connect! + + puts "Loading credentials..." + begin + credentials = Google::Auth.get_application_default ["https://www.googleapis.com/auth/cloud-platform"] + rescue StandardError => e + puts "Failed to load credentials: #{e.message}" + return + end + + credentials.logger = Logger.new $stdout + credentials.logger.level = Logger::INFO + + puts "Credential Type: #{credentials.class.name}" + + if credentials.is_a? Google::Auth::ServiceAccountCredentials + email = credentials.instance_variable_get :@issuer + url = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/#{email}/allowedLocations" + + # Stub the RAB lookup endpoint to return a malformed response (missing encodedLocations) + stub_request(:get, url) + .to_return(status: 200, body: MultiJson.dump({ "locations" => ["us-central1"] })) + + puts "Stubbed #{url} to return malformed response" + else + puts "This sample requires ServiceAccountCredentials to run correctly." + WebMock.disable! + return + end + + bucket_name = "trust_boundary_test_bucket" + url = "https://storage.googleapis.com/storage/v1/b/#{bucket_name}" + + headers = {} + + puts "--- First Call to apply! (should trigger fetch) ---" + credentials.apply! headers, url: url + + puts "\nSleeping for 2 seconds to let background fetch fail on malformed response..." + sleep 2 + + puts "\n--- Second Call to apply! (should be in cooldown) ---" + headers = {} + credentials.apply! headers, url: url + + puts "Headers (Second attempt):" + x_allowed_locations = headers["x-allowed-locations"] + puts "x-allowed-locations: #{x_allowed_locations || 'NOT PRESENT (Expected after failure)'}" + + if x_allowed_locations + puts "Failure! RAB header should NOT be present after malformed response." + else + puts "Success! RAB header is not present after malformed response." + end + + # Clean up + WebMock.disable! +end + +main if __FILE__ == $PROGRAM_NAME diff --git a/samples/regional_access_boundary/non_default_universe.rb b/samples/regional_access_boundary/non_default_universe.rb new file mode 100644 index 00000000..bd71016e --- /dev/null +++ b/samples/regional_access_boundary/non_default_universe.rb @@ -0,0 +1,57 @@ +# 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 +# +# 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. + +require "googleauth" +require "faraday" +require "multi_json" + +def main + puts "Loading credentials..." + begin + credentials = Google::Auth.get_application_default + rescue StandardError => e + puts "Failed to load credentials: #{e.message}" + return + end + + # Force a non-default universe domain for testing + credentials.universe_domain = "example.com" + + puts "Credential Type: #{credentials.class.name}" + puts "Universe Domain: #{credentials.universe_domain}" + + bucket_name = "trust_boundary_test_bucket" + url = "https://storage.example.com/storage/v1/b/#{bucket_name}" + + headers = {} + + puts "--- Call to apply! with non-default universe ---" + begin + credentials.apply! headers, url: url + rescue StandardError => e + puts "Error in apply!: #{e.message}" + return + end + + puts "Headers:" + puts "x-allowed-locations: #{headers['x-allowed-locations'] || 'NOT PRESENT (Expected for non-default universe)'}" + + if headers["x-allowed-locations"] + puts "Failure! RAB header should NOT be present for non-default universe." + else + puts "Success! RAB header is not present for non-default universe." + end +end + +main if __FILE__ == $PROGRAM_NAME diff --git a/samples/regional_access_boundary/regional_endpoint.rb b/samples/regional_access_boundary/regional_endpoint.rb new file mode 100644 index 00000000..b8fc9e92 --- /dev/null +++ b/samples/regional_access_boundary/regional_endpoint.rb @@ -0,0 +1,54 @@ +# 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 +# +# 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. + +require "googleauth" +require "faraday" +require "multi_json" + +def main + puts "Loading credentials..." + begin + credentials = Google::Auth.get_application_default + rescue StandardError => e + puts "Failed to load credentials: #{e.message}" + return + end + + puts "Credential Type: #{credentials.class.name}" + puts "Universe Domain: #{credentials.universe_domain}" + + # Use a regional endpoint (matches .rep.googleapis.com) + url = "https://storage.rep.googleapis.com/v1/b/trust_boundary_test_bucket" + + headers = {} + + puts "--- Call to apply! with regional endpoint ---" + begin + credentials.apply! headers, url: url + rescue StandardError => e + puts "Error in apply!: #{e.message}" + return + end + + puts "Headers:" + puts "x-allowed-locations: #{headers['x-allowed-locations'] || 'NOT PRESENT (Expected for regional endpoint)'}" + + if headers["x-allowed-locations"] + puts "Failure! RAB header should NOT be present for regional endpoint." + else + puts "Success! RAB header is not present for regional endpoint." + end +end + +main if __FILE__ == $PROGRAM_NAME diff --git a/samples/regional_access_boundary/retryable_error.rb b/samples/regional_access_boundary/retryable_error.rb new file mode 100644 index 00000000..64abf252 --- /dev/null +++ b/samples/regional_access_boundary/retryable_error.rb @@ -0,0 +1,85 @@ +# 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 +# +# 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. + +require "googleauth" +require "faraday" +require "multi_json" +require "webmock" +require "logger" + +include WebMock::API + +def main + # Enable WebMock but allow real connections for token fetching + WebMock.enable! + WebMock.allow_net_connect! + + puts "Loading credentials..." + begin + credentials = Google::Auth.get_application_default ["https://www.googleapis.com/auth/cloud-platform"] + rescue StandardError => e + puts "Failed to load credentials: #{e.message}" + return + end + + credentials.logger = Logger.new $stdout + credentials.logger.level = Logger::INFO + + puts "Credential Type: #{credentials.class.name}" + + if credentials.is_a? Google::Auth::ServiceAccountCredentials + email = credentials.instance_variable_get :@issuer + url = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/#{email}/allowedLocations" + + # Stub the RAB lookup endpoint to return a 500 error + stub_request(:get, url) + .to_return(status: 500, body: "Internal Server Error") + + puts "Stubbed #{url} to return 500" + else + puts "This sample requires ServiceAccountCredentials to run correctly." + WebMock.disable! + return + end + + bucket_name = "trust_boundary_test_bucket" + url = "https://storage.googleapis.com/storage/v1/b/#{bucket_name}" + + headers = {} + + puts "--- First Call to apply! (should trigger fetch) ---" + credentials.apply! headers, url: url + + puts "\nSleeping for 70 seconds to let background fetch exhaust retries and fail..." + sleep 70 + + puts "\n--- Second Call to apply! (should be in cooldown) ---" + headers = {} + credentials.apply! headers, url: url + + puts "Headers (Second attempt):" + x_allowed_locations = headers["x-allowed-locations"] + puts "x-allowed-locations: #{x_allowed_locations || 'NOT PRESENT (Expected after failure)'}" + + if x_allowed_locations + puts "Failure! RAB header should NOT be present after failure." + else + puts "Success! RAB header is not present after lookup failure." + end + + # Clean up + WebMock.disable! +end + +main if __FILE__ == $PROGRAM_NAME diff --git a/samples/regional_access_boundary/self_signed_jwt.rb b/samples/regional_access_boundary/self_signed_jwt.rb new file mode 100644 index 00000000..a71e038a --- /dev/null +++ b/samples/regional_access_boundary/self_signed_jwt.rb @@ -0,0 +1,76 @@ +# 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 +# +# 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. + +require "googleauth" +require "faraday" +require "multi_json" +require "logger" + +def main + puts "Loading credentials..." + begin + credentials = Google::Auth.get_application_default ["https://www.googleapis.com/auth/cloud-platform"] + rescue StandardError => e + puts "Failed to load credentials: #{e.message}" + return + end + + # Force self-signed JWT if it is a ServiceAccountCredentials + if credentials.is_a? Google::Auth::ServiceAccountCredentials + credentials.instance_variable_set :@enable_self_signed_jwt, true + puts "Forced enable_self_signed_jwt = true" + else + puts "This sample requires ServiceAccountCredentials to run correctly." + return + end + + credentials.logger = Logger.new $stdout + credentials.logger.level = Logger::INFO + + puts "Credential Type: #{credentials.class.name}" + + bucket_name = "trust_boundary_test_bucket" + url = "https://storage.googleapis.com/storage/v1/b/#{bucket_name}" + + headers = {} + + # Set the JWT audience key to avoid returning early in self-signed JWT logic + headers["jwt_aud_uri"] = "https://storage.googleapis.com/" + + puts "--- Call to apply! with self-signed JWT ---" + begin + credentials.apply! headers, url: url + rescue StandardError => e + puts "Error in apply!: #{e.message}" + return + end + + puts "Headers:" + puts "x-allowed-locations: #{headers['x-allowed-locations'] || 'NOT PRESENT (Expected for self-signed JWT)'}" + + if headers["x-allowed-locations"] + puts "Failure! RAB header should NOT be present for self-signed JWT." + else + puts "Success! RAB header is not present for self-signed JWT." + end + + puts "\nFull Headers Hash (Redacted):" + redacted_headers = headers.dup + if redacted_headers[:authorization] + redacted_headers[:authorization] = "Bearer " + end + puts MultiJson.dump(redacted_headers, pretty: true) +end + +main if __FILE__ == $PROGRAM_NAME diff --git a/samples/regional_access_boundary/unsupported.rb b/samples/regional_access_boundary/unsupported.rb new file mode 100644 index 00000000..268c0ae2 --- /dev/null +++ b/samples/regional_access_boundary/unsupported.rb @@ -0,0 +1,55 @@ +# 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 +# +# 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. + +require "googleauth" +require "faraday" +require "multi_json" + +def main + puts "Loading credentials..." + begin + # This will typically load UserRefreshCredentials if run on a local machine + # after running `gcloud auth application-default login` + credentials = Google::Auth.get_application_default ["https://www.googleapis.com/auth/cloud-platform"] + rescue StandardError => e + puts "Failed to load credentials: #{e.message}" + return + end + + puts "Credential Type: #{credentials.class.name}" + + bucket_name = "trust_boundary_test_bucket" + url = "https://storage.googleapis.com/storage/v1/b/#{bucket_name}" + + headers = {} + + puts "--- Call to apply! with unsupported credentials ---" + begin + credentials.apply! headers, url: url + rescue StandardError => e + puts "Error in apply!: #{e.message}" + return + end + + puts "Headers:" + puts "x-allowed-locations: #{headers['x-allowed-locations'] || 'NOT PRESENT (Expected for unsupported credentials)'}" + + if headers["x-allowed-locations"] + puts "Failure! RAB header should NOT be present for unsupported credentials." + else + puts "Success! RAB header is not present for unsupported credentials." + end +end + +main if __FILE__ == $PROGRAM_NAME diff --git a/samples/regional_access_boundary/workforce_identity.rb b/samples/regional_access_boundary/workforce_identity.rb new file mode 100644 index 00000000..4847db09 --- /dev/null +++ b/samples/regional_access_boundary/workforce_identity.rb @@ -0,0 +1,117 @@ +# 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 +# +# 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. + +require "googleauth" +require "faraday" +require "multi_json" +require "webmock" +require "logger" + +include WebMock::API + +def main + # Enable WebMock but allow real connections for other potential calls + WebMock.enable! + WebMock.allow_net_connect! + + config_path = File.expand_path "workforce_identity_config.json", __dir__ + ENV["GOOGLE_APPLICATION_CREDENTIALS"] = config_path + + puts "Loading credentials from #{config_path}..." + begin + credentials = Google::Auth.get_application_default ["https://www.googleapis.com/auth/cloud-platform"] + rescue StandardError => e + puts "Failed to load credentials: #{e.message}" + WebMock.disable! + return + end + + credentials.logger = Logger.new $stdout + credentials.logger.level = Logger::INFO + + puts "Credential Type: #{credentials.class.name}" + puts "Universe Domain: #{credentials.universe_domain}" + + # 1. Stub the external token source + stub_request(:get, "http://dummyurl.com/token") + .to_return(status: 200, body: MultiJson.dump({ "access_token" => "external_subject_token" })) + + # 2. Stub the STS token exchange + stub_request(:post, "https://sts.googleapis.com/v1/token") + .to_return(status: 200, body: MultiJson.dump({ + "access_token" => "sts_access_token", + "issued_token_type" => "urn:ietf:params:oauth:token-type:access_token", + "token_type" => "Bearer", + "expires_in" => 3600 + })) + + # 3. Stub the RAB lookup endpoint for Workforce Identity + url = "https://iamcredentials.googleapis.com/v1/locations/global/workforcePools/POOL_ID/allowedLocations" + stub_request(:get, url) + .to_return(status: 200, body: MultiJson.dump({ "encodedLocations" => "0x7ffffffffffffffe" })) + + puts "Stubbed external token source, STS exchange, and RAB lookup." + + bucket_name = "trust_boundary_test_bucket" + url = "https://storage.googleapis.com/storage/v1/b/#{bucket_name}" + + headers = {} + + puts "--- First Call to apply! (should trigger fetch) ---" + begin + credentials.apply! headers, url: url + rescue StandardError => e + puts "Error in apply!: #{e.message}" + WebMock.disable! + return + end + + puts "Headers (First attempt):" + puts "x-allowed-locations: #{headers['x-allowed-locations'] || 'NOT PRESENT (Expected for cold start)'}" + + puts "\nSleeping for 5 seconds to let background fetch complete..." + sleep 5 + + headers = {} + puts "--- Second Call to apply! ---" + begin + credentials.apply! headers, url: url + rescue StandardError => e + puts "Error in apply!: #{e.message}" + WebMock.disable! + return + end + + puts "Headers (Second attempt):" + x_allowed_locations = headers["x-allowed-locations"] + puts "x-allowed-locations: #{x_allowed_locations || 'STILL NOT PRESENT'}" + + if x_allowed_locations == "0x7ffffffffffffffe" + puts "Success! RAB header is present for workforce identity." + else + puts "Failure! RAB header should be present for workforce identity." + end + + puts "\nFull Headers Hash (Redacted):" + redacted_headers = headers.dup + if redacted_headers[:authorization] + redacted_headers[:authorization] = "Bearer " + end + puts MultiJson.dump(redacted_headers, pretty: true) + + # Clean up + WebMock.disable! +end + +main if __FILE__ == $PROGRAM_NAME diff --git a/samples/regional_access_boundary/workforce_identity_config.json b/samples/regional_access_boundary/workforce_identity_config.json new file mode 100644 index 00000000..7de88645 --- /dev/null +++ b/samples/regional_access_boundary/workforce_identity_config.json @@ -0,0 +1,13 @@ +{ + "type": "external_account", + "audience": "//iam.googleapis.com/locations/global/workforcePools/POOL_ID/providers/PROVIDER_ID", + "subject_token_type": "urn:ietf:params:oauth:token-type:id_token", + "token_url": "https://sts.googleapis.com/v1/token", + "credential_source": { + "url": "http://dummyurl.com/token", + "format": { + "type": "json", + "subject_token_field_name": "access_token" + } + } +} diff --git a/samples/regional_access_boundary/workload_identity.rb b/samples/regional_access_boundary/workload_identity.rb new file mode 100644 index 00000000..8f6c54ca --- /dev/null +++ b/samples/regional_access_boundary/workload_identity.rb @@ -0,0 +1,117 @@ +# 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 +# +# 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. + +require "googleauth" +require "faraday" +require "multi_json" +require "webmock" +require "logger" + +include WebMock::API + +def main + # Enable WebMock but allow real connections for other potential calls + WebMock.enable! + WebMock.allow_net_connect! + + config_path = File.expand_path "workload_identity_config.json", __dir__ + ENV["GOOGLE_APPLICATION_CREDENTIALS"] = config_path + + puts "Loading credentials from #{config_path}..." + begin + credentials = Google::Auth.get_application_default ["https://www.googleapis.com/auth/cloud-platform"] + rescue StandardError => e + puts "Failed to load credentials: #{e.message}" + WebMock.disable! + return + end + + credentials.logger = Logger.new $stdout + credentials.logger.level = Logger::INFO + + puts "Credential Type: #{credentials.class.name}" + puts "Universe Domain: #{credentials.universe_domain}" + + # 1. Stub the external token source + stub_request(:get, "http://dummyurl.com/token") + .to_return(status: 200, body: MultiJson.dump({ "access_token" => "external_subject_token" })) + + # 2. Stub the STS token exchange + stub_request(:post, "https://sts.googleapis.com/v1/token") + .to_return(status: 200, body: MultiJson.dump({ + "access_token" => "sts_access_token", + "issued_token_type" => "urn:ietf:params:oauth:token-type:access_token", + "token_type" => "Bearer", + "expires_in" => 3600 + })) + + # 3. Stub the RAB lookup endpoint for Workload Identity + url = "https://iamcredentials.googleapis.com/v1/projects/123456/locations/global/workloadIdentityPools/POOL_ID/allowedLocations" + stub_request(:get, url) + .to_return(status: 200, body: MultiJson.dump({ "encodedLocations" => "0x7ffffffffffffffe" })) + + puts "Stubbed external token source, STS exchange, and RAB lookup." + + bucket_name = "trust_boundary_test_bucket" + url = "https://storage.googleapis.com/storage/v1/b/#{bucket_name}" + + headers = {} + + puts "--- First Call to apply! (should trigger fetch) ---" + begin + credentials.apply! headers, url: url + rescue StandardError => e + puts "Error in apply!: #{e.message}" + WebMock.disable! + return + end + + puts "Headers (First attempt):" + puts "x-allowed-locations: #{headers['x-allowed-locations'] || 'NOT PRESENT (Expected for cold start)'}" + + puts "\nSleeping for 5 seconds to let background fetch complete..." + sleep 5 + + headers = {} + puts "--- Second Call to apply! ---" + begin + credentials.apply! headers, url: url + rescue StandardError => e + puts "Error in apply!: #{e.message}" + WebMock.disable! + return + end + + puts "Headers (Second attempt):" + x_allowed_locations = headers["x-allowed-locations"] + puts "x-allowed-locations: #{x_allowed_locations || 'STILL NOT PRESENT'}" + + if x_allowed_locations == "0x7ffffffffffffffe" + puts "Success! RAB header is present for workload identity." + else + puts "Failure! RAB header should be present for workload identity." + end + + puts "\nFull Headers Hash (Redacted):" + redacted_headers = headers.dup + if redacted_headers[:authorization] + redacted_headers[:authorization] = "Bearer " + end + puts MultiJson.dump(redacted_headers, pretty: true) + + # Clean up + WebMock.disable! +end + +main if __FILE__ == $PROGRAM_NAME diff --git a/samples/regional_access_boundary/workload_identity_config.json b/samples/regional_access_boundary/workload_identity_config.json new file mode 100644 index 00000000..98f2c213 --- /dev/null +++ b/samples/regional_access_boundary/workload_identity_config.json @@ -0,0 +1,13 @@ +{ + "type": "external_account", + "audience": "//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID", + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "token_url": "https://sts.googleapis.com/v1/token", + "credential_source": { + "url": "http://dummyurl.com/token", + "format": { + "type": "json", + "subject_token_field_name": "access_token" + } + } +} diff --git a/spec/googleauth/compute_engine_spec.rb b/spec/googleauth/compute_engine_spec.rb index 1d545eb9..5b7eb533 100644 --- a/spec/googleauth/compute_engine_spec.rb +++ b/spec/googleauth/compute_engine_spec.rb @@ -20,6 +20,7 @@ require "faraday" require "googleauth/compute_engine" require "spec_helper" +require "googleauth" describe Google::Auth::GCECredentials do MD_ACCESS_URI = "http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token".freeze @@ -413,4 +414,23 @@ def make_auth_stubs opts expect(@creds.duplicate(universe_domain_overridden: true).instance_variable_get(:@universe_domain_overridden)).to be_truthy end end + + describe "#regional_access_boundary_url" do + it "returns the correct URL after fetching email from metadata server" do + stub_request(:get, "http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/email") + .with(headers: { "Metadata-Flavor" => "Google" }) + .to_return(body: "app@developer.gserviceaccount.com", status: 200, headers: { "Metadata-Flavor" => "Google" }) + + expect(@client.regional_access_boundary_url).to eq( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/app@developer.gserviceaccount.com/allowedLocations" + ) + end + + it "returns nil if metadata server fails" do + stub_request(:get, "http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/email") + .to_return(status: 404) + + expect(@client.regional_access_boundary_url).to be_nil + end + end end diff --git a/spec/googleauth/external_account/identity_pool_credentials_spec.rb b/spec/googleauth/external_account/identity_pool_credentials_spec.rb index 0a6173c5..69b5eedd 100644 --- a/spec/googleauth/external_account/identity_pool_credentials_spec.rb +++ b/spec/googleauth/external_account/identity_pool_credentials_spec.rb @@ -377,4 +377,48 @@ end end end + + describe "#regional_access_boundary_url" do + let(:token_url) { "https://sts.googleapis.com/v1/token" } + let(:workload_audience) { "//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID" } + let(:workforce_audience) { "//iam.googleapis.com/locations/global/workforcePools/POOL_ID/providers/PROVIDER_ID" } + let(:impersonation_url) { "https://us-east1-iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-1234@service-name.iam.gserviceaccount.com:generateAccessToken" } + + it "returns correct URL for workload identity pool" do + creds = ExternalAccountCredential.new audience: workload_audience, credential_source: CREDENTIAL_SOURCE_TEXT, token_url: token_url + expect(creds.regional_access_boundary_url).to eq( + "https://iamcredentials.googleapis.com/v1/projects/123456/locations/global/workloadIdentityPools/POOL_ID/allowedLocations" + ) + end + + it "returns correct URL for workforce identity pool" do + creds = ExternalAccountCredential.new audience: workforce_audience, credential_source: CREDENTIAL_SOURCE_TEXT, token_url: token_url + expect(creds.regional_access_boundary_url).to eq( + "https://iamcredentials.googleapis.com/v1/locations/global/workforcePools/POOL_ID/allowedLocations" + ) + end + + it "returns correct URL for service account impersonation" do + creds = ExternalAccountCredential.new( + audience: workload_audience, + credential_source: CREDENTIAL_SOURCE_TEXT, + token_url: token_url, + service_account_impersonation_url: impersonation_url + ) + expect(creds.regional_access_boundary_url).to eq( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-1234@service-name.iam.gserviceaccount.com/allowedLocations" + ) + end + + it "raises error for unknown audience format" do + creds = ExternalAccountCredential.new audience: "//invalid/format", credential_source: CREDENTIAL_SOURCE_TEXT, token_url: token_url + expect { creds.regional_access_boundary_url }.to raise_error(Google::Auth::AuthorizationError, /Unknown audience format/) + end + + it "raises error for mismatched universe domain" do + creds = ExternalAccountCredential.new audience: workload_audience, credential_source: CREDENTIAL_SOURCE_TEXT, token_url: token_url + creds.universe_domain = "invalid.com" + expect { creds.regional_access_boundary_url }.to raise_error(Google::Auth::AuthorizationError, /does not match domain in audience/) + end + end end diff --git a/spec/googleauth/helpers/connection_spec.rb b/spec/googleauth/helpers/connection_spec.rb new file mode 100644 index 00000000..17481cb2 --- /dev/null +++ b/spec/googleauth/helpers/connection_spec.rb @@ -0,0 +1,67 @@ +# 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 +# +# 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. + +require "spec_helper" +require "googleauth" + +describe Google::Auth::Helpers::Connection do + describe ".connection_for" do + let(:global_connection) { Faraday.new } + let(:custom_connection) { Faraday.new } + + before do + allow(described_class).to receive(:connection).and_return(global_connection) + end + + context "when client responds to build_default_connection" do + let(:client) do + double("client", build_default_connection: custom_connection) + end + + it "returns the result of build_default_connection" do + expect(described_class.connection_for(client)).to eq(custom_connection) + end + end + + context "when client responds to connection" do + let(:client) do + double("client", connection: custom_connection) + end + + it "returns the result of connection" do + expect(described_class.connection_for(client)).to eq(custom_connection) + end + end + + context "when client responds to both" do + let(:client) do + double("client", + build_default_connection: custom_connection, + connection: double("fallback_connection")) + end + + it "prioritizes build_default_connection" do + expect(described_class.connection_for(client)).to eq(custom_connection) + end + end + + context "when client responds to neither" do + let(:client) { double("client") } + + it "falls back to global connection" do + expect(described_class.connection_for(client)).to eq(global_connection) + end + end + end +end diff --git a/spec/googleauth/impersonated_service_account_spec.rb b/spec/googleauth/impersonated_service_account_spec.rb index a24eebf6..417f24c2 100644 --- a/spec/googleauth/impersonated_service_account_spec.rb +++ b/spec/googleauth/impersonated_service_account_spec.rb @@ -14,6 +14,7 @@ require "googleauth/impersonated_service_account" require_relative "../spec_helper" +require "googleauth" describe Google::Auth::ImpersonatedServiceAccountCredentials do @@ -361,4 +362,26 @@ def make_error_stub(status, body = "error message") end end end + + describe "#regional_access_boundary_url" do + it "returns the correct URL" do + creds = Google::Auth::ImpersonatedServiceAccountCredentials.make_creds( + base_credentials: @base_creds, + impersonation_url: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/app@developer.gserviceaccount.com:generateAccessToken", + scope: ["https://www.googleapis.com/auth/cloud-platform"] + ) + expect(creds.regional_access_boundary_url).to eq( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/app@developer.gserviceaccount.com/allowedLocations" + ) + end + + it "returns nil if URL cannot be parsed" do + creds = Google::Auth::ImpersonatedServiceAccountCredentials.make_creds( + base_credentials: @base_creds, + impersonation_url: "https://invalid.url", + scope: ["https://www.googleapis.com/auth/cloud-platform"] + ) + expect(creds.regional_access_boundary_url).to be_nil + end + end end diff --git a/spec/googleauth/regional_access_boundary/cache_spec.rb b/spec/googleauth/regional_access_boundary/cache_spec.rb new file mode 100644 index 00000000..174af255 --- /dev/null +++ b/spec/googleauth/regional_access_boundary/cache_spec.rb @@ -0,0 +1,73 @@ +# 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 +# +# 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. + +require "spec_helper" +require "googleauth" +require "googleauth/regional_access_boundary/cache" + +describe Google::Auth::RegionalAccessBoundary::Cache do + let(:cache) { described_class.new } + + describe "#get" do + it "returns nil when empty" do + expect(cache.get).to be_nil + end + + it "returns data when set" do + data = double("Data") + cache.set data, 60 + expect(cache.get).to eq data + end + + it "returns nil after expiry" do + data = double("Data") + cache.set data, 1 + sleep 2 + expect(cache.get).to be_nil + end + end + + describe "#should_fetch?" do + it "returns true when empty" do + expect(cache.should_fetch?).to be_truthy + end + + it "returns false when fetching" do + cache.try_mark_fetching! + expect(cache.should_fetch?).to be_falsey + end + + it "returns true when fetching but PID changed (fork)" do + cache.try_mark_fetching! + # Simulate fork by stubbing Process.pid + allow(Process).to receive(:pid).and_return(Process.pid + 1) + expect(cache.should_fetch?).to be_truthy + end + end + + describe "#try_mark_fetching!" do + it "returns true when empty and transitions to fetching" do + expect(cache.try_mark_fetching!).to be_truthy + # Subsequent attempts should return false because it's already fetching + expect(cache.try_mark_fetching!).to be_falsey + end + + it "returns true when fetching but PID changed (fork)" do + expect(cache.try_mark_fetching!).to be_truthy + # Simulate fork by stubbing Process.pid + allow(Process).to receive(:pid).and_return(Process.pid + 1) + expect(cache.try_mark_fetching!).to be_truthy + end + end +end diff --git a/spec/googleauth/regional_access_boundary/data_spec.rb b/spec/googleauth/regional_access_boundary/data_spec.rb new file mode 100644 index 00000000..ef832492 --- /dev/null +++ b/spec/googleauth/regional_access_boundary/data_spec.rb @@ -0,0 +1,26 @@ +# 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 +# +# 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. + +require "spec_helper" +require "googleauth" +require "googleauth/regional_access_boundary/data" + +describe Google::Auth::RegionalAccessBoundary::RegionalAccessBoundaryData do + describe "#initialize" do + it "stores encoded locations" do + data = described_class.new "0xABC" + expect(data.encoded_locations).to eq "0xABC" + end + end +end diff --git a/spec/googleauth/regional_access_boundary/fetcher_spec.rb b/spec/googleauth/regional_access_boundary/fetcher_spec.rb new file mode 100644 index 00000000..6b73f586 --- /dev/null +++ b/spec/googleauth/regional_access_boundary/fetcher_spec.rb @@ -0,0 +1,54 @@ +# 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 +# +# 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. + +require "spec_helper" +require "googleauth" +require "googleauth/regional_access_boundary" +require "googleauth/regional_access_boundary/fetcher" +require "webmock/rspec" + +describe Google::Auth::RegionalAccessBoundary::Fetcher do + let(:client) { Faraday.new } + let(:url) { "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@example.com/allowedLocations" } + let(:token) { double("Token", token_type: :access_token, access_token: "secret_token") } + let(:fetcher) { described_class.new client, url, token } + + describe "#fetch" do + it "returns RegionalAccessBoundaryData on success" do + stub_request(:get, url) + .with(headers: { "Authorization" => "Bearer secret_token" }) + .to_return(status: 200, body: '{"locations": ["us-central1"], "encodedLocations": "0xABC"}') + + data = fetcher.fetch + expect(data).to be_a Google::Auth::RegionalAccessBoundary::RegionalAccessBoundaryData + expect(data.encoded_locations).to eq "0xABC" + end + + it "retries on 500" do + stub_request(:get, url) + .to_return(status: 500) + .then.to_return(status: 200, body: '{"locations": ["us-central1"], "encodedLocations": "0xABC"}') + + allow(fetcher).to receive(:sleep) + + data = fetcher.fetch + expect(data.encoded_locations).to eq "0xABC" + end + + it "raises error on 400" do + stub_request(:get, url).to_return(status: 400) + expect { fetcher.fetch }.to raise_error(Google::Auth::AuthorizationError) + end + end +end diff --git a/spec/googleauth/regional_access_boundary/integration_spec.rb b/spec/googleauth/regional_access_boundary/integration_spec.rb new file mode 100644 index 00000000..51264d62 --- /dev/null +++ b/spec/googleauth/regional_access_boundary/integration_spec.rb @@ -0,0 +1,152 @@ +# 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 +# +# 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. + +require "spec_helper" +require "googleauth" +require "googleauth/regional_access_boundary" + +describe "RegionalAccessBoundary Integration" do + # A minimal client that includes BaseClient + class TestClient + include Google::Auth::BaseClient + + attr_accessor :access_token + attr_accessor :logger + + def initialize + @access_token = "secret_token" + @logger = nil + end + + def token_type + :access_token + end + + def id_token + "secret_id_token" + end + + def regional_access_boundary_url + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@example.com/allowedLocations" + end + + def fetch_access_token! opts = {} + # no-op + end + + def needs_access_token? + false + end + + # BaseClient requires expires_within? to be implemented + def expires_within? seconds + false + end + + def principal + "test@example.com" + end + + def supports_regional_access_boundary? + true + end + end + + let(:client) { TestClient.new } + let(:headers) { {} } + let(:url) { "https://storage.googleapis.com/v1/b/my-bucket" } + let(:cache) { Google::Auth::RegionalAccessBoundary::Cache.new } + + before do + # Stub the module-level cache to use our isolated test cache + allow(Google::Auth::RegionalAccessBoundary).to receive(:cache).and_return(cache) + # Stub Thread.new to yield immediately, running the fetch in the main thread + # to avoid WebMock thread-safety issues and ensure predictable test execution. + allow(Thread).to receive(:new).and_yield + + # Stub the allowedLocations request globally for integration specs to prevent + # unhandled WebMock errors in background threads. + stub_request(:get, /allowedLocations/) + .to_return(status: 200, body: MultiJson.dump({ "encodedLocations" => "0xABC" })) + end + + describe "applying headers" do + it "does not attach header on cold start but triggers fetch" do + client.apply! headers, url: url + + expect(headers["x-allowed-locations"]).to be_nil + expect(cache.should_fetch?).to be_falsey # It should be marked as fetching + end + + it "attaches header when cache is populated" do + data = Google::Auth::RegionalAccessBoundary::RegionalAccessBoundaryData.new "0xABC" + cache.set data, 60 + + client.apply! headers, url: url + + expect(headers["x-allowed-locations"]).to eq "0xABC" + end + + it "skips lookup for regional endpoints" do + regional_url = "https://storage.rep.googleapis.com/v1/b/my-bucket" + + client.apply! headers, url: regional_url + + expect(headers["x-allowed-locations"]).to be_nil + expect(cache.should_fetch?).to be_truthy # Should not have marked as fetching + end + + it "skips lookup for STS and IAM endpoints" do + sts_url = "https://sts.googleapis.com/v1/token" + client.apply! headers, url: sts_url + expect(headers["x-allowed-locations"]).to be_nil + expect(cache.should_fetch?).to be_truthy + + headers.clear # Reset headers + iam_url = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@example.com:generateAccessToken" + client.apply! headers, url: iam_url + expect(headers["x-allowed-locations"]).to be_nil + expect(cache.should_fetch?).to be_truthy + end + + it "fails open if lookup raises error" do + mock_fetcher = double("Fetcher") + allow(Google::Auth::RegionalAccessBoundary::Fetcher).to receive(:new).and_return(mock_fetcher) + allow(mock_fetcher).to receive(:fetch).and_raise(Google::Auth::AuthorizationError, "Network error") + + client.apply! headers, url: url + + expect(headers["x-allowed-locations"]).to be_nil + expect(cache.should_fetch?).to be_falsey # Should be in cooldown + end + + it "skips lookup for unsupported credential types" do + allow(client).to receive(:supports_regional_access_boundary?).and_return(false) + + client.apply! headers, url: url + + expect(headers["x-allowed-locations"]).to be_nil + expect(cache.should_fetch?).to be_truthy # Should not have marked as fetching + end + + it "skips lookup for ID tokens" do + allow(client).to receive(:token_type).and_return(:id_token) + + client.apply! headers, url: url + + expect(headers["x-allowed-locations"]).to be_nil + expect(cache.should_fetch?).to be_truthy # Should not have marked as fetching + end + end +end diff --git a/spec/googleauth/service_account_spec.rb b/spec/googleauth/service_account_spec.rb index 4d9071db..a032a6b9 100644 --- a/spec/googleauth/service_account_spec.rb +++ b/spec/googleauth/service_account_spec.rb @@ -420,4 +420,12 @@ def cred_json_text_with_universe_domain expect(@creds.duplicate(enable_self_signed_jwt: true).enable_self_signed_jwt?).to eq true end end + + describe "#regional_access_boundary_url" do + it "returns the correct URL" do + expect(@client.regional_access_boundary_url).to eq( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/app@developer.gserviceaccount.com/allowedLocations" + ) + end + end end diff --git a/spec/googleauth/signet_spec.rb b/spec/googleauth/signet_spec.rb index e87b5a38..90778c77 100644 --- a/spec/googleauth/signet_spec.rb +++ b/spec/googleauth/signet_spec.rb @@ -17,6 +17,7 @@ $LOAD_PATH.uniq! require "apply_auth_examples" +require "googleauth" require "googleauth/signet" require "jwt" require "openssl" diff --git a/spec/googleauth/user_refresh_spec.rb b/spec/googleauth/user_refresh_spec.rb index 4a6324b7..c5207f78 100644 --- a/spec/googleauth/user_refresh_spec.rb +++ b/spec/googleauth/user_refresh_spec.rb @@ -19,6 +19,7 @@ require "apply_auth_examples" require "fakefs/safe" require "fileutils" +require "googleauth" require "googleauth/user_refresh" require "jwt" require "multi_json"